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.

Bài 95 sê-ri Java còn đo một điều nữa: H2 không nhanh hơn PostgreSQL. Chèn hàng loạt thì PostgreSQL nhanh gấp đôi. Cái đắt của Testcontainers là 680 ms khởi động — trả một lần cho cả bộ test.

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.