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

Ảnh chụp đoạn mã nền tối test tích hợp với PostgreSQL thật năm cách cô lập một bẫy một ảo tưởng với Go 1.23 cộng pgx pg-lab PostgreSQL 16 SQLite trong bộ nhớ modernc.org sqlite để so cùng khối việc 8 INSERT cộng 1 SELECT, năm chiến lược cô lập cùng một khối việc gồm DBPerTest create database t nano template t4_template rồi chạy rồi drop database là sạch tuyệt đối, SchemaPerTest create schema s nano set search_path chạy schema.sql rồi chạy rồi drop schema cascade, TxRollback tx bằng conn.Begin run t tx tx.Rollback không ghi gì xuống đĩa, Truncate chạy trên bảng dùng chung rồi truncate orders customers restart identity, PoolNoCleanup pool dùng chung không dọn là cận dưới chỉ còn chi phí truy vấn, bẫy của TxRollback mã dưới test tự mở kết nối riêng và COMMIT gồm tx bằng conn.Begin tx.QueryRow insert into customers returning id Scan cid là khách chưa commit, err bằng placeOrderWithOwnTx cid 5 là code sản phẩm pgx.Connect mới Begin INSERT orders Commit, tx.Rollback, ghi chú kết nối thứ hai không thấy khách chưa commit khoá ngoại từ chối test đỏ oan nếu nó thấy được khách đã có sẵn thì đơn được COMMIT thật và rò sang test sau, ảo tưởng SQLite cho nhanh 6 lỗi ứng dụng chạy cùng mã test trên hai CSDL gồm 1 INSERT email 25 ký tự vào varchar 10, 2 INSERT amount 3.000.000.000 vào integer, 3 DELETE khách còn đơn khoá ngoại, 4 LIKE an phần trăm mong khớp An, 5 WHERE name bằng tham số kiểu số, 6 GROUP BY customer_id nhưng SELECT name, ghi chú bắt là CSDL từ chối hoặc kết quả khác ý định test đỏ trong CI lọt là xanh lỗi đi thẳng lên production, container mới mỗi lần chạy docker run postgres 16 fsync off rồi pg_isready cộng select 1 ba lần đo trên máy này

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ả

Ảnh chụp output thật nền tối rollback 2,9 ms DB mỗi test 20,5 ms SQLite lọt 6 trên 6 với go test count 30 trong go-lab pg-lab cùng máy container mới đo từ host Docker, chi phí mỗi test theo chiến lược cô lập khấu hao 30 lượt không gồm biên dịch gồm PoolNoCleanup 3,40 ms 1,0 lần cận dưới không sạch bẫy test phụ thuộc thứ tự, TxRollback 2,87 ms 0,8 lần rẻ hơn cả không dọn vì không fsync bẫy mã tự COMMIT kết nối riêng, Truncate 5,70 ms 1,7 lần sạch bảng bẫy chạy song song là đè nhau, SchemaPerTest 9,13 ms 2,7 lần sạch schema bẫy search_path phải đi theo mọi kết nối, DBPerTest 20,47 ms 6,0 lần sạch tuyệt đối bẫy tốn 6 lần không chạy được trong transaction, TxRollback vỡ ở đâu gồm mã tự COMMIT trên kết nối riêng lỗi commit unexpectedly resulted in rollback vì kết nối thứ hai không thấy khách chưa commit khoá ngoại từ chối INSERT test đỏ dù mã đúng và với khách có sẵn đơn COMMIT thật rò sang test sau rollback chỉ bọc được mã dùng đúng tx của test, 6 lỗi ứng dụng SQLite trong bộ nhớ so với PostgreSQL 16 gồm email 25 ký tự vào varchar 10 cho qua lọt và TỪ CHỐI bắt, amount 3 tỉ vào integer cho qua và từ chối, xoá khách còn đơn khoá ngoại cho qua và từ chối vì SQLite mặc định tắt FK, LIKE an phần trăm mong khớp An cho qua và từ chối vì LIKE của PG phân biệt hoa thường, so sánh cột text với tham số số cho qua và từ chối, GROUP BY thiếu cột cho qua và từ chối, tổng 0 trên 6 và 6 trên 6, container postgres 16 mới fsync off tới khi select 1 trả lời 0,80 s 0,77 s 0,73 s một truy vấn tới container đang chạy 0,056 s mỗi gói một container là chấp nhận được, nói thẳng CSDL cùng máy nên mọi mili giây ở đây là cận dưới qua mạng chênh lệch giữa các chiến lược thu hẹp vì RTT át hết thứ không đổi theo máy SQLite lọt 6 trên 6 nó không chạy SQL của bạn nó chạy một phương ngữ khác

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_id nhưng SELECT 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ề

  1. 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.
  2. 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.
  3. 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

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.