Backend

99 Ngày Spring — Ngày 57: Test data & fixtures

SSite Admin
22 tháng 09, 2026 12 phút đọc 1 lượt xem
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 đó.

Sketchnote Ngày 57: test data và fixtures — builder với mặc định hợp lệ và Object Mother, TestComponent gói builder với repository, Sql với executionPhase và bẫy SqlMergeMode OVERRIDE, ba bẫy script SQL, chiến lược dọn dẹp rollback TRUNCATE dữ liệu duy nhất, DirtiesContext không phải công cụ dọn

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à Clock tiêm vào service (Ngày 56) khi logic phụ thuộc "hôm nay".

  • @TestComponent gó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 @Import trê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 địnhSqlMergeMode.OVERRIDE; muốn script class chạy trước rồi đến script method thì @SqlMergeMode(MERGE) trên class.

  • AFTER_TEST_METHOD chạy cả khi test fail — dọn cục bộ đáng tin hơn một dòng delete ở 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ặc setval ở 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$Random
  • Rollback chỉ khi cùng luồng@DataJpaTest@SpringBootTest với MOCK; RANDOM_PORT commit thật, @Transactional trên test là vô dụng.

  • Dọn trước, không dọn sauTRUNCATE trong @BeforeEach của lớp cha: test chết giữa chừng vẫn để lại DB sạch cho test kế tiếp; RESTART IDENTITY giữ 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.

  • @DirtiesContext khô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 XxxBuilder vớ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 @TestComponent gói builder với repository; quên @Import và đọc thông điệp lỗi NoSuchBeanDefinitionException.

  • Đặt @Sql ở class và một @Sql khá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ồi save() một entity mới trong cùng test — đọc lỗi duplicate key và sửa bằng setval.

  • Bật MethodOrderer$RandomClassOrderer$Random trong junit-platform.properties rồ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!

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 58: Optional

Optional là công cụ cho kiểu trả về — không field, không tham số, không bọc collection; chuỗi map/flatMap/filter thay kim tự tháp if; orElse luôn tính tham số còn orElseGet lười, orElseThrow cho “không có là lỗi” — và các anti-pattern isPresent + get, orElse(null).

23 thg 9, 20269 phút9
99 Ngày Spring — Ngày 58: Spring Boot Actuator

Boot 3 mặc định chỉ lộ health; bản đồ endpoint an toàn và nguy hiểm; HealthIndicator tự viết với liveness tách khỏi readiness cho Kubernetes, info từ build và git, metrics Micrometer với tag hữu hạn — và ba lớp khóa: cổng riêng, SecurityFilterChain với EndpointRequest, exclude env/heapdump/threaddump.

23 thg 9, 202610 phút5
So sánh EasyExcel và Apache Fesod: khác biệt thật nằm ở đâu?

Fesod là hậu duệ của EasyExcel, cùng engine SAX/SXSSF. Khác biệt kiểm chứng được: vòng đời dự án, POI 5.2.5 vs 5.5.1, các API mới (numRows, csv(), headerMergeStrategy) và độ bền với file xấu — không phải hiệu năng gấp nhiều lần.

23 thg 9, 20268 phút1