Backend

99 Ngày Spring — Ngày 54: @SpringBootTest

SSite Admin
19 tháng 09, 2026 8 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 54: @SpringBootTest

Ba ngày qua ta cắt ứng dụng thành từng lát: service, web, dữ liệu. Hôm nay ghép tất cả lại — @SpringBootTest nạp toàn bộ ứng dụng và gọi request qua HTTP thật. Nhưng câu hỏi thú vị không phải "làm thế nào" mà là "khi nào thì xứng đáng": mỗi lần nạp context tốn ba tới mười lăm giây, và dùng sai chỗ thì bộ test của bạn chạy bốn mươi phút thay vì hai phút.

Sketchnote Ngày 54: SpringBootTest — bốn lựa chọn webEnvironment, cache context và bốn thứ phá cache, lớp cha gom cấu hình, TestRestTemplate và WebTestClient, và vì sao WithMockUser vô nghĩa ở đây

Khi nào thì xứng đáng, và chọn webEnvironment nào

@SpringBootTest chỉ nên dành cho những gì test nhỏ không chứng minh được: cả luồng đặt hàng có chạy thông không, cấu hình có ghép đúng không, transaction có trải đúng qua nhiều service không. Logic nghiệp vụ vẫn ở unit test của Ngày 51 — kim tự tháp là nhiều unit test, ít slice test, rất ít test tích hợp.

// @SpringBootTest — nạp TOÀN BỘ ứng dụng. Câu hỏi không phải "làm thế nào",
// mà là "KHI NÀO thì xứng đáng".

@SpringBootTest(webEnvironment = RANDOM_PORT)   // ⭐ dựng server thật trên cổng ngẫu nhiên
class DatHangIntegrationTest {

    @Autowired TestRestTemplate rest;           // client HTTP thật, tự trỏ đúng cổng
    @Autowired DonHangRepository repo;          // và bạn xem được DB để kiểm chứng

    @Test
    void dat_hang_thi_luu_don_va_tru_ton_kho() {
        var res = rest.postForEntity("/api/don",
                new TaoDonRequest("SP-01", 2), DonHangResponse.class);

        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(repo.findAll()).hasSize(1);              // ⭐ đi qua CẢ ngăn xếp
        assertThat(khoRepo.tonKho("SP-01")).isEqualTo(98);  //    và kiểm chứng ở tầng DB
    }
}

// ⭐ BỐN LỰA CHỌN webEnvironment, và chọn sai là tự làm khổ mình:
//   MOCK (mặc định)  — KHÔNG có server, dùng MockMvc. Nhanh nhất.
//   RANDOM_PORT      — server THẬT, cổng ngẫu nhiên. ⭐ Dùng cái này cho test tích hợp:
//                      chạy song song thoải mái, không bao giờ đụng cổng trên CI.
//   DEFINED_PORT     — server thật, cổng 8080. ❌ Hai test song song là vỡ.
//   NONE             — không web gì cả, chỉ context.

// ❗ GIÁ PHẢI TRẢ, nói thẳng: 3–15 giây MỖI LẦN nạp context.
//    ➜ Nên @SpringBootTest chỉ dành cho những gì test nhỏ KHÔNG chứng minh được:
//      "cả luồng đặt hàng có chạy thông không", "cấu hình có ghép đúng không",
//      "transaction có trải đúng qua nhiều service không".
//    ➜ Logic nghiệp vụ vẫn là unit test (Ngày 51). Đừng đưa lên đây.
//      Kim tự tháp: nhiều unit test, ít slice test, RẤT ít @SpringBootTest.
  • RANDOM_PORT là lựa chọn đúng cho test tích hợp — chạy song song thoải mái và không bao giờ đụng cổng trên CI, khác hẳn DEFINED_PORT.

  • MOCK là mặc định và nhanh nhất, nhưng nó dùng MockMvc — nghĩa là bạn đang làm lại việc của @WebMvcTest với giá đắt hơn nhiều.

  • Giá trị thật của test tích hợp là nó đi qua cả ngăn xếp rồi bạn kiểm chứng được ở tầng database.

Cache context — thứ quyết định 2 phút hay 20 phút

Đây là phần ít người biết mà ảnh hưởng lớn nhất. Spring cache lại context và tái sử dụng giữa các class test, với khóa cache là toàn bộ cấu hình. Khác một chút — thêm một @MockitoBean, đổi một property — là một context mới, cộng thêm mười lăm giây.

// CACHE CONTEXT — thứ quyết định bộ test của bạn chạy 2 phút hay 20 phút.

// ⭐ Spring CACHE LẠI context và TÁI SỬ DỤNG giữa các class test.
//    Khóa cache là toàn bộ CẤU HÌNH: classes, profile, properties, mock bean...
//    Cấu hình GIỐNG NHAU → dùng lại context cũ → nạp một lần cho cả trăm test.
//    Cấu hình KHÁC một chút → nạp context MỚI → cộng thêm 3–15 giây.

// ❗ BỐN THỨ ÂM THẦM PHÁ CACHE — mỗi cái là một context mới:
@SpringBootTest @ActiveProfiles("test")                    // context A
@SpringBootTest @ActiveProfiles("test") @MockitoBean X x;  // context B — khác A!
@SpringBootTest(properties = "a=1")                        // context C
@SpringBootTest @DirtiesContext                            // ⭐ HỦY cache, nạp lại

// ➜ Một dự án có 30 class test với 12 tổ hợp cấu hình khác nhau
//   = 12 lần nạp context = vài phút chỉ để KHỞI ĐỘNG.
// ➜ MẸO THỰC DỤNG: gom cấu hình chung vào MỘT lớp cha rồi kế thừa:
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ActiveProfiles("test")
abstract class BaseIntegrationTest { }        // ⭐ mọi test kế thừa → MỘT context duy nhất

class DatHangTest extends BaseIntegrationTest { }
class ThanhToanTest extends BaseIntegrationTest { }

// ❗ @DirtiesContext là công cụ ĐẮT NHẤT trong bộ test: nó vứt cache đi.
//    Chỉ dùng khi test thật sự làm hỏng trạng thái context (đổi bean, đóng tài nguyên).
//    Nhiều người rắc nó khắp nơi "cho chắc" rồi thắc mắc sao CI chạy 40 phút.

// PROFILE TEST — tách cấu hình test khỏi cấu hình thật (Ngày 09):
// src/test/resources/application-test.yml
spring.jpa.hibernate.ddl-auto: create-drop
logging.level.org.hibernate.SQL: DEBUG
// ❗ ĐỪNG để test trỏ vào DB thật. Nghe hiển nhiên, nhưng chuyện này xảy ra
//    thường xuyên hơn bạn tưởng — và ddl-auto: create-drop thì XÓA SẠCH bảng.
  • Gom cấu hình chung vào một lớp cha rồi kế thừa — ba mươi class test dùng chung một context thay vì mười hai lần nạp.

  • @DirtiesContextcông cụ đắt nhất trong bộ test: nó vứt cache đi. Nhiều người rắc nó khắp nơi "cho chắc" rồi thắc mắc sao CI chạy bốn mươi phút.

  • Đừng để test trỏ vào DB thật — nghe hiển nhiên nhưng xảy ra thường xuyên hơn bạn tưởng, và ddl-auto: create-drop thì xóa sạch bảng.

Client, và cái bẫy xác thực

TestRestTemplate gọn cho MVC truyền thống, WebTestClient có API fluent và dùng được cho cả hai — nhiều dự án mới chọn nó để đội chỉ phải học một cú pháp. Nhưng cái đáng nhớ nhất ở đây là một cái bẫy: @WithMockUser hoàn toàn vô nghĩa trong test tích hợp.

// TESTRESTTEMPLATE hay WEBTESTCLIENT — chọn theo API bạn xây, không theo mốt.

// ① TestRestTemplate — đơn giản, đồng bộ, hợp với Spring MVC truyền thống:
var res = rest.getForEntity("/api/don/1", DonHangResponse.class);
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);

// ⭐ Nó KHÔNG ném ngoại lệ khi gặp 4xx/5xx (khác RestTemplate thường) —
//    nên bạn khẳng định được mã lỗi thay vì phải bọc try-catch.
var loi = rest.getForEntity("/api/don/999", String.class);
assertThat(loi.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);   // ✅ gọn

// ② WebTestClient — API fluent, dùng được cho CẢ WebFlux lẫn MVC:
webTestClient.get().uri("/api/don/1")
    .exchange()
    .expectStatus().isOk()
    .expectBody()
        .jsonPath("$.maSanPham").isEqualTo("SP-01");
// ⭐ Từ Spring 5, WebTestClient chạy được với MVC luôn, nên nhiều dự án mới
//    dùng nó cho tất cả để đội chỉ phải học MỘT cú pháp.

// ❗ XÁC THỰC trong test tích hợp — @WithMockUser KHÔNG có tác dụng ở đây!
//    Vì request đi qua HTTP THẬT, không phải gọi trong tiến trình như MockMvc.
rest.withBasicAuth("user", "pass").getForEntity(...);          // ✅ đăng nhập thật
// hoặc lấy token thật rồi gắn vào header:
var headers = new HttpHeaders();
headers.setBearerAuth(layTokenThat());
// ⭐ Đây là khác biệt hay làm người ta mất cả buổi: cùng một annotation
//    chạy tốt ở @WebMvcTest hôm kia nhưng vô nghĩa ở @SpringBootTest RANDOM_PORT.

// ❗ VÀ NHỚ: @Transactional trên test tích hợp có server thật thì KHÔNG rollback
//    được thao tác của server, vì server chạy trên LUỒNG KHÁC với transaction của test.
//    ➜ Phải tự dọn dữ liệu, hoặc dùng Testcontainers dựng DB mới mỗi lần (Ngày 55).
  • Request đi qua HTTP thật, không phải gọi trong tiến trình như MockMvc — nên phải đăng nhập thật hoặc gắn token thật vào header.

  • Cùng một annotation chạy tốt ở @WebMvcTest hôm kia mà vô dụng ở đây — khác biệt này làm người ta mất cả buổi.

  • TestRestTemplate không ném ngoại lệ khi gặp 4xx/5xx, nên bạn khẳng định được mã lỗi thay vì bọc try-catch.

  • @Transactional không rollback được thao tác của server vì server chạy trên luồng khác — phải tự dọn, hoặc dựng DB mới mỗi lần (Ngày 55).

Bài tập nhỏ

  • Viết một test tích hợp với RANDOM_PORT đi từ HTTP xuống tới kiểm chứng ở database.

  • Thêm một @MockitoBean vào một class test rồi đo xem tổng thời gian chạy tăng bao nhiêu.

  • Gom hai class test vào một lớp cha chung và so số lần nạp context trong log.

  • Thử @WithMockUser trên test RANDOM_PORT để thấy nó không có tác dụng gì.

  • Viết một test ghi dữ liệu qua HTTP rồi xem @Transactional có rollback được không.

Kết lại

Bốn ý gói lại hôm nay: @SpringBootTest chỉ dành cho những gì test nhỏ không chứng minh được — kim tự tháp là nhiều unit test, rất ít test tích hợp; RANDOM_PORT là lựa chọn đúng vì nó không bao giờ đụng cổng trên CI; cache context quyết định bộ test chạy hai phút hay bốn mươi phút, nên hãy gom cấu hình vào một lớp cha và đừng rắc @DirtiesContext cho chắc; và @WithMockUser vô nghĩa ở đây vì request đi qua HTTP thật — phải đăng nhập thật. Ngày 55 khép lại năm ngày kiểm thử bằng Testcontainers: Postgres thật trong container, @ServiceConnection, và cách chạy trong CI. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 54: Stream API nhập môn

Dây chuyền ba phần và luật không có phép kết thúc thì không chạy gì, tính lười cho ngắt sớm và dữ liệu vô hạn, vì sao lọc trước sắp sau nhanh hơn hàng chục lần — và khi nào vòng lặp vẫn thắng.

19 thg 9, 20268 phút0
99 Ngày Spring — Ngày 53: @DataJpaTest

Mỗi test là một transaction bị rollback, flush và clear quyết định test có chứng minh được gì không, vì sao "xanh trên H2 chết trên production" làm cả đội mất niềm tin — và Testcontainers với @ServiceConnection.

18 thg 9, 20268 phút6
99 Ngày Java — Ngày 53: Method reference

Bốn loại và loại thứ ba hay nhầm nhất, khi nào method reference dễ đọc hơn lambda và khi nào không, cái bẫy NPE nổ ngay lúc tạo reference — cùng nơi nó đáng giá nhất: Comparator và groupingBy.

18 thg 9, 20267 phút5