@SpringBootTest và các test slice

4 câu hỏi~25 phútTừ khóa: @SpringBootTest, test slice, @WebMvcTest, @DataJpaTest, @JsonTest, @RestClientTest, MockRestServiceServer, context caching, @MockitoBean, @TestConfiguration, @DirtiesContext

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:

2. Vấn đề: @SpringBootTest cho mọi thứ

@SpringBootTest  ← load: Web, JPA, Security, Kafka, Redis, Scheduler, Actuator...
class OrderJsonTest { ... }   ← chỉ cần ObjectMapper!

3. Giải pháp: test slice

Slice chỉ bật auto-configuration và bean của một tầng:

AnnotationLoad gìKhông load
@WebMvcTestController, @ControllerAdvice, filter, converter, MockMvc@Service, @Repository
@DataJpaTestEntity, repository, DataSource, Flyway; mỗi test @Transactional rollbackWeb, service
@JsonTestObjectMapper, @JsonComponent, JacksonTesterGần như mọi thứ khác
@RestClientTestRestClient.Builder/RestTemplateBuilder, MockRestServiceServerWeb server, DB
@SpringBootTestToà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 + MockitoSlice@SpringBootTest
Khởi động01–3 giây5–20 giây
Kiểm traLogicMột tầng có Spring thật (JSON, JPA, MVC)Wiring, transaction, luồng
Phụ thuộc ngoàiKhôngTùy slice (DataJpaTest cần DB)DB, broker... (Testcontainers)
Số lượng nên cóNhiềuVừ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

@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:

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


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.