Phần 3 kết thúc với một lưu ý: fake cần được tin, và cách để tin là chạy cùng bộ ca trên cài đặt thật. Bài này là về cái "thật" đó — PostgreSQL thật trong test — và hai câu hỏi thực dụng đi kèm. Một: mỗi test cần cơ sở dữ liệu sạch, có năm cách làm, cách nào tốn bao nhiêu và vỡ ở đâu? Hai: có nên thay bằng SQLite trong bộ nhớ "cho nhanh" như nhiều dự án vẫn làm? Cả hai đều đo được.
Năm cách cô lập và một phép so sánh

Hình 1: Năm chiến lược cô lập trên cùng một khối việc, cái bẫy của rollback, và sáu lỗi ứng dụng để so SQLite với PostgreSQL.
Khối việc đại diện cho một test tích hợp: chèn ba khách, năm đơn, truy vấn tổng và kiểm. Năm cách để test sau không thấy rác của test trước:
- Cơ sở dữ liệu mới mỗi test, tạo từ một database template đã có schema (
create database ... template), xoá sau khi xong. Sạch tuyệt đối, kể cả sequence và dữ liệu rò. - Schema mới mỗi test trong cùng database, đổi
search_path, chạy file schema, xoá cascade. - Transaction rồi rollback: mở transaction, chạy toàn bộ test bên trong, rollback. Không ghi gì xuống đĩa.
- Truncate các bảng dùng chung sau mỗi test, kèm
restart identity. - Pool dùng chung, không dọn: không phải chiến lược, mà là cận dưới — chi phí thuần của truy vấn, để biết mỗi cách dọn tốn thêm bao nhiêu.
Thêm hai phép đo. Một là bẫy của rollback: mã dưới test tự mở kết nối riêng (như mọi code sản phẩm dùng pool) rồi commit, trong khi dữ liệu mồi của test nằm trong transaction chưa commit. Hai là container mới: docker run postgres:16, đo tới khi select 1 trả lời, ba lần.
Phần thứ hai: sáu lỗi ứng dụng kiểu thường gặp — email quá dài so với cột, số tiền vượt int4, xoá khách còn đơn, tìm kiếm bằng LIKE với giả định không phân biệt hoa thường, so sánh cột text với tham số số, GROUP BY thiếu cột. Cùng một mã test chạy trên SQLite trong bộ nhớ và trên PostgreSQL thật; "bắt" nghĩa là cơ sở dữ liệu từ chối hoặc kết quả khác ý định, tức test đỏ trong CI.
Đo thật: giá của sạch, và giá của giả

Hình 2: Chi phí mỗi test theo chiến lược, chỗ rollback vỡ, và bảng sáu lỗi trên hai cơ sở dữ liệu.
| chiến lược | ms mỗi test | sạch | vỡ ở đâu |
|---|---|---|---|
| pool không dọn | 3,40 | không | test phụ thuộc thứ tự |
| transaction + rollback | 2,87 | có | mã tự commit, kết nối riêng |
| truncate | 5,70 | có | chạy song song đè nhau |
| schema mỗi test | 9,13 | có | search_path theo mọi kết nối |
| database mỗi test | 20,47 | tuyệt đối | tốn 6×, không trong transaction |
| container mới | ~800 | tuyệt đối | mỗi gói, không mỗi test |
Rollback rẻ hơn cả không dọn. 2,87 ms so với 3,40 ms của pool dùng chung — vì transaction bị rollback không bao giờ phải fsync WAL lúc commit. Với một nghìn test, đó là 3 giây thay vì 20 giây của database-mỗi-test. Đây là lý do nó là lựa chọn mặc định của phần lớn bộ test tích hợp, và là lý do cần biết chính xác nó vỡ ở đâu.
Rollback vỡ khi mã dưới test tự quản lý transaction. Test mở transaction, chèn khách (chưa commit), rồi gọi hàm sản phẩm placeOrderWithOwnTx — hàm này làm điều mọi code dùng pool đều làm: lấy kết nối riêng, mở transaction riêng, chèn đơn, commit. Kết quả thật: commit unexpectedly resulted in rollback. Kết nối thứ hai không thấy khách chưa commit của kết nối thứ nhất, khoá ngoại từ chối, test đỏ dù mã hoàn toàn đúng. Và mặt kia của cùng vấn đề: nếu khách có sẵn, đơn sẽ được commit thật và rò sang test sau — rollback của test không bọc được một transaction khác. Rollback-per-test chỉ an toàn khi mọi truy vấn trong test đi qua đúng tx mà test cầm; code sản phẩm nhận pool thay vì nhận interface chạy được trên cả tx lẫn pool là code không dùng được chiến lược này.
Database mỗi test đắt 6 lần, và là cách duy nhất sạch tuyệt đối. 20,47 ms chủ yếu là create database từ template (sao chép thư mục) và drop database. Nó chịu được mã tự commit, chạy song song không đụng nhau (mỗi test một tên), và dọn cả sequence lẫn dữ liệu rò. Schema-mỗi-test ở giữa (9,13 ms) nhưng đòi mọi kết nối — kể cả kết nối code sản phẩm tự mở — phải đặt đúng search_path, một nguồn lỗi khó hiểu khác.
Container mới chỉ tốn 0,8 giây. Ba lần đo: 0,80, 0,77, 0,73 giây từ docker run tới khi select 1 trả lời, với image đã có sẵn và fsync=off. Rẻ hơn nhiều người nghĩ — đủ rẻ để mỗi gói test có container riêng, nhưng vẫn gấp 40 lần database-mỗi-test, nên không phải mỗi test. Kết hợp hay gặp: một container mỗi gói (hoặc mỗi lần CI), một database template dựng một lần, rồi rollback hoặc database-mỗi-test tuỳ mã có tự quản lý transaction hay không.
SQLite trong bộ nhớ cho qua 6/6 lỗi. Cùng một mã test:
- Email 25 ký tự vào
varchar(10): SQLite không kiểm độ dài, PostgreSQL từ chối. - 3 tỉ vào
integer: SQLite lưu được vì kiểu là gợi ý, PostgreSQL từ chối vì int4 tối đa 2,1 tỉ. - Xoá khách còn đơn: SQLite mặc định tắt khoá ngoại, PostgreSQL từ chối.
LIKE 'an%'tìm thấy "An" trên SQLite (không phân biệt hoa thường với ASCII), không thấy trên PostgreSQL — tính năng tìm kiếm chạy khác hẳn trên production.- So sánh cột text với tham số số: SQLite ép kiểu ngầm, PostgreSQL báo
operator does not exist. GROUP BY customer_idnhưngSELECT name: SQLite cho phép "cột trần", PostgreSQL từ chối.
Sáu lỗi này không hiếm; chúng là lỗi điển hình của lớp dữ liệu. Test trên SQLite xanh cả sáu và lỗi đi thẳng lên production. Hai trong sáu (so sánh kiểu, GROUP BY) còn tệ hơn: câu SQL hợp lệ với SQLite nhưng không chạy được trên PostgreSQL, nghĩa là bộ test không hề thực thi câu lệnh production sẽ thực thi.
Đánh đổi cần cân nhắc
Chọn chiến lược theo cách mã dùng kết nối, không theo con số. Mã nhận một interface chạy được trên cả transaction lẫn pool (pgx có pgx.Tx và pgxpool.Pool cùng thoả Exec/Query/QueryRow) thì rollback là đúng và rẻ. Mã tự mở kết nối, tự commit, hoặc có goroutine ghi nền thì phải dùng database-mỗi-test, chấp nhận 6×. Trộn hai loại trong một gói là nguồn của test giòn (phần 9).
Dữ liệu cùng máy là cận dưới. Cơ sở dữ liệu qua mạng cộng thêm một RTT cho mỗi truy vấn; với tám lệnh một test, 1 ms RTT là thêm 8 ms cho mọi chiến lược, và chênh lệch giữa chúng thu hẹp. Thứ không thu hẹp: số lần round-trip — chạy schema bằng một lệnh nhiều câu, không phải nhiều lệnh.
SQLite có chỗ của nó, không phải chỗ này. Nếu production là SQLite thì test trên SQLite là đúng. Nếu production là PostgreSQL thì SQLite trong test là test một phần ngôn ngữ khác; muốn nhanh, dùng rollback trên PostgreSQL thật — 2,87 ms một test không chậm hơn SQLite là bao, và nó chạy đúng câu SQL.
Phạm vi đo. Một khối việc nhỏ, fsync=off ở container mới; database-mỗi-test đắt hơn nữa khi schema lớn (template lớn hơn thì sao chép lâu hơn). Container 0,8 giây là với image đã kéo sẵn; lần đầu trên máy CI sạch là thêm thời gian kéo image.
Ba ý mang về
- Rollback-mỗi-test là rẻ nhất (2,87 ms, rẻ hơn cả không dọn) nhưng chỉ bọc được mã dùng đúng transaction của test; mã tự mở kết nối và commit làm test đỏ oan hoặc rò dữ liệu thật. Mã có tự quản lý transaction thì database-mỗi-test (20,5 ms) là giá phải trả cho sạch.
- Container mới chỉ 0,8 giây với image sẵn: một container mỗi gói là hợp lý, mỗi test thì không.
- SQLite trong bộ nhớ lọt 6/6 lỗi lớp dữ liệu mà PostgreSQL bắt 6/6, và hai câu SQL hợp lệ với nó không chạy nổi trên production. Không có "nhanh hơn" nào đáng giá bằng việc test không thực thi câu SQL thật.
Nguồn
- PostgreSQL — Template Databases: https://www.postgresql.org/docs/16/manage-ag-templatedbs.html
- PostgreSQL — Transaction Isolation (vì sao kết nối khác không thấy dữ liệu chưa commit): https://www.postgresql.org/docs/16/transaction-iso.html
- SQLite — Datatypes In SQLite (kiểu là gợi ý, không ràng buộc): https://www.sqlite.org/datatype3.html
- SQLite — Foreign Key Support (mặc định tắt, cần PRAGMA): https://www.sqlite.org/foreignkeys.html
- Testcontainers for Go — PostgreSQL module: https://golang.testcontainers.org/modules/postgres/
Phần sau đổi cách sinh dữ liệu: thay vì bạn nghĩ ra từng ca, để máy sinh hàng nghìn đầu vào ngẫu nhiên và tự thu nhỏ phản ví dụ — đo xem property-based testing tìm được lỗi nào mà bảng ca cố định của phần 2 bỏ sót.