Hình dung việc dựng một ngữ cảnh test của Spring như khởi động cả một cái máy: chậm, nên bạn muốn bật một lần rồi chạy mọi thứ trên đó. Spring đệm ngữ cảnh lại và dùng chung một lần khởi động cho mọi test có cùng cấu hình — nhưng lệch đúng một cái nút là nó tắt máy và khởi động lại từ đầu. Chặng cuối bắt đầu, và bài này đo các cách viết test trong Spring Boot, tìm ra thứ thật sự làm chúng chậm.

Ba loại, ba con số

  @SpringBootTest      Started in 0,852 s | 191 bean
  @WebMvcTest          Started in 0,792 s | 132 bean
  test đơn vị thuần    không khởi động context | 0 bean

Lời khuyên phổ biến là "dùng lát cắt, nó nhanh hơn nhiều". Trên ứng dụng này, chênh lệch là 60 mili giây.

Lý do rất rõ khi nhìn số bean: 191 so với 132. Ứng dụng thử nghiệm của tôi chỉ có web và security — không có JPA, không có CSDL. Lát cắt chỉ tiết kiệm được thứ nó loại ra, mà ở đây gần như không có gì để loại.

Trên ứng dụng thật với JPA, Flyway, Redis và pool kết nối, khác biệt sẽ lớn hơn nhiều. Nhưng bài học là: đừng giả định, hãy đo trên chính dự án của bạn.

Thứ thật sự làm bộ test chậm

  4 lớp test, CÙNG cấu hình  : 2916 ms | context khởi động 1 lần
  4 lớp test, MỖI lớp khác   : 3422 ms | context khởi động 4 lần

Spring đệm lại ngữ cảnh test và dùng chung cho mọi lớp có cùng cấu hình. Khác cấu hình một chút là một ngữ cảnh mới — một lần khởi động lại cả máy.

Trên ứng dụng này, một ngữ cảnh khởi động trong 0,8 giây nên bốn cái chỉ tốn thêm nửa giây. Nhưng trên ứng dụng thật khởi động 5 giây, đó là 20 giây thay vì 5 — và bộ test có 30 cấu hình khác nhau thì bạn mất hai phút rưỡi chỉ để dựng ngữ cảnh.

Những thứ tạo ra ngữ cảnh mới:

@SpringBootTest(properties = "a=1")           // mỗi bộ properties khác nhau
@ActiveProfiles("test-rieng")                 // mỗi tổ hợp profile
@MockBean DichVuNaoDo x;                      // mỗi tổ hợp @MockBean
@TestPropertySource(...)

@MockBean là thủ phạm hay gặp nhất: nó thay bean trong ngữ cảnh, nên mỗi tổ hợp @MockBean khác nhau là một ngữ cảnh riêng.

Ba cách giữ số ngữ cảnh nhỏ:

Gom cấu hình test vào một lớp cha rồi kế thừa. Mọi test dùng chung một ngữ cảnh.

Dùng @MockBean tiết kiệm. Cần giả lập thì cân nhắc tiêm một cài đặt giả qua cấu hình test dùng chung, thay vì @MockBean rải rác.

Đo số ngữ cảnh:

mvn test | grep -c "Started .* in .* seconds"

Con số đó lớn hơn số bạn nghĩ nhiều lần — tôi chưa gặp dự án nào không bất ngờ với nó.

Các lát cắt có sẵn

Lát cắt Nạp gì Dùng cho
@WebMvcTest controller, filter, MockMvc tầng web, có @MockBean cho service
@DataJpaTest JPA, repository, CSDL nhúng truy vấn (bài 36)
@JsonTest Jackson tuần tự hoá (bài 15)
@RestClientTest RestClient, MockRestServiceServer gọi HTTP ra ngoài (bài 20)
@DataRedisTest Redis bài 37
@SpringBootTest tất cả test tích hợp

@WebMvcTest(DonController.class) chỉ nạp một controller — nhanh hơn và ít phụ thuộc hơn nữa.

@RestClientTest đáng dùng hơn nhiều người nghĩ: nó cho bạn giả lập dịch vụ ngoài ở mức HTTP thay vì giả lập client, nên bạn test được cả phần ánh xạ JSON và xử lý mã lỗi.

Kim tự tháp, và vì sao nó vẫn đúng

Nhiều test đơn vị. Không Spring, không context, chạy mili giây. Logic nghiệp vụ phải test được như vậy — nếu không thì đó là dấu hiệu logic đang dính chặt vào framework.

Vừa phải test lát cắt. Kiểm phần Spring làm hộ: ánh xạ request, truy vấn, tuần tự hoá.

Ít test đầu-cuối. Chậm, dễ chập chờn, nhưng chúng bắt được thứ hai tầng trên bỏ lọt — bài 47 vừa đo một trường hợp: MockMvc nói 403, máy chủ thật trả 405.

Sai lầm tôi thấy nhiều nhất là đảo ngược: mọi test đều là @SpringBootTest. Bộ test chạy mười lăm phút, không ai chạy nó trước khi đẩy mã, và giá trị của nó về gần không.

Viết logic để test được không cần Spring

// khó test: phụ thuộc trộn lẫn với logic
@Service class DvDon {
    @Autowired RepoDon repo;
    public BigDecimal tinhTien(Long id) {
        var d = repo.findById(id).orElseThrow();
        return d.getGia().multiply(...);      // logic nằm chung với truy cập dữ liệu
    }
}

// dễ test: logic là hàm thuần
class TinhTien {
    static BigDecimal tinh(Don d, ChinhSach cs) { ... }   // không Spring, không CSDL
}

Tách được như vậy thì phần lớn quy tắc nghiệp vụ test bằng JUnit thuần, chạy trong mili giây, và không có ngữ cảnh nào để đệm.

Đây cũng là nơi những bài về SOLID trả công.

Bốn điều đã gặp

MockMvc .param() cộng dồn giá trị chứ không ghi đè. Gọi hai lần cùng tên sẽ gửi hai giá trị và Spring nối chúng bằng dấu phẩy.

Test dùng @Transactional giữ dữ liệu chưa commit, nên thao tác chạy trên kết nối khác sẽ không nhìn thấy — đó là hành vi đúng, đừng "sửa" nó.

Cộng số test từ target/surefire-reports/ sẽ thiếu: lớp @Nested được ghi thành báo cáo riêng còn lớp cha hiện 0. Lấy con số tổng ở dòng cuối của mvn test.

Vài test cố tình sinh lỗi. Log có dòng ERROR là bình thường — miễn là bạn biết test nào sinh ra chúng.

@MockBean sắp bị thay

Từ Spring Boot 3.4, @MockBean và @SpyBean đã được đánh dấu ngừng dùng, thay bằng:

@MockitoBean DichVuNgoai dichVu;
@MockitoSpyBean DichVuThat that;

Cùng ý tưởng, nằm trong spring-test thay vì spring-boot-test. Dự án mới nên dùng tên mới ngay.

Nếu chỉ đo một thứ sau bài này, đếm số lần cái máy bị khởi động lại:

mvn test | grep -c "Started .* in .* seconds"

Đó là số ngữ cảnh Spring mà bộ test của bạn dựng lên. Nhân nó với thời gian khởi động một ngữ cảnh, và bạn có phần lớn thời gian chạy test — cùng với danh sách chính xác thứ cần gom lại.

Mẫu số chung

Hai ý trong bài này vượt khỏi Spring. Một là kim tự tháp test của Mike Cohn — nhiều đơn vị, vừa phải tích hợp, ít đầu-cuối — mô hình ngôn ngữ nào cũng cãi nhau quanh nó (Kent C. Dodds còn đề xuất "chiếc cúp test" nghiêng về tích hợp). Hai là thứ sâu hơn: thời gian chạy của một bộ test bị chi phối bởi việc bạn trả cái phí thiết lập đắt đỏ bao nhiêu lần, không phải bởi logic trong từng test.

Cái phí đó — một ngữ cảnh framework, một CSDL, một trình duyệt — là cố định mỗi lần trả, nên mọi hệ sinh thái đều mọc ra cách nhiếp nó lại: bộ đệm ngữ cảnh của Spring, fixture scope="session" của pytest, @BeforeAll của JUnit, một Testcontainer hay một trình duyệt Playwright dùng lại cho cả bộ. Nước đi thắng luôn giống nhau: dựng cái đắt một lần, chỉ thay những đầu vào rẻ.

Và cái test rẻ nhất là cái không cần thiết lập gì cả — điều chỉ xảy ra khi logic không dính vào framework. Nên khả năng test được và sự tách rời là cùng một tính chất: một bộ test toàn @SpringBootTest nặng nề thật ra là tấm gương phản chiếu một codebase có logic bị hàn chặt vào framework. Sợi chỉ chung: đẩy quy tắc nghiệp vụ vào những hàm không-framework để phần lớn test chạy với thiết lập bằng không; chia sẻ một ngữ cảnh đắt cho mọi thứ dùng chung được nó; và đo xem bộ test dựng bao nhiêu ngữ cảnh khác nhau trước khi đổ cho framework là chậm.

Ngày mai: cấu hình — profile, @ConfigurationProperties, và vì sao chuỗi rỗng không phải là "chưa cấu hình".