Test tầng dữ liệu chạy trên H2 vì nó nhanh. Bài này đo xem cái nhanh đó đổi lấy gì.
Chín phép thử, hai CSDL
H2 PostgreSQL 16
chuỗi dài hơn cột varchar(5) lỗi lỗi
so sánh chuỗi phân biệt hoa thường 0 0
group by cột không chọn lỗi lỗi
chia cho không lỗi lỗi
|| nối chuỗi với NULL null null
đặt tên cột trùng từ khoá: user LỖI cú pháp OK
boolean từ số: where 1 OK LỖI
chèn ký tự NUL OK (ghi 1 dòng) LỖI
Sáu dòng đầu giống nhau. Ba dòng cuối là chỗ test nói dối bạn.
Ba chỗ lệch
where 1 — H2 chấp nhận số làm điều kiện, PostgreSQL từ chối:
ERROR: argument of WHERE must be type boolean, not type integer
Câu SQL này chạy suốt trong test, và đỏ ngay lần đầu chạy sản xuất.
Ký tự NUL — H2 lưu bình thường, PostgreSQL từ chối:
ERROR: null character not permitted
Đây là bẫy có thật của chính blog này. Một bài viết bàn về giá trị mặc định của char trong Java là đủ để ký tự 0x00 lọt vào nội dung, và cả lần nhập bài hỏng. PostImportService.boKyTuNul giờ gỡ nó ra và ghi cảnh báo — nhưng phải mất một lần hỏng trên sản xuất mới biết, vì test dùng H2 không bao giờ phàn nàn.
select 1 as user — chiều ngược lại: H2 báo lỗi cú pháp, PostgreSQL chấp nhận. Nên H2 cũng làm bạn sửa thứ vốn không hỏng.
Gộp lại: H2 mua cho bạn 680 mili giây, đổi bằng việc test không còn chứng minh được gì về CSDL thật.
@DataJpaTest
@DataJpaTest
class RepoTacGiaTest {
@Autowired RepoTacGia repo;
@Autowired TestEntityManager em;
@Test void timTheoTen() {
em.persist(tacGia("Minh"));
em.flush(); em.clear(); // BẮT BUỘC
assertThat(repo.findByTen("Minh")).hasSize(1);
}
}
Nó chỉ nạp phần JPA — repository, entity, DataSource — nên nhanh hơn @SpringBootTest nhiều.
Hai điều phải biết:
Mỗi test chạy trong giao dịch và rollback ở cuối. Tiện, nhưng nó nghĩa là thao tác chạy trên kết nối khác sẽ không thấy dữ liệu của bạn — đó là hành vi đúng, đừng "sửa" nó.
flush() rồi clear() là bắt buộc trước khi khẳng định. Không có chúng, repo.findByTen trả về entity từ persistence context chứ không thật sự đi tới CSDL — và bạn đang test cache tầng một, không phải truy vấn.
Đây là lý do nhiều test repository xanh trong khi truy vấn thật sai.
Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE) // KHÔNG thay bằng H2
@Testcontainers
class RepoTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");
}
@ServiceConnection (Spring Boot 3.1+) tự cấu hình DataSource từ container — không cần @DynamicPropertySource nữa.
@AutoConfigureTestDatabase(replace = NONE) là dòng dễ quên nhất: không có nó, Spring vẫn thay CSDL của bạn bằng H2, và container chạy để không.
Khai static để container dùng chung cho cả lớp test. Không static là mỗi test một container, và bộ test của bạn mất vài phút.
Ba mẫu đáng dùng
Container dùng chung cho cả bộ test — khai trong một lớp cha, hoặc dùng chế độ tái sử dụng của Testcontainers. Trả 680 ms một lần thay vì mỗi lớp.
Cùng phiên bản với sản xuất. postgres:16-alpine nếu sản xuất chạy 16. Test trên 16 rồi chạy 14 là tự tạo một lớp khác biệt nữa.
Chạy Flyway trong test. Nếu sản xuất dùng Flyway (bài 33), test cũng phải — như vậy bạn kiểm luôn cả migration. Dùng ddl-auto: create-drop trong test là bỏ qua toàn bộ phần migration, và một migration hỏng chỉ lộ ra lúc triển khai.
Test số truy vấn
Bài 24 nói N+1 phải có gì đó ngăn nó quay lại. Đây là cách:
@Test void danhSachKhongDuocQua3TruyVan() {
var thongKe = ((Session) em.getDelegate()).getSessionFactory().getStatistics();
thongKe.setStatisticsEnabled(true);
thongKe.clear();
dichVu.layDanhSach();
assertThat(thongKe.getPrepareStatementCount()).isLessThanOrEqualTo(3);
}
Test này đỏ ngay khi ai đó thêm một quan hệ lazy vào vòng lặp. Tôi thấy nó là loại test có giá trị trên công sức cao nhất trong tầng dữ liệu — nó bắt được thứ mà test chức năng không bao giờ bắt được, vì kết quả vẫn đúng, chỉ là chậm gấp trăm lần.
Dữ liệu test
Dựng bằng mã, đừng dùng tệp SQL dùng chung. Một data.sql cho cả bộ test nghĩa là mỗi test phụ thuộc vào dữ liệu của test khác, và sửa một dòng làm đỏ mười test không liên quan.
Hàm dựng có mặc định:
static Don don(Consumer<Don.Builder> tuyBien) {
var b = Don.builder().ma("DH-1").trangThai(MOI).soTien(1000);
tuyBien.accept(b);
return b.build();
}
// trong test — chỉ khai thứ liên quan tới test này
var d = don(b -> b.trangThai(HUY));
Test đọc ra được điều nó đang kiểm, thay vì mười dòng dựng dữ liệu che mất một dòng quan trọng.
Testcontainers không chạy được ở đâu
Một hạn chế đã gặp khi viết sê-ri này: Testcontainers cần nói chuyện được với Docker daemon. Chạy bộ test bên trong một container (như CI kiểu docker-in-docker, hoặc như cách tôi đo trong sê-ri này) thì trên macOS nó không tới được daemon của máy chủ.
Cách chữa: gắn socket Docker vào container, hoặc dùng chế độ host networking, hoặc — như tôi đã làm trong sê-ri này — khởi động PostgreSQL bằng tay rồi trỏ vào.
Biết trước điều này tiết kiệm được một buổi chiều khi dựng CI.
Thử ba mươi giây
grep -rn "h2" pom.xml build.gradle 2>/dev/null
Thấy H2 trong phạm vi test nghĩa là bộ test của bạn đang chạy trên một CSDL khác với sản xuất. Bảng đầu bài cho thấy ba chỗ chúng bất đồng — và bạn chỉ biết chỗ thứ tư khi nó xảy ra trên sản xuất.
Ngày mai: Redis — cache chung, session chung, và khoá phân tán.