99 Ngày Spring — Ngày 57: Test data & fixtures
Sáu ngày qua ta học chạy test ở từng tầng: unit, @WebMvcTest, @DataJpaTest, @SpringBootTest, Testcontainers, mock và security. Nhưng thứ làm bộ test chậm lớn, khó đọc và chập chờn thường không phải cách chạy — mà là dữ liệu: new DonHang(...) mười hai tham số lặp lại trong tám mươi test, script SQL cứng id, và test hôm nay xanh mai đỏ vì test khác chạy trước. Hôm nay là ba câu trả lời: builder với mặc định hợp lệ để test chỉ nói điều nó quan tâm, @Sql cho dữ liệu nhiều hay legacy với cái bẫy ghi đè ít ai biết, và chiến lược dọn dẹp — rollback, TRUNCATE, dữ liệu duy nhất — cùng lý do @DirtiesContext không thuộc danh sách đó.

Builder cho test data — test chỉ nói điều nó quan tâm
Một test tốt đọc như đặc tả: tên test nói hành vi, thân test chỉ chứa dữ liệu ảnh hưởng đến kết quả. new DonHang(...) với mười hai tham số phá vỡ điều đó hai lần — người đọc không biết tham số nào quan trọng, và mỗi lần entity thêm cột thì hàng chục test không compile. Câu trả lời cổ điển là Test Data Builder với giá trị mặc định hợp lệ, lối vào tĩnh đọc như câu (motDonHang() — kiểu Object Mother), và một @TestComponent khi dữ liệu cần thật sự nằm trong DB.
// BUILDER CHO TEST DATA — vì sao "new DonHang(...)" trong mỗi test là một quả bom hẹn giờ.
// ❌ Cách quen: mỗi test tự dựng entity với ĐỦ mọi tham số
@Test
void tinhTong() {
var kh = new KhachHang(null, "Nguyễn Văn An", "an@example.com", "0901234567", true, LocalDate.now());
var don = new DonHang(null, kh, List.of(new DongHang(null, "SP-1", 2, 150_000)), TrangThai.MOI,
LocalDateTime.now(), null, "Giao giờ hành chính");
...
}
// 💥 Thêm một cột vào KhachHang → 80 test không compile. Đọc test không biết tham số nào QUAN TRỌNG.
// Test tên "tinhTong" nhưng 90% dòng là thiết lập không liên quan — và LocalDateTime.now() làm test
// "cuối tháng" đỏ đúng ngày 31.
// ✅ ① Builder với GIÁ TRỊ MẶC ĐỊNH HỢP LỆ — test chỉ nói ra thứ nó quan tâm
public final class DonHangBuilder {
private KhachHang khach = KhachHangBuilder.motKhach().build(); // mặc định: một khách hợp lệ bất kỳ
private final List<DongHang> dong = new ArrayList<>();
private TrangThai trangThai = TrangThai.MOI;
private LocalDateTime taoLuc = LocalDateTime.of(2026, 1, 15, 9, 0); // ⭐ CỐ ĐỊNH, không phải now()
public static DonHangBuilder motDonHang() { return new DonHangBuilder(); } // "Object Mother" — đọc như câu
public DonHangBuilder cuaKhach(KhachHang k) { this.khach = k; return this; }
public DonHangBuilder coDong(String sp, int sl, long donGia) {
dong.add(new DongHang(null, sp, sl, donGia)); return this;
}
public DonHangBuilder daThanhToan() { this.trangThai = TrangThai.DA_THANH_TOAN; return this; }
public DonHangBuilder taoLuc(LocalDateTime t) { this.taoLuc = t; return this; }
public DonHang build() {
if (dong.isEmpty()) coDong("SP-MAC-DINH", 1, 100_000); // ⭐ mặc định vẫn là một đơn HỢP LỆ
return new DonHang(null, khach, List.copyOf(dong), trangThai, taoLuc, null, null);
}
}
// Test giờ đọc như đặc tả — chỉ những gì ảnh hưởng đến kết quả mới xuất hiện:
@Test
void giamGiaKhiTongTren1Trieu() {
var don = motDonHang().coDong("SP-1", 2, 600_000).build(); // tổng 1,2 triệu — thế thôi
assertThat(dichVu.tinhGiamGia(don)).isEqualTo(120_000);
}
// ② Dữ liệu CẦN XUỐNG DB → một @TestComponent gói builder + repository
@TestComponent // ⭐ chỉ được nạp trong test — không lọt vào component scan của production
public class DuLieuTest {
private final KhachHangRepository khachRepo;
private final DonHangRepository donRepo;
DuLieuTest(KhachHangRepository k, DonHangRepository d) { this.khachRepo = k; this.donRepo = d; }
public DonHang donDaLuu(Consumer<DonHangBuilder> tuyChinh) {
var b = motDonHang();
tuyChinh.accept(b);
var don = b.build();
khachRepo.save(don.khach()); // lưu theo đúng thứ tự khóa ngoại
return donRepo.save(don);
}
}
// Dùng: var don = duLieu.donDaLuu(b -> b.daThanhToan().coDong("SP-9", 1, 50_000));
// ❗ @SpringBootTest KHÔNG tự quét @TestComponent — nạp bằng @Import(DuLieuTest.class) trên test hoặc lớp cha.
// ③ Dữ liệu ngẫu nhiên có kiểm soát: Datafaker / Instancio hữu ích cho tên, email, địa chỉ —
// NHƯNG giá trị QUYẾT ĐỊNH kết quả test (tổng tiền, trạng thái, ngày) PHẢI cố định. Random ở đó = test chập chờn.Mặc định phải là một đối tượng hợp lệ —
build()không tham số vẫn ra một đơn hàng lưu được; test chỉ ghi đè thứ nó kiểm tra.Ngày giờ cố định, không
now()—LocalDateTime.of(2026, 1, 15, 9, 0)trong builder, vàClocktiêm vào service (Ngày 56) khi logic phụ thuộc "hôm nay".@TestComponentgói builder với repository cho dữ liệu cần xuống DB — không được quét tự động, nạp bằng@Importtrên test hoặc lớp cha.Datafaker/Instancio cho phần không quyết định kết quả (tên, email); giá trị quyết định kết quả — tổng tiền, trạng thái — phải cố định, nếu không test chập chờn.
@Sql — khi script thắng builder, và cái bẫy ghi đè
Builder không phải lúc nào cũng thắng: báo cáo trên năm trăm đơn, trạng thái legacy mà code hiện tại không còn tạo ra được, bảng không có entity, hay chính migration Flyway — đó là việc của @Sql. Nó chạy script trước test theo mặc định, sau test với AFTER_TEST_METHOD, nhận câu lệnh trực tiếp, và có @SqlConfig cho dấu ngăn cách và mã hóa. Cái bẫy ít người biết: @Sql ở method ghi đè @Sql ở class — script nền không chạy, khóa ngoại vỡ, và bạn mất nửa buổi.
// @Sql — KHI NÀO SCRIPT SQL THẮNG BUILDER, và cách dùng không tự bắn vào chân.
// ① Đặt trên method hoặc class — chạy TRƯỚC test (mặc định)
@SpringBootTest
@Sql("/data/khach-hang-co-ban.sql") // class: chạy cho MỌI test trong lớp
class BaoCaoDoanhThuTest {
@Test
@Sql("/data/don-hang-thang-1.sql") // method: script riêng cho test này
void tongThang1() { ... }
@Test
@Sql(scripts = "/data/don-hang-huy.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) // mặc định — viết ra cho rõ
@Sql(scripts = "/data/don-dep.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // ⭐ dọn SAU test — chạy cả khi test FAIL
void donHuyKhongTinhDoanhThu() { ... }
}
// ❗ BẪY LỚN NHẤT: @Sql ở method GHI ĐÈ @Sql ở class (SqlMergeMode.OVERRIDE là mặc định) — với tongThang1,
// script khach-hang-co-ban.sql KHÔNG chạy, và khóa ngoại vỡ. Muốn CỘNG DỒN:
@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) // đặt trên class → script class chạy trước, rồi đến script method
// ② Câu lệnh trực tiếp, không cần file — cho một dòng thay đổi trạng thái
@Sql(statements = "UPDATE don_hang SET trang_thai = 'HUY' WHERE ma = 'DH-1'")
// ③ @SqlConfig — dấu ngăn cách, mã hóa, cách xử lý lỗi (thủ tục PL/pgSQL có dấu ; bên trong)
@Sql(scripts = "/data/thu-tuc.sql",
config = @SqlConfig(separator = "$$", encoding = "UTF-8", errorMode = SqlConfig.ErrorMode.FAIL_ON_ERROR))
// ④ KHI NÀO dùng @Sql thay builder?
// ✅ dữ liệu NHIỀU (báo cáo trên 500 đơn), trạng thái LEGACY mà code hiện tại không còn tạo ra được,
// bảng KHÔNG có entity (view, bảng tổng hợp), hay muốn kiểm tra chính migration Flyway.
// ❌ dữ liệu nhỏ mà người đọc test cần hiểu: builder rõ hơn, và KHÔNG lệch khỏi entity khi cột đổi tên.
// ⭐ BA BẪY của script SQL:
// ❗ 1. Phương ngữ: ON CONFLICT, jsonb, now() — chạy trên Postgres THẬT (Testcontainers, Ngày 55), không H2.
// ❗ 2. Khóa chính viết cứng: INSERT INTO khach_hang (id, ...) VALUES (1, ...)
// → sequence KHÔNG biết; test tiếp theo save() một khách mới → 💥 duplicate key value violates unique constraint.
// Đừng ghi id, hoặc SELECT setval('khach_hang_id_seq', (SELECT max(id) FROM khach_hang)) ở cuối script.
// ❗ 3. Script trôi khỏi schema: Flyway thêm cột NOT NULL → 20 script fail cùng lúc. Giữ script NHỎ, chỉ cột bắt buộc,
// và để Flyway tạo schema cho test (KHÔNG ddl-auto trong test — Ngày 34).Method ghi đè class theo mặc định —
SqlMergeMode.OVERRIDE; muốn script class chạy trước rồi đến script method thì@SqlMergeMode(MERGE)trên class.AFTER_TEST_METHODchạy cả khi test fail — dọn cục bộ đáng tin hơn một dòngdeleteở cuối thân test.Không viết cứng id trong script — sequence không biết,
save()tiếp theo làduplicate key; bỏ cột id hoặcsetvalở cuối script.Script chạy trên Postgres thật (Testcontainers) và để Flyway tạo schema — script nhỏ, chỉ cột bắt buộc, để migration thêm cột không quật đổ hai mươi script cùng lúc.
Chiến lược dọn dẹp — và vì sao @DirtiesContext không nằm trong đó
Test phụ thuộc thứ tự là loại lỗi khiến cả đội mất niềm tin vào bộ test: xanh khi chạy một mình, đỏ khi chạy cả bộ. Có bốn cách giữ DB sạch và mỗi cách có điều kiện: rollback transaction miễn phí nhưng chỉ khi test và request cùng luồng (không với RANDOM_PORT); TRUNCATE ... RESTART IDENTITY CASCADE trước mỗi test là cách chắc nhất cho test tích hợp; @Sql AFTER_TEST_METHOD cho vài test đặc biệt; và dữ liệu duy nhất không cần dọn nhưng cấm assert trên tổng số. Còn @DirtiesContext — nó hủy context, không hủy dữ liệu.
// CHIẾN LƯỢC DỌN DẸP — test hôm nay xanh, ngày mai đỏ vì một test KHÁC chạy trước nó.
// ① ROLLBACK TRANSACTION — miễn phí, nhưng chỉ khi request và test CÙNG một luồng
@DataJpaTest // ✅ mặc định @Transactional → rollback sau mỗi test (Ngày 53)
@SpringBootTest // webEnvironment = MOCK (mặc định) + @Transactional trên test → ✅ vẫn rollback
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional // ❌ VÔ DỤNG: server xử lý request trên luồng khác, commit THẬT (Ngày 54)
// ② TRUNCATE trước mỗi test — cách chắc chắn nhất cho RANDOM_PORT / Testcontainers
@TestComponent
public class DonDepDb {
private final JdbcTemplate jdbc;
DonDepDb(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public void xoaSach() {
// ⭐ RESTART IDENTITY: sequence về 1 → id ổn định giữa các test; CASCADE: khỏi lo thứ tự khóa ngoại
jdbc.execute("TRUNCATE TABLE dong_hang, don_hang, khach_hang RESTART IDENTITY CASCADE");
}
}
abstract class BaseIntegrationTest { // lớp cha dùng chung MỘT context (Ngày 54)
@Autowired DonDepDb donDep;
@BeforeEach void don() { donDep.xoaSach(); } // ⭐ dọn TRƯỚC, không phải sau: test fail giữa chừng vẫn sạch
}
// Không muốn viết SQL: JdbcTestUtils.deleteFromTables(jdbc, "dong_hang", "don_hang", "khach_hang")
// → DELETE theo thứ tự đưa vào, giữ nguyên sequence (id tăng dần qua các test — đừng assert id = 1).
// ❗ TRUNCATE trên Postgres khóa bảng ACCESS EXCLUSIVE — không chạy test SONG SONG trên cùng một DB.
// ③ @Sql AFTER_TEST_METHOD — dọn cục bộ cho vài test đặc biệt (bài trên); không phải chiến lược toàn cục.
// ④ KHÔNG DỌN, mà dùng DỮ LIỆU DUY NHẤT — mỗi test tự tạo khách của riêng nó
var email = "an+" + UUID.randomUUID() + "@example.com";
var don = duLieu.donDaLuu(b -> b.cuaKhach(motKhach().email(email).build()));
// ✅ chạy song song được, không TRUNCATE, không phụ thuộc thứ tự.
// ❌ Nhưng assert "tổng số đơn trong hệ thống = 3" là sai ngay — chỉ được assert trên dữ liệu MÌNH tạo.
// ⑤ @DirtiesContext KHÔNG PHẢI công cụ dọn dữ liệu
@DirtiesContext // ❌ hủy ApplicationContext (+15 giây nạp lại) — dữ liệu trong Postgres container VẪN CÒN NGUYÊN
// Nó chỉ dành cho test đã làm HỎNG chính context (đổi bean tĩnh, cache trong bộ nhớ) — và ngay cả khi đó, hãy nghi ngờ.
// ⭐ QUY TẮC: mỗi test phải chạy đúng khi chạy MỘT MÌNH, khi chạy SAU BẤT KỲ test nào, và khi chạy HAI LẦN.
// Kiểm tra bằng thứ tự ngẫu nhiên — junit-platform.properties:
// junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$Random
// junit.jupiter.testclass.order.default = org.junit.jupiter.api.ClassOrderer$RandomRollback chỉ khi cùng luồng —
@DataJpaTestvà@SpringBootTestvớiMOCK;RANDOM_PORTcommit thật,@Transactionaltrên test là vô dụng.Dọn trước, không dọn sau —
TRUNCATEtrong@BeforeEachcủa lớp cha: test chết giữa chừng vẫn để lại DB sạch cho test kế tiếp;RESTART IDENTITYgiữ id ổn định.Dữ liệu duy nhất (email có UUID) chạy song song được và không cần
TRUNCATE— nhưng chỉ assert trên dữ liệu mình tạo, không bao giờ trên tổng số dòng.@DirtiesContextkhông phải công cụ dọn dữ liệu — nó nạp lại context mất mười lăm giây, còn dữ liệu trong container vẫn y nguyên; và mỗi test phải đúng khi chạy một mình, sau bất kỳ test nào, và hai lần.
Bài tập nhỏ
Chọn entity có nhiều tham số nhất trong dự án và viết
XxxBuildervới mặc định hợp lệ — rồi thay ba test đang gọi constructor bằng builder và đếm số dòng giảm.Viết một
@TestComponentgói builder với repository; quên@Importvà đọc thông điệp lỗiNoSuchBeanDefinitionException.Đặt
@Sqlở class và một@Sqlkhác ở method — xem script class có chạy không; thêm@SqlMergeMode(MERGE)và xem lại.Chèn một dòng với id viết cứng bằng
@Sql, rồisave()một entity mới trong cùng test — đọc lỗiduplicate keyvà sửa bằngsetval.Bật
MethodOrderer$RandomvàClassOrderer$Randomtrongjunit-platform.propertiesrồi chạy cả bộ ba lần — test nào đỏ là test phụ thuộc thứ tự.
Kết lại
Bốn ý gói lại hôm nay: builder với mặc định hợp lệ để test chỉ nói điều nó quan tâm — ngày giờ cố định, @TestComponent nạp bằng @Import khi cần xuống DB; @Sql cho dữ liệu nhiều, legacy hay bảng không có entity — với method ghi đè class trừ khi @SqlMergeMode(MERGE), và ba bẫy phương ngữ, id cứng, script trôi khỏi schema; dọn dẹp bằng rollback khi cùng luồng, TRUNCATE trước mỗi test khi RANDOM_PORT, hoặc dữ liệu duy nhất mà không assert tổng số; và @DirtiesContext hủy context chứ không hủy dữ liệu. Bộ test giờ đã có dữ liệu sạch — Ngày 58 rời phòng test để nhìn ứng dụng đang chạy: Spring Boot Actuator — endpoints, health/info/metrics, và cách expose chúng mà không mở toang cửa. 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.


