@SpringBootTest và các test slice
Tiếp đi: test có Spring. Mình đang để @SpringBootTest cho mọi class test, giờ suite chạy gần 8 phút. Vì sao chậm vậy, và nên làm sao?
Chậm vì mỗi lần Spring phải dựng toàn bộ ApplicationContext, và có thể còn dựng lại nhiều lần. Có hai đòn bẩy: dùng slice khi chỉ cần một tầng, và giữ cho context cache trúng.
1. Bài toán
shop-app cần test những thứ mà unit test + Mockito (bài 5.3) không kiểm tra được:
OrderRepository.findPendingOlderThan(...): query JPQL có đúng không.OrderResponseserialize ra JSON đúng format tiền, ngày.PaymentClient(dùngRestClient) gọi đúng URL, parse lỗi 402.OrderControllertrả 201/400/409 đúng.
2. Vấn đề: @SpringBootTest cho mọi thứ
@SpringBootTest ← load: Web, JPA, Security, Kafka, Redis, Scheduler, Actuator...
class OrderJsonTest { ... } ← chỉ cần ObjectMapper!
- Load mọi auto-configuration, mọi bean, kết nối DB/Kafka/Redis: 5–20 giây mỗi context.
- Mỗi class test có cấu hình khác nhau (
@MockitoBeankhác, property khác) → context mới → khởi động lại. - Test JSON mà fail vì Kafka không chạy: lỗi không liên quan.
3. Giải pháp: test slice
Slice chỉ bật auto-configuration và bean của một tầng:
| Annotation | Load gì | Không load |
|---|---|---|
@WebMvcTest | Controller, @ControllerAdvice, filter, converter, MockMvc | @Service, @Repository |
@DataJpaTest | Entity, repository, DataSource, Flyway; mỗi test @Transactional rollback | Web, service |
@JsonTest | ObjectMapper, @JsonComponent, JacksonTester | Gần như mọi thứ khác |
@RestClientTest | RestClient.Builder/RestTemplateBuilder, MockRestServiceServer | Web server, DB |
@SpringBootTest | Toàn bộ app | - |
@WebMvcTest đã có bài riêng: Test controller (4.2.13). Test security: 4.4.10. Ở đây tập trung vào các slice còn lại và cơ chế chung.
4. Code từng bước
Bước 1: Dependency. spring-boot-starter-test. Từ Boot 3.4 có @MockitoBean (thay @MockBean đã deprecated).
Bước 2: @DataJpaTest
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // không thay bằng H2, dùng DB thật (bài 5.5)
@Import(TestcontainersConfig.class)
class OrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@Autowired TestEntityManager em;
@Test
void findPendingOlderThan_returnsOnlyOldPendingOrders() {
em.persist(Order.pending(42L, Instant.parse("2026-10-01T00:00:00Z")));
em.persist(Order.pending(42L, Instant.parse("2026-10-13T00:00:00Z")));
em.persist(Order.paid(42L, Instant.parse("2026-10-01T00:00:00Z")));
em.flush(); em.clear(); // buộc đọc lại từ DB, không lấy từ persistence context
var result = orderRepository.findPendingOlderThan(Instant.parse("2026-10-10T00:00:00Z"));
assertThat(result).hasSize(1).first()
.extracting(Order::getStatus).isEqualTo(OrderStatus.PENDING);
}
}
Bước 3a: @JsonTest
@JsonTest
class OrderResponseJsonTest {
@Autowired JacksonTester<OrderResponse> json;
@Test
void serializesMoneyAsStringAndDateAsIso() throws Exception {
var res = new OrderResponse(7L, new BigDecimal("180.00"), Instant.parse("2026-10-14T03:00:00Z"));
assertThat(json.write(res))
.extractingJsonPathStringValue("$.total").isEqualTo("180.00");
assertThat(json.write(res))
.extractingJsonPathStringValue("$.createdAt").isEqualTo("2026-10-14T03:00:00Z");
}
}
Bước 3b: @RestClientTest cho PaymentClient
@RestClientTest(PaymentClient.class)
class PaymentClientTest {
@Autowired PaymentClient client;
@Autowired MockRestServiceServer server; // chặn request của RestClient.Builder
@Test
void declined402_mapsToDeclinedResult() {
server.expect(requestTo("/payments"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.orderId").value(7))
.andRespond(withStatus(HttpStatus.PAYMENT_REQUIRED)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"code\":\"NO_FUNDS\"}"));
PaymentResult r = client.charge(7L, Money.of(180));
assertThat(r.success()).isFalse();
assertThat(r.reason()).isEqualTo("NO_FUNDS");
}
}
PaymentClient phải tạo RestClient từ RestClient.Builder được inject thì MockRestServiceServer mới chặn được.
Bước 3c: @SpringBootTest cho luồng đầy đủ, mock ở biên
@SpringBootTest
@Import(TestcontainersConfig.class)
class CreateOrderFlowIT {
@Autowired OrderService orderService;
@MockitoBean PaymentClient paymentClient; // thay bean thật trong context
@Test
void createsOrderAndPersists() {
when(paymentClient.charge(anyLong(), any())).thenReturn(PaymentResult.ok());
...
}
}
Bước 3d: @TestConfiguration
@TestConfiguration(proxyBeanMethods = false)
class FixedClockConfig {
@Bean @Primary
Clock testClock() { return Clock.fixed(Instant.parse("2026-10-14T00:00:00Z"), ZoneOffset.UTC); }
}
// dùng: @Import(FixedClockConfig.class) — không bị component scan nhặt nhầm
Bước 4: Chạy và đo
./mvnw test
# log khi context được tạo:
# Started OrderRepositoryTest in 2.1 seconds
# bật log để thấy cache trúng/trượt:
logging.level.org.springframework.test.context.cache=debug
# Spring test ApplicationContext cache statistics: [size = 4, hitCount = 37, missCount = 4]
5. Cách hoạt động: context caching
Cache key (MergedContextConfiguration) =
các class cấu hình + slice/auto-config + active profiles
+ property (@TestPropertySource, properties=...) + @MockitoBean/@MockitoSpyBean
+ context customizer (@ServiceConnection, @DynamicPropertySource...)
Test class A ─ key K1 ─┐
Test class B ─ key K1 ─┼─► cùng 1 context (khởi động 1 lần)
Test class C ─ key K2 ─┴─► context mới (khởi động lại)
@DirtiesContext → xóa context khỏi cache → class sau phải dựng lại
Cache tối đa 32 context (spring.test.context.cache.maxSize), LRU
Mẹo giữ cache trúng: gom @MockitoBean và @Import chung vào một abstract base class, tránh mỗi class một bộ mock khác nhau, tránh @DirtiesContext.
6. So sánh
| Unit + Mockito | Slice | @SpringBootTest | |
|---|---|---|---|
| Khởi động | 0 | 1–3 giây | 5–20 giây |
| Kiểm tra | Logic | Một tầng có Spring thật (JSON, JPA, MVC) | Wiring, transaction, luồng |
| Phụ thuộc ngoài | Không | Tùy slice (DataJpaTest cần DB) | DB, broker... (Testcontainers) |
| Số lượng nên có | Nhiều | Vừa | Ít, có chọn lọc |
@MockitoBean vs @Mock: @Mock chỉ tạo mock, không có Spring. @MockitoBean thay bean trong ApplicationContext và là một phần của cache key.
7. Khi nào nên dùng / không nên
- Query, mapping, constraint →
@DataJpaTest(với PostgreSQL thật). - Format JSON phức tạp, custom serializer →
@JsonTest. - HTTP client tự viết →
@RestClientTest; cần mô phỏng mạng thật (timeout, delay) → WireMock. - Bean wiring, transaction xuyên tầng, luồng chính →
@SpringBootTest, số lượng ít. - Không dùng
@SpringBootTestđể test logic thuần. Không rải@DirtiesContextđể "chữa" test bẩn state.
@DataJpaTest mặc định thay DataSource bằng H2. Test xanh trên H2 nhưng câu query dùng jsonb, ON CONFLICT hay FOR UPDATE SKIP LOCKED thì production PostgreSQL vỡ. Cần DB thật trong test: Testcontainers.@DataJpaTest tự rollback sau mỗi test, vậy có phải lúc nào cũng tốt không?
Phần lớn là tốt (DB sạch cho test sau), nhưng nó có thể che lỗi:
- Không có commit → constraint
DEFERRABLEkhông chạy, listener@TransactionalEventListener(AFTER_COMMIT)không bao giờ được gọi. - Không
flush→INSERTchưa xuống DB, vi phạm unique/NOT NULL chưa lộ. Vì vậy ví dụ ở trên gọiem.flush(); em.clear();. - Test và code dùng chung một transaction → lazy loading "chạy được" trong test nhưng lỗi
LazyInitializationExceptionở production.
Bài 5.6 đi sâu vào bẫy transaction trong test và cách test concurrency.
Mình có 40 class @SpringBootTest, mỗi class @MockitoBean một kiểu. Có phải đó là lý do suite chậm không?
Đúng rồi, gần như chắc chắn. Mỗi tổ hợp @MockitoBean khác nhau là một cache key khác → một context mới. 40 class có thể thành 30 lần khởi động.
@SpringBootTest
@Import(TestcontainersConfig.class)
public abstract class IntegrationTestBase {
@MockitoBean protected PaymentClient paymentClient; // biên ngoài dùng chung
@MockitoBean protected EmailSender emailSender;
}
class CreateOrderFlowIT extends IntegrationTestBase { ... }
class CancelOrderFlowIT extends IntegrationTestBase { ... } // cùng context
Mock được reset sau mỗi test (mặc định MockReset.AFTER), nên dùng chung an toàn. Kiểm tra lại bằng log cache statistics: missCount phải nhỏ.
Phỏng vấn hỏi test với Spring Boot thì trả lời sao?
Q1. Test slice là gì, tại sao không dùng @SpringBootTest cho mọi test?
🇻🇳 Tiếng Việt:
Slice như @WebMvcTest, @DataJpaTest, @JsonTest, @RestClientTest chỉ bật auto-configuration và bean của một tầng, nên khởi động nhanh và lỗi tập trung đúng chỗ. @SpringBootTest load toàn bộ app, chậm và cần mọi hạ tầng. Em dùng slice cho từng tầng và chỉ vài @SpringBootTest cho luồng chính.
🇬🇧 English:
Slices such as @WebMvcTest, @DataJpaTest, @JsonTest and @RestClientTest only enable the auto-configuration and beans of one layer, so they start fast and failures point to the right place. @SpringBootTest loads the whole application, which is slow and needs all the infrastructure. I use slices per layer and a few @SpringBootTest tests for the main flows.
Q2. Spring test context caching hoạt động thế nào?
🇻🇳 Tiếng Việt:
Spring cache ApplicationContext theo cấu hình: class cấu hình, profile, property, các bean mock. Test class có cùng cấu hình dùng lại context. Mỗi @MockitoBean hay property khác nhau tạo context mới, @DirtiesContext xóa context. Để suite nhanh em gom cấu hình vào base class và tránh @DirtiesContext.
🇬🇧 English:
Spring caches the ApplicationContext keyed by its configuration: configuration classes, profiles, properties and mocked beans. Test classes with the same configuration reuse the context. Each different @MockitoBean or property creates a new context, and @DirtiesContext evicts it. To keep the suite fast I share configuration through a base class and avoid @DirtiesContext.
Q3. @Mock và @MockitoBean khác nhau thế nào?
🇻🇳 Tiếng Việt:
@Mock là của Mockito, tạo mock thuần cho unit test, không có Spring. @MockitoBean (thay @MockBean từ Boot 3.4) thay hoặc thêm một bean trong ApplicationContext bằng mock, dùng trong slice hay @SpringBootTest. Nó là một phần của cache key nên dùng tùy tiện sẽ làm chậm suite.
🇬🇧 English:
@Mock is plain Mockito and creates a mock for unit tests without Spring. @MockitoBean, which replaces @MockBean since Boot 3.4, replaces or adds a bean in the ApplicationContext with a mock, used in slices or @SpringBootTest. It is part of the context cache key, so using it carelessly slows the suite down.
Cách nhớ ngắn gọn
- Một tầng → một slice; cả luồng → @SpringBootTest, ít thôi.
- Context được cache theo cấu hình; khác mock/property = khởi động lại.
- Gom @MockitoBean vào base class, tránh @DirtiesContext.
- @DataJpaTest rollback: nhớ flush/clear, và nó mặc định dùng H2.
Tự kiểm tra
@DataJpaTest có load @Service không?
Không. Chỉ JPA: entity, repository, DataSource, migration.
Vì sao gọi em.flush(); em.clear(); trước khi query trong test?
Đẩy SQL xuống DB và xóa persistence context để đọc thật từ DB, lộ lỗi constraint/mapping.
Hai test class khác nhau một @MockitoBean thì có dùng chung context không?
Không, cache key khác nên tạo context mới.
@RestClientTest chặn request bằng gì?
MockRestServiceServer, gắn vào RestClient.Builder/RestTemplateBuilder được auto-config.
Vì sao dùng @TestConfiguration + @Import thay vì @Configuration trong thư mục test?
Không bị component scan nhặt vào mọi test, chỉ áp dụng nơi import.