99 Ngày Spring — Ngày 53: @DataJpaTest
Hôm qua là lát cắt tầng web, hôm nay là lát cắt cuối cùng: tầng dữ liệu. Nhưng bài này có một câu hỏi lớn hơn cú pháp, và nó quyết định bộ test của bạn có đáng tin hay không: test repository trên H2 hay trên Postgres thật? Câu trả lời sai dẫn tới thứ tệ nhất — test xanh trên máy bạn, chết trên production.

Mỗi test là một transaction bị rollback
Đây là mặc định quan trọng nhất và cũng hay bị hiểu nhầm nhất: mỗi test chạy trong một transaction, và transaction đó được rollback khi test kết thúc. Nhờ vậy test không bao giờ làm bẩn nhau. Nhưng nó cũng có nghĩa là những lỗi chỉ xuất hiện lúc commit có thể không hiện ra.
// @DataJpaTest — lát cắt tầng dữ liệu, và một mặc định RẤT dễ hiểu nhầm.
@DataJpaTest
class DonHangRepositoryTest {
@Autowired DonHangRepository repo;
@Autowired TestEntityManager em; // ⭐ dựng dữ liệu mà KHÔNG qua repository
// — nếu repo hỏng, test vẫn phát hiện được
@Test
void tim_theo_khach_hang() {
em.persist(new DonHang("KH-01", "SP-01", 2));
em.persist(new DonHang("KH-01", "SP-02", 1));
em.persist(new DonHang("KH-02", "SP-01", 5));
em.flush(); // ⭐ ĐẨY XUỐNG DB — không có dòng này thì
em.clear(); // câu truy vấn có thể chỉ đọc cache
// ⭐ XÓA cache tầng 1 — buộc đọc THẬT từ DB
var kq = repo.timTheoKhachHang("KH-01");
assertThat(kq).hasSize(2);
}
}
// ⭐ MẶC ĐỊNH QUAN TRỌNG NHẤT: MỖI TEST NẰM TRONG MỘT TRANSACTION,
// VÀ TRANSACTION ĐÓ ĐƯỢC ROLLBACK KHI TEST KẾT THÚC.
// ➜ Test không bao giờ làm bẩn dữ liệu của test khác. Chạy thứ tự nào cũng được.
// ➜ Nhưng cũng có nghĩa là: bạn KHÔNG thấy dữ liệu này trong DB sau khi test xong.
// ❗ HỆ QUẢ HAY GÂY NHẦM: vì tất cả nằm trong một transaction chưa commit,
// những lỗi chỉ xuất hiện lúc COMMIT (vi phạm khóa ngoại, ràng buộc hoãn lại)
// có thể KHÔNG hiện ra trong test. Muốn thấy thì gọi em.flush() tường minh.
// @DataJpaTest NẠP: Entity, Repository, EntityManager, DataSource, cấu hình JPA
// @DataJpaTest KHÔNG NẠP: @Service, @Controller, @Component thường
// ➜ Cùng nguyên tắc như @WebMvcTest hôm qua: nạp đúng một lát cắt.TestEntityManagerdựng dữ liệu không qua repository — nếu repository hỏng thì test vẫn phát hiện được, chứ dùng chính nó để dựng thì hỏng cả hai đầu.flush()đẩy câu SQL xuống DB,clear()xóa cache tầng một — thiếu hai dòng này thì truy vấn của bạn có thể chỉ đọc cache và không chứng minh được gì.Cùng nguyên tắc với
@WebMvcTesthôm qua: nạp đúng một lát cắt, không nạp@Servicehay@Controller.
H2 hay Testcontainers — câu hỏi quan trọng nhất
Mặc định, @DataJpaTest thay database thật bằng H2 nếu nó có trên classpath. Nhanh và tiện — nhưng H2 không phải Postgres. Kiểu jsonb, hàm ON CONFLICT, cách phân biệt hoa thường, mức cô lập transaction, thậm chí thứ tự dòng mặc định — tất cả đều khác.
// H2 hay TESTCONTAINERS — câu hỏi quan trọng nhất của bài này.
// MẶC ĐỊNH, @DataJpaTest thay DataSource thật bằng một DB nhúng (H2) nếu có trên classpath.
// Nghe rất tiện: nhanh, không cần cài gì. Nhưng nó KHÔNG PHẢI Postgres.
// ❗ NHỮNG THỨ H2 CHẠY ĐƯỢC MÀ POSTGRES THÌ KHÔNG (và ngược lại):
// · Kiểu riêng: jsonb, array, uuid, enum của Postgres — H2 mô phỏng KHÔNG đầy đủ
// · Hàm riêng: to_tsvector, generate_series, ON CONFLICT ... DO UPDATE
// · Phân biệt hoa thường của tên bảng/cột — hai bên xử lý KHÁC nhau
// · Kiểu khóa và mức cô lập transaction khác nhau
// · Thứ tự mặc định khi không có ORDER BY — khác nhau, và đây là nguồn bug ẩn
// ➜ Kết quả kinh điển: test xanh trên H2, chết trên production. Loại lỗi
// khiến người ta mất niềm tin vào cả bộ test.
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE) // ⭐ ĐỪNG thay DataSource
@Testcontainers // → dùng Postgres THẬT
class DonHangRepositoryTest {
@Container @ServiceConnection // ⭐ Spring Boot 3.1+: tự nối,
static PostgreSQLContainer<?> db = // KHỎI khai url/user/password
new PostgreSQLContainer<>("postgres:16-alpine");
...
}
// ⭐ @ServiceConnection là thứ khiến Testcontainers trở nên đáng dùng:
// trước nó bạn phải viết @DynamicPropertySource lằng nhằng, giờ chỉ một dòng.
// ❗ static — QUAN TRỌNG: container được TÁI SỬ DỤNG cho mọi test trong class.
// Bỏ static thì mỗi test dựng một container: chậm gấp hàng chục lần.
// ➜ CHỌN THẾ NÀO: H2 cho truy vấn đơn giản và vòng lặp phát triển nhanh;
// Testcontainers khi bạn dùng tính năng riêng của DB, hoặc trước khi lên production.
// Nhiều đội chạy H2 lúc code và Testcontainers trên CI — nhưng phải chấp nhận
// rằng CI có thể đỏ trong khi máy bạn xanh. Đó là cái giá của hai môi trường.Test xanh trên H2, chết trên production là loại lỗi khiến cả đội mất niềm tin vào bộ test — và mất niềm tin thì bộ test coi như bỏ.
@ServiceConnectioncủa Spring Boot 3.1 là thứ khiến Testcontainers thật sự đáng dùng: trước nó bạn phải viết@DynamicPropertySourcelằng nhằng, giờ chỉ một dòng.Nhớ
staticcho container — bỏ nó thì mỗi test dựng một container mới, chậm gấp hàng chục lần.Chạy H2 lúc code và Testcontainers trên CI cũng được, nhưng phải chấp nhận CI đỏ khi máy bạn xanh — đó là cái giá của hai môi trường.
@Sql và bốn cái bẫy còn lại
@Sql hợp khi cần nhiều dữ liệu, hoặc khi cần một trạng thái mà entity không tạo ra được — dữ liệu cũ thiếu cột, giá trị mà validation hiện tại sẽ chặn. Còn bốn cái bẫy dưới đây thì đều sinh ra những test trông có vẻ bảo vệ mà thật ra không.
// @Sql — dựng dữ liệu bằng SQL thuần, và khi nào nên dùng.
@Test
@Sql("/du-lieu-test/100-don-hang.sql") // chạy TRƯỚC test
@Sql(scripts = "/du-lieu-test/don-dep.sql",
executionPhase = AFTER_TEST_METHOD) // chạy SAU test
void thong_ke_theo_thang() { ... }
// ➜ KHI NÀO DÙNG @Sql thay vì TestEntityManager:
// · dữ liệu nhiều (hàng trăm dòng) — viết SQL gọn hơn hẳn code Java
// · cần trạng thái mà entity KHÔNG tạo ra được: dữ liệu cũ thiếu cột,
// giá trị mà validation hiện tại sẽ chặn, dữ liệu do hệ thống khác ghi vào
// · test một VIEW hay stored procedure
// ❗ NHƯNG: @Sql viết bằng phương ngữ SQL cụ thể. Script cho Postgres sẽ
// KHÔNG chạy trên H2. Đây là một lý do nữa để chọn Testcontainers.
// BỐN CÁI BẪY CÒN LẠI của @DataJpaTest:
// ① TEST TRÙNG VỚI SPRING DATA — đừng test thứ Spring đã test rồi:
@Test void save_thi_luu_duoc() { repo.save(don); } // ❌ bạn đang test Spring Data
// ✅ HÃY TEST: @Query bạn tự viết, Specification, phương thức phái sinh phức tạp,
// ràng buộc unique, cascade, orphanRemoval — những thứ THUỘC VỀ BẠN.
// ② N+1 KHÔNG LỘ RA — vì mọi thứ trong một transaction, lazy loading luôn thành công.
// Muốn bắt N+1 (Ngày 27) thì bật đếm câu lệnh:
spring.jpa.properties.hibernate.generate_statistics=true
// Rồi khẳng định số truy vấn — đây là cách duy nhất bắt được N+1 trong test.
// ③ QUÊN em.flush() — câu SQL chưa hề chạy, và test khẳng định lên hư không.
// ④ TEST PHỤ THUỘC THỨ TỰ — vì thiếu ORDER BY, kết quả có vẻ ổn định trên H2
// rồi đổi thứ tự trên Postgres. Luôn sắp rõ ràng, hoặc khẳng định không theo thứ tự:
assertThat(kq).containsExactlyInAnyOrder(...);Đừng test thứ Spring Data đã test rồi — hãy test
@Querybạn tự viết, ràng buộc unique, cascade: những thứ thuộc về bạn.N+1 của Ngày 27 không bao giờ lộ ra trong
@DataJpaTestvì mọi thứ nằm trong một transaction — muốn bắt thì phải bậtgenerate_statisticsvà khẳng định số truy vấn.Thiếu
ORDER BYthì kết quả ổn định trên H2 rồi đổi thứ tự trên Postgres — dùngcontainsExactlyInAnyOrderhoặc sắp rõ ràng.@Sqlviết bằng phương ngữ cụ thể: script Postgres không chạy trên H2 — thêm một lý do chọn Testcontainers.
Bài tập nhỏ
Viết một
@DataJpaTestrồi kiểm tra DB sau khi chạy — thấy dữ liệu đã bị rollback.Bỏ
em.flush()vàem.clear()rồi xem test vẫn xanh dù truy vấn chưa hề chạy xuống DB.Đổi từ H2 sang Testcontainers Postgres với
@ServiceConnectionvà so thời gian chạy.Viết một truy vấn dùng
jsonbđể thấy nó chạy trên Postgres mà vỡ trên H2.Bật
generate_statisticsrồi khẳng định số câu truy vấn để bắt một ca N+1.
Kết lại
Bốn ý gói lại hôm nay: mỗi test là một transaction bị rollback nên test không làm bẩn nhau, nhưng lỗi lúc commit cũng có thể không lộ ra; flush() và clear() là hai dòng quyết định test của bạn có chứng minh được gì không; H2 không phải Postgres — và "xanh trên H2, chết trên production" là loại lỗi làm cả đội mất niềm tin vào bộ test, nên có @ServiceConnection rồi thì Testcontainers đáng dùng hơn nhiều; và đừng test thứ Spring Data đã test, hãy test @Query và ràng buộc của chính bạn. Ngày 54 lên tầng cao nhất: @SpringBootTest — chạy cả ứng dụng thật, và khi nào thì xứng đáng. 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.


