Cách quen thuộc để test tầng dữ liệu là dùng H2 trong bộ nhớ: nhanh, không cần cài gì, chạy được trên mọi máy. Cách còn lại là chạy PostgreSQL thật trong Docker bằng Testcontainers.

Lập luận thường thấy: "H2 nhanh hơn nhiều, đổi lại kém giống thật". Tôi đo cả hai vế và vế thứ nhất gần như sai.

Chỗ chúng khác nhau

Tôi viết chín câu SQL, chạy y hệt trên hai bên, cùng một bảng t(id int, ma varchar(5), tien numeric(10,2)):

  câu lệnh                                 | 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       | OK -> 0       | OK -> 0
  chia cho 0                               | LỖI           | LỖI
  nối chuỗi với NULL                       | OK -> null    | OK -> null
  ép kiểu ngầm text sang số                | LỖI           | LỖI
  ---------------------------------------- | ------------- | -------------------------------
  nhóm thiếu cột trong select              | OK -> abc     | LỖI: column "t.tien" must appear
  cột không có trong GROUP BY              | OK -> abc     | LỖI: column "t.ma" must appear
  dấu nháy kép quanh tên cột "MA"          | OK -> abc     | LỖI: column "MA" does not exist
  điều kiện WHERE là số nguyên             | OK -> 1       | LỖI: argument of WHERE must be
                                           |               |       type boolean, not integer

Năm dòng đầu giống hệt nhau. Bốn dòng cuối là chỗ H2 nói "được" còn PostgreSQL nói "không".

Hướng của sai lệch mới là điều đáng sợ: H2 dễ dãi hơn. Test của bạn xanh, rồi sản xuất đỏ. Nếu ngược lại — H2 chặt hơn — thì bạn chỉ mất công sửa những thứ không cần sửa.

Dòng "MA" đáng nói riêng vì nó rất khó đoán: H2 chuyển tên định danh không có nháy thành CHỮ HOA, PostgreSQL chuyển thành chữ thường. Nên select "MA" from t tìm thấy cột trên H2 và không tìm thấy trên PostgreSQL. Nếu bạn từng gặp lỗi column "..." does not exist trong khi nhìn rõ cột đó tồn tại, đây chính là nguyên nhân.

Hai dòng GROUP BY cũng phổ biến không kém: viết thiếu cột trong GROUP BY là lỗi SQL chuẩn, nhưng H2 bỏ qua. Bạn viết một truy vấn báo cáo, test xanh, rồi nó nổ trên máy chủ.

H2 có chế độ tương thích (MODE=PostgreSQL) và nó thu hẹp khoảng cách, nhưng không xoá hết. Nó vẫn là một bộ máy khác, với bộ tối ưu khác, kiểu dữ liệu khác, và không có các tính năng riêng của PostgreSQL như jsonb, mảng, hay khoá tư vấn. Chế độ tương thích làm bạn tin mình an toàn hơn thực tế.

Còn tốc độ thì sao

Đây là phần tôi vào với giả định sai. Cùng thao tác, hai bên:

                | kết nối | chèn 2000 dòng | 300 truy vấn
  H2            | 149 ms  |     48 ms      |    64 ms
  PostgreSQL    | 161 ms  |     21 ms      |   121 ms
  H2            | 152 ms  |     51 ms      |    66 ms
  PostgreSQL    | 169 ms  |     17 ms      |   109 ms

Kết nối gần như bằng nhau. Chèn hàng loạt thì PostgreSQL nhanh hơn gấp đôi. Chỉ ở truy vấn lặp lại PostgreSQL mới chậm hơn, khoảng 1,8 lần — và đó là chi phí đi qua socket mạng.

Nghĩa là câu "H2 nhanh hơn nhiều" không đúng ở mức thao tác. Cái đắt nằm ở chỗ khác:

  khởi động PostgreSQL tới lúc sẵn sàng: 675 ms | 699 ms | 675 ms

Khoảng 680 mili giây, một lần. Đó là toàn bộ cái giá thật.

Nên đánh đổi đúng ra là: một lần trả 680 ms để đổi lấy việc test chạy trên chính bộ máy mà sản xuất dùng — chứ không phải "đánh đổi tốc độ lấy độ tin cậy" như cách nói quen thuộc. Với một bộ test chạy vài chục giây, 680 ms là không đáng kể.

Testcontainers trông như thế nào

@Testcontainers
class KhoTest {
    @Container
    static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");

    static Connection c;

    @BeforeAll static void mo() throws Exception {
        c = DriverManager.getConnection(pg.getJdbcUrl(), pg.getUsername(), pg.getPassword());
    }
}

Thư viện lo phần còn lại: kéo image, khởi động, chờ tới khi cơ sở dữ liệu thật sự sẵn sàng nhận kết nối (không phải chỉ "container đã chạy"), cấp cổng ngẫu nhiên để không đụng nhau, và dọn dẹp khi test xong.

Cổng ngẫu nhiên là chi tiết đáng giá hơn vẻ ngoài: nó cho phép chạy nhiều bộ test song song trên cùng máy CI mà không xung đột — thứ mà một PostgreSQL cài cứng ở cổng 5432 không làm được.

Với Spring Boot 3.1 trở lên còn gọn hơn nữa:

@ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16-alpine");

Spring tự lấy URL, tên đăng nhập, mật khẩu từ container. Không phải khai @DynamicPropertySource như trước nữa.

Một lưu ý trung thực về bài này: tôi không chạy được chính Testcontainers trong môi trường đo của mình — nó cần truy cập Docker từ bên trong một container, và trên macOS đường đó không thông. Mọi con số ở trên đo bằng PostgreSQL khởi động trực tiếp, tức là đúng phần việc mà Testcontainers làm, chỉ khác chỗ ai gõ lệnh. Phần mô tả API ở trên là từ tài liệu và từ kinh nghiệm dùng, không kèm số đo.

Giữ cho nó không chậm

@Container gắn trên trường static thì container dùng chung cho cả lớp test; gắn trên trường thực thể thì mỗi test một container — 680 ms nhân với số test. Đây là lỗi hay gặp nhất và cũng dễ sửa nhất.

Muốn dùng chung cho toàn bộ bộ test thì có hai cách:

Mẫu singleton — một lớp cơ sở khởi động container trong khối static và không bao giờ dừng nó:

abstract class CoCSDL {
    static final PostgreSQLContainer<?> PG = new PostgreSQLContainer<>("postgres:16-alpine");
    static { PG.start(); }        // không gọi stop(); Ryuk dọn khi JVM thoát
}

Chế độ tái dùngwithReuse(true) cộng testcontainers.reuse.enable=true trong ~/.testcontainers.properties. Container sống sót qua nhiều lần chạy test, nên lần thứ hai trở đi gần như không tốn gì. Rất hợp cho máy lập trình viên; trên CI thì không có tác dụng vì mỗi lần là máy mới.

Và điều quan trọng hơn cả hai: dọn dữ liệu, đừng dựng lại container. Giữa các test, TRUNCATE các bảng hoặc bọc mỗi test trong một giao dịch rồi rollback — cả hai đều tính bằng mili giây.

Không chỉ cơ sở dữ liệu

Cùng mô hình áp dụng cho mọi thứ khác mà bạn hay bị cám dỗ đem đi mock:

KafkaContainer, RabbitMQContainer, MinIOContainer cho lưu trữ đối tượng, GenericContainer cho image bất kỳ, và MockServerContainer khi cần giả một API HTTP bên ngoài.

Đây là câu trả lời cho điểm cuối của bài hôm qua: đừng mock thứ bạn không sở hữu. Mock một driver JDBC là mã hoá giả định của bạn về cách nó hoạt động; chạy CSDL thật thì không cần giả định nào.

Khi nào vẫn nên dùng H2

Tôi không nghĩ nên bỏ hẳn H2. Nó vẫn đúng chỗ khi:

Chạy ứng dụng lúc phát triển, không phải test — đúng như blog này dùng H2 cho profile dev.

Test không chạm SQL thật — kiểm tầng nghiệp vụ, ánh xạ, cấu hình.

Môi trường không có Docker. Vẫn tồn tại, nhất là trên máy công ty bị hạn chế.

Nhưng với test kiểm chính truy vấn SQL, bảng đầu bài là lý do đủ: bốn trong chín câu cho kết quả khác nhau, và cả bốn đều theo hướng H2 dễ dãi hơn.

Một quy tắc gọn: H2 để chạy, CSDL thật để kiểm.

Thử ba mươi giây

Lấy một truy vấn phức tạp nhất trong dự án của bạn — có GROUP BY, hàm cửa sổ, hoặc kiểu riêng của PostgreSQL — rồi chạy nó trên H2.

Nếu nó chạy được ở cả hai nơi, tốt. Nếu H2 từ chối, bạn biết mình đang có một truy vấn không test được bằng H2. Còn trường hợp nguy hiểm nhất — H2 chấp nhận mà PostgreSQL từ chối — thì ba mươi giây này không phát hiện được, và đó chính là lý do tồn tại của Testcontainers.

Ngày mai: kiểm thử mã đồng thời — vì sao loại test này hay chập chờn, và những kỹ thuật làm nó ổn định thay vì chạy lại cho tới khi xanh.