99 Ngày Spring — Ngày 60: Tổng kết chiến lược test
Mười ngày của Giai đoạn 6 cho bạn từng công cụ: unit test không cần context, @WebMvcTest, @DataJpaTest, @SpringBootTest, Testcontainers, @MockitoBean với jwt(), builder và @Sql, Actuator, JaCoCo. Hôm nay ghép chúng thành một chiến lược cho một service thật — đơn hàng với REST, JPA, cổng thanh toán ngoài và JWT: lớp nào test bằng gì, biên giới đặt ở đâu, ngân sách thời gian bao nhiêu, xử lý test chập chờn ra sao — và kết thúc bằng checklist 12 câu để review test của đồng nghiệp.

Mỗi lớp một câu hỏi: test nhỏ nhất nào chứng minh được?
Chiến lược tốt không bắt đầu từ "phủ bao nhiêu phần trăm" mà từ một câu hỏi lặp lại ở mỗi lớp: test nhỏ nhất nào chứng minh được điều này? Logic nghiệp vụ là unit test thuần với Mockito và Clock cố định. Mỗi controller là một @WebMvcTest đi đủ 401, 403, 200, 400 validation và mapping lỗi. Tầng dữ liệu là @DataJpaTest trên Postgres thật, chỉ cho @Query tự viết, ràng buộc và migration. Và vài hành trình end-to-end bằng @SpringBootTest kế thừa một lớp cha chung.
// CHIẾN LƯỢC CHO MỘT SERVICE THẬT: đơn hàng = REST + JPA + cổng thanh toán ngoài + JWT.
// Câu hỏi duy nhất cho MỖI lớp: "Test NHỎ NHẤT nào CHỨNG MINH được điều này?"
// ① DOMAIN / SERVICE — unit test thuần, KHÔNG Spring (Ngày 51). Hàng nghìn test chạy trong vài giây.
@ExtendWith(MockitoExtension.class)
class DichVuDonHangTest {
@Mock KhoDonHang kho;
@Mock CongThanhToan cong;
Clock clock = Clock.fixed(Instant.parse("2026-09-25T09:00:00Z"), ZoneOffset.UTC); // ⭐ thời gian cố định
DichVuDonHang dv;
@BeforeEach void setUp() { dv = new DichVuDonHang(kho, cong, clock); }
@Test
void thanhToan_donDaHuy_thiTuChoi() {
var don = DonHangBuilder.mot().daHuy().build(); // builder Ngày 57
when(kho.findById(don.getId())).thenReturn(Optional.of(don));
assertThatThrownBy(() -> dv.thanhToan(don.getId()))
.isInstanceOf(DonHangKhongHopLe.class)
.hasMessageContaining("đã hủy");
verifyNoInteractions(cong); // ⭐ khẳng định HÀNH VI: không được gọi cổng
}
}
// ② WEB — @WebMvcTest cho MỖI controller (Ngày 52 + 56): 401 · 403 · 200 · 400 validation · mapping lỗi
@WebMvcTest(DonHangController.class)
@Import(SecurityConfig.class)
class DonHangControllerTest {
@Autowired MockMvc mvc;
@MockitoBean DichVuDonHang dv; // service là BIÊN của lớp web
@Test void khongToken_401() throws Exception {
mvc.perform(post("/api/don-hang")).andExpect(status().isUnauthorized());
}
@Test void thieuScope_403() throws Exception {
mvc.perform(post("/api/don-hang").with(jwt())).andExpect(status().isForbidden());
}
@Test void bodyRong_400_coDanhSachLoi() throws Exception {
mvc.perform(post("/api/don-hang").with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:write")))
.contentType(APPLICATION_JSON).content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field=='khachHang')]").exists());
}
}
// ③ DATA — @DataJpaTest trên Postgres THẬT (Ngày 53 + 55): CHỈ @Query tự viết, ràng buộc, migration
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Testcontainers
class KhoDonHangTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16"); // static — một container cho cả lớp
@Autowired KhoDonHang kho;
@Autowired TestEntityManager em;
@Test void timDonQuaHan_chiTraDonMoiTruocHanChot() { /* persist → flush → clear → assert */ }
// ❌ KHÔNG test kho.save() — đó là việc của Spring Data, họ test rồi.
}
// ④ HÀNH TRÌNH end-to-end — VÀI cái thôi, @SpringBootTest(RANDOM_PORT), lớp cha dùng chung (Ngày 54)
class HanhTrinhDatHangIT extends BaseIntegrationTest { // *IT → Failsafe, chạy ở mvn verify
@Test void tao_thanhToan_xem() {
var tao = rest.postForEntity("/api/don-hang", donMau(), DonHangDto.class); // 201
rest.postForEntity("/api/don-hang/{id}/thanh-toan", null, Void.class, tao.getBody().id()); // qua cổng GIẢ
var xem = rest.getForEntity("/api/don-hang/{id}", DonHangDto.class, tao.getBody().id());
assertThat(xem.getBody().trangThai()).isEqualTo("DA_THANH_TOAN");
}
}
// ⭐ TỈ LỆ thực tế của một service cỡ vừa: ~300 unit · ~40 slice · ~8 hành trình. Đảo ngược lại là CI 40 phút.Tỉ lệ thực tế của một service cỡ vừa: khoảng 300 unit, 40 slice, 8 hành trình — đảo ngược tỉ lệ này là CI 40 phút và không ai chạy test ở máy mình nữa.
Mỗi controller đi đủ ba trạng thái 401/403/200 với
jwt()(Ngày 56), cộng 400 cho validation và mapping exception — đây là chỗ hàng loạt bug "quên bảo vệ endpoint" bị bắt.Tầng dữ liệu không test
save— chỉ@Querytự viết, unique constraint, cascade, và migration Flyway chạy được trên Postgres thật (Ngày 53, 55).Hành trình end-to-end đi qua cổng giả, không phải mock trả
null: tạo đơn, thanh toán, xem lại — ba request thật, một khẳng định về trạng thái cuối.
Biên giới, tốc độ và test chập chờn
Ba thứ quyết định bộ test sống hay chết sau sáu tháng. Biên giới: thứ ngoài hệ thống — cổng thanh toán, email, API bên thứ ba — được thay bằng fake có hành vi qua @TestConfiguration, và HTTP ra ngoài được test bằng WireMock đủ cả 500, timeout, JSON lệch. Tốc độ: đo, rồi tách Surefire cho unit và Failsafe cho *IT, bật JUnit chạy song song cho unit test thuần. Chập chờn: Thread.sleep thay bằng Awaitility, thời gian tiêm qua Clock, test chập chờn được cách ly có ticket và hạn sửa.
// BIÊN GIỚI, TỐC ĐỘ, VÀ TEST CHẬP CHỜN — ba thứ quyết định bộ test sống hay chết.
// ① CỔNG NGOÀI: một FAKE có hành vi — không phải mock trả null — bằng @TestConfiguration + @Primary (Ngày 56)
@TestConfiguration
class CongThanhToanGia {
@Bean @Primary
CongThanhToan congThanhToan() {
return yeuCau -> yeuCau.soTien().compareTo(new BigDecimal("50000000")) > 0
? KetQuaThanhToan.tuChoi("vượt hạn mức") // hành vi thật của cổng, thu nhỏ
: KetQuaThanhToan.thanhCong("FAKE-" + UUID.randomUUID());
}
}
// ② HTTP RA NGOÀI: WireMock — test cả timeout, 500, JSON lệch; không chỉ happy path
@SpringBootTest(webEnvironment = RANDOM_PORT,
properties = "cong.thanh-toan.url=http://localhost:${wiremock.server.port}")
@EnableWireMock
class CongThanhToanClientIT {
@Autowired CongThanhToanClient client;
@Test void congTra500_thiNemLoiCoTheThuLai() {
stubFor(post("/v1/charges").willReturn(serverError()));
assertThatThrownBy(() -> client.tinhPhi(yeuCau())).isInstanceOf(CongTamThoiLoi.class);
}
@Test void congTreo_quaTimeout() {
stubFor(post("/v1/charges").willReturn(ok().withFixedDelay(5_000))); // timeout cấu hình 2 giây
assertThatThrownBy(() -> client.tinhPhi(yeuCau())).hasCauseInstanceOf(SocketTimeoutException.class);
}
}
// ③ NGÂN SÁCH THỜI GIAN — đo, rồi TÁCH: unit (Surefire) chạy mọi commit, IT (Failsafe) chạy ở verify
// pom.xml
// maven-surefire-plugin → *Test.java mục tiêu: < 10 giây TỔNG
// maven-failsafe-plugin → *IT.java mục tiêu: cả bộ < 5 phút
// src/test/resources/junit-platform.properties
// junit.jupiter.execution.parallel.enabled = true
// junit.jupiter.execution.parallel.mode.default = concurrent // ⭐ unit test thuần chạy song song an toàn
// ❗ @SpringBootTest KHÔNG chạy song song trong cùng JVM khi dùng chung cổng/DB — để Failsafe <forkCount> lo.
// ④ TEST CHẬP CHỜN — chính sách, không phải cảm tính
// - Thread.sleep(500) trong test = chập chờn CHẮC CHẮN → Awaitility: await().atMost(2, SECONDS).untilAsserted(...)
// - Phụ thuộc thứ tự test / static mutable → @TestMethodOrder KHÔNG phải cách chữa; xóa trạng thái chia sẻ đi
// - LocalDate.now() trong code → tiêm Clock (Ngày 51): test "cuối tháng" không được chỉ đỏ vào ngày 31
// - Cách ly: @Tag("flaky") chạy riêng, có TICKET, sửa trong sprint. @Disabled không hạn = xóa test cho thật.
@Test @Tag("flaky") @DisplayName("ORD-412: đôi khi đỏ vì thứ tự event — sửa trước 30/09")
void guiEmail_sauKhiThanhToan() { /* ... */ }
// ⭐ Awaitility thay cho sleep: đợi ĐÚNG đến khi điều kiện đúng, tối đa 2 giây — không thừa, không thiếu
await().atMost(2, SECONDS).untilAsserted(() ->
assertThat(hopThu.emailCho("an@vd.vn")).hasSize(1));Fake khác mock: fake có luật ("vượt 50 triệu thì từ chối"), mock chỉ trả cái bạn bảo — hành trình end-to-end cần fake để bắt được lỗi ghép nối.
WireMock test đường xấu: 500 phải ra exception có thể thử lại, treo phải ra timeout sau 2 giây — không có hai test này, lần đầu bạn biết cổng chậm là trên production.
Ngân sách thời gian là con số: unit dưới 10 giây tổng, cả bộ dưới 5 phút; Surefire chạy mọi commit, Failsafe ở
mvn verify;junit.jupiter.execution.parallel.enabledcho unit test thuần.Chính sách chập chờn:
@Tag("flaky")chạy riêng, có ticket, sửa trong sprint;@Disabledkhông hạn thì xóa test cho thật — một test tắt vĩnh viễn chỉ là nợ giả vờ.
Checklist 12 câu để review test
Review test khó hơn review code, vì test xanh trông luôn có vẻ đúng. Mười hai câu hỏi dưới đây là thứ đi qua đầu bạn trước khi bấm Approve — tên có nói hành vi không, assert kết quả hay cài đặt, giá trị có chính xác không, tính năng hỏng thì test có đỏ không, lát cắt Spring có nhỏ nhất chưa, có sleep hay trạng thái chia sẻ không, thời gian có quyết định được không, dữ liệu có qua builder chưa, dọn dẹp có chắc không, có rắc @DirtiesContext không, và chạy bao lâu. Một test "xanh mà vô dụng" thường phạm bốn câu đầu cùng lúc.
// CHECKLIST REVIEW TEST — 12 câu hỏi trước khi bấm Approve.
// 1. Tên test nói HÀNH VI chưa? thanhToan_donDaHuy_thiTuChoi ✅ test1 / testThanhToan ❌
// 2. Assert KẾT QUẢ hay CÀI ĐẶT? assertThat(kq.trangThai()) ✅ verify(kho).findById() ❌ khi không cần
// 3. Assert giá trị CHÍNH XÁC? isEqualTo(new BigDecimal("1250000")) ✅ isNotNull() ❌
// 4. Tính năng hỏng thì test có ĐỎ? xóa một dòng logic rồi chạy — vẫn xanh là test vô dụng (Ngày 59: mutation)
// 5. Lát cắt Spring NHỎ NHẤT chưa? unit → @WebMvcTest / @DataJpaTest → @SpringBootTest, đi từ trái sang
// 6. Có Thread.sleep không? có = chập chờn. Awaitility, hoặc thiết kế lại cho đồng bộ.
// 7. Có trạng thái CHIA SẺ không? static mutable, singleton giữ list, thứ tự test → mỗi test tự đủ
// 8. Thời gian QUYẾT ĐỊNH được không? Clock.fixed — không LocalDate.now(), không Instant.now()
// 9. Dữ liệu qua BUILDER chưa? DonHangBuilder.mot().daHuy() ✅ new DonHang(null, "x", 1, null, ...) ❌
// 10. Dọn dẹp CHẮC CHẮN chưa? rollback / @Sql AFTER_TEST_METHOD / truncate — không "mong test sau không đụng"
// 11. @DirtiesContext / @MockitoBean rắc lung tung? mỗi bộ mock = một context MỚI (Ngày 54, 56)
// 12. Chạy trong CI bao lâu? unit < 10 s · cả bộ < 5 phút · theo dõi TREND, không đoán
// ❌ Test "xanh mà vô dụng" — phạm 1, 2, 3, 4 cùng một lúc
@Test void test1() {
var kq = dv.thanhToan(1L);
assertThat(kq).isNotNull(); // hỏng gì cũng xanh
verify(kho).findById(1L); // ghim cài đặt: thêm cache là đỏ dù tính năng vẫn đúng
}
// ✅ Cùng tính năng, viết lại — given / when / then, một hành vi, giá trị chính xác
@Test void thanhToan_donHopLe_thiDoiTrangThaiVaGoiCongDungMotLan() {
// given
var don = DonHangBuilder.mot().tong("1250000").build();
when(kho.findById(don.getId())).thenReturn(Optional.of(don));
when(cong.tinhPhi(any())).thenReturn(KetQuaThanhToan.thanhCong("TX-1"));
// when
var kq = dv.thanhToan(don.getId());
// then
assertThat(kq.trangThai()).isEqualTo(TrangThai.DA_THANH_TOAN);
assertThat(kq.maGiaoDich()).isEqualTo("TX-1");
verify(cong, times(1)).tinhPhi(argThat(y -> y.soTien().compareTo(new BigDecimal("1250000")) == 0));
// ↑ verify CÓ nghĩa ở đây: "gọi cổng đúng một lần với đúng số tiền" LÀ hành vi nghiệp vụ
}
// ⭐ KHÔNG test: getter/setter, Lombok, repo.save(), cấu hình framework, code sinh tự động.
// ⭐ Coverage (Ngày 59) là HỆ QUẢ của 12 câu trên, không phải mục tiêu.Câu 4 là câu quyết định: xóa một dòng logic rồi chạy — test vẫn xanh nghĩa là nó không bảo vệ gì; đây chính là mutation testing làm bằng tay (Ngày 59).
verifykhông xấu, chỉ hay bị đặt sai chỗ: "gọi cổng đúng một lần với đúng số tiền" là hành vi nghiệp vụ; "gọifindById" là chi tiết cài đặt — thêm cache là đỏ oan.given / when / then với một hành vi mỗi test và tên dạng
hanhDong_dieuKien_ketQua— khi đỏ, tên test là dòng đầu tiên người ta đọc trong CI.Không test getter/setter, Lombok,
repo.save(), cấu hình framework, code sinh tự động — coverage là hệ quả của checklist, không phải mục tiêu.
Bài tập nhỏ
Vẽ bảng chiến lược cho project của bạn: mỗi lớp (domain, web, data, tích hợp) — loại test, số lượng hiện có, thời gian chạy; khoanh chỗ tỉ lệ bị đảo ngược.
Thay một
@MockitoBeancổng ngoài bằng fake@TestConfigurationcó ít nhất một luật nghiệp vụ; chạy lại hành trình end-to-end.Tách Surefire/Failsafe: đổi tên test tích hợp thành
*IT, đo thời gianmvn testtrước và sau.Tìm mọi
Thread.sleeptrong thư mục test và thay bằng Awaitility; tìm mọiLocalDate.now()trong code và tiêmClock.Chọn 5 test bất kỳ của đồng nghiệp, chấm theo 12 câu; với mỗi test phạm câu 4, xóa một dòng logic để chứng minh rồi viết lại.
Kết lại
Bốn ý gói lại hôm nay: mỗi lớp một câu hỏi — test nhỏ nhất nào chứng minh được — cho ra tỉ lệ nhiều unit, vừa slice, ít hành trình; biên giới dùng fake có hành vi và WireMock cho cả đường xấu; tốc độ và chập chờn là chính sách — Surefire/Failsafe, chạy song song, Awaitility, Clock, cách ly có hạn; và checklist 12 câu biến review test thành việc có thể làm đều tay. Giai đoạn 6 khép lại ở đây: test không phải là thứ viết sau cho đủ coverage, mà là cách bạn chứng minh service của mình làm đúng điều nó hứa, nhanh đến mức bạn chạy nó mỗi lần lưu file. Ngày 61 mở Giai đoạn 7 — Spring nâng cao — bằng AOP: cross-cutting concern, @Aspect, pointcut và advice, và giới hạn của proxy mà bạn đã chạm phải ở @Transactional. Hẹn gặp lại!
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.


