Giao diện
Test với database
Code chạm database có nhiều lỗi chỉ lộ ra khi chạy trên database thật: query sai cú pháp với engine đang dùng, vi phạm ràng buộc, deadlock, migration không chạy được. Trang này giải thích vì sao nên test với database thật, cách giữ cho các test không ảnh hưởng nhau mà vẫn nhanh, và nên test những gì ngoài logic nghiệp vụ.
Dùng database thật, không dùng SQLite hay mock
Mock repository thì test nhanh, nhưng không kiểm tra được câu SQL, ràng buộc hay transaction, tức đúng những chỗ hay sai nhất. Thay bằng SQLite trong RAM thì nhanh và không cần cài gì, nhưng SQLite khác MySQL và PostgreSQL ở những điểm ảnh hưởng thẳng tới kết quả test. Chạy cùng các câu lệnh trên ba database:
| Phép thử | SQLite | MySQL 8.0 | PostgreSQL 17 |
|---|---|---|---|
Chèn chuỗi 'abc' vào cột INT | Chấp nhận | Lỗi | Lỗi |
Chuỗi dài hơn VARCHAR(20) | Chấp nhận | Lỗi | Lỗi |
| Foreign key trỏ tới dòng không tồn tại | Chấp nhận (mặc định không kiểm FK) | Lỗi | Lỗi |
| UNIQUE trên email, thêm bản khác hoa thường | Chấp nhận | Lỗi trùng (collation không phân biệt hoa thường) | Chấp nhận |
'a' = 'A' | Khác nhau | Bằng nhau | Khác nhau |
GROUP BY thiếu cột trong SELECT | Trả về một dòng tuỳ ý | Lỗi | Lỗi |
7 / 2 | 3 | 3.5000 | 3 |
Một test thêm user với email trùng khác hoa thường sẽ qua trên SQLite và PostgreSQL nhưng lỗi trên MySQL. Một bug ghi chuỗi vào cột số sẽ không bao giờ lộ ra nếu chỉ test bằng SQLite. Bảng cũng cho thấy MySQL và PostgreSQL khác nhau ở nhiều điểm, nên test phải chạy trên đúng engine và đúng phiên bản dùng ở production.
Cách phổ biến là chạy database trong container. Thư viện Testcontainers (có bản cho Node.js) tự dựng container lúc bắt đầu chạy test và xoá khi xong. Trong CI cũng có thể dùng service container của GitHub Actions hay GitLab CI. Container MySQL hay PostgreSQL khởi động mất vài giây, nên dựng một lần cho cả lượt chạy test, không dựng lại cho từng test.
Cô lập dữ liệu giữa các test
Mỗi test phải bắt đầu từ trạng thái biết trước. Nếu test trước để lại dữ liệu, test sau có thể qua hay hỏng tuỳ thứ tự chạy. Có bốn cách:
| Cách | Làm gì | Chi phí mỗi test (lab) | Giới hạn |
|---|---|---|---|
| Rollback | Mỗi test chạy trong một transaction, cuối test ROLLBACK | Khoảng 5 ms trên cả hai database | Code được test phải dùng đúng connection của transaction đó; không test được code tự commit hay dùng nhiều connection |
| TRUNCATE | Cuối test xoá sạch mọi bảng | 9 ms trên PostgreSQL, khoảng 230 ms trên MySQL | MySQL tạo lại từng bảng khi truncate nên chậm; phải tắt kiểm FK |
| DELETE | Cuối test DELETE mọi bảng theo thứ tự con trước cha | 9 ms trên PostgreSQL, khoảng 44 ms trên MySQL | Phải giữ đúng thứ tự xoá theo FK |
| Database mới từ mẫu | PostgreSQL: CREATE DATABASE t TEMPLATE mau cho mỗi test hay mỗi nhóm test | Khoảng 50 ms | Chỉ có trên PostgreSQL; hợp với chạy song song nhiều worker |
Rollback nhanh nhất nhưng có một cái bẫy: nó chỉ cô lập được những gì chạy trên cùng một connection. Nếu code dùng connection pool và tự lấy một connection khác, dữ liệu của nó đã commit thật và rollback không xoá được. Muốn dùng cách này thì code phải nhận connection hay transaction từ bên ngoài:
js
import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { pool } from './db.js' // mysql2/promise
import { createOrder } from './orders.js' // createOrder(conn, input): nhận connection từ bên ngoài
let conn
beforeEach(async () => { conn = await pool.getConnection(); await conn.query('BEGIN') })
afterEach(async () => { await conn.query('ROLLBACK'); conn.release() })
test('không cho đặt hàng khi hết tồn kho', async () => {
await conn.query('INSERT INTO products (id, stock) VALUES (1, 0)')
await assert.rejects(createOrder(conn, { productId: 1, qty: 1 }), /hết hàng/)
})Code tự mở transaction bên trong, như createOrder có BEGIN … COMMIT của riêng nó, thì test bằng TRUNCATE hay DELETE, hoặc dùng savepoint để transaction bên trong lồng được vào transaction của test.
Chạy test song song thì các worker không được dùng chung bảng. Cho mỗi worker một database riêng (test_1, test_2, …), tạo một lần lúc bắt đầu. PostgreSQL tạo nhanh bằng template, MySQL thì tạo schema bằng cách chạy migration.
Dữ liệu cho test: fixture và factory
- Fixture là một bộ dữ liệu cố định nạp sẵn trước khi chạy test, ví dụ danh mục sản phẩm, cấu hình. Hợp với dữ liệu tham chiếu ít đổi.
- Factory là hàm tạo dữ liệu với giá trị mặc định hợp lệ, test chỉ ghi đè trường mình quan tâm:
await createUser(conn, { email: 'an@example.com' }). Mỗi test tự tạo đúng dữ liệu nó cần, nên đọc test là thấy ngay điều kiện ban đầu.
Một bộ fixture lớn dùng chung cho mọi test làm các test ngầm phụ thuộc vào nhau: sửa fixture cho test này lại làm hỏng test khác. Nên dùng factory là chính.
Ngoài logic nghiệp vụ, nên test gì
- Migration: CI chạy toàn bộ migration từ đầu trên database trống, rồi chạy test trên schema đó. Cách này bắt được migration sai thứ tự, migration chỉ chạy được trên máy một người, và lệch giữa migration với schema mà code mong đợi (Migration trong team).
- Ràng buộc: test rằng database thật sự chặn dữ liệu sai (email trùng, số âm, FK không tồn tại), vì ràng buộc là chốt chặn cuối khi code có bug (Race condition).
- Query quan trọng dùng index: với vài query chạy nhiều nhất, chạy
EXPLAINtrên một lượng dữ liệu đủ lớn rồi kiểm tra plan có dùng index mong đợi, không quét cả bảng. Test này hỏng khi ai đó lỡ xoá index hay viết lại query. - Race condition: chạy hai request song song trên hai connection để kiểm tra không bị trừ tồn kho hai lần. Loại lỗi này không lộ ra với test chạy tuần tự (Chống trùng & race condition).
Ghi nhớ
- Test trên đúng engine và phiên bản dùng ở production, chạy trong container. SQLite chấp nhận chuỗi vào cột số, chuỗi quá dài, FK không tồn tại; MySQL và PostgreSQL cũng khác nhau ở hoa thường, phép chia, GROUP BY.
- Rollback sau mỗi test là cách nhanh nhất, nhưng chỉ khi code dùng đúng connection của test. Không thì dùng TRUNCATE hay DELETE; trên MySQL, TRUNCATE nhiều bảng chậm hơn DELETE.
- Chạy song song thì mỗi worker một database riêng.
- Dùng factory tạo dữ liệu trong từng test thay vì một bộ fixture lớn dùng chung.
- CI chạy migration từ đầu trên database trống; test cả ràng buộc, plan của query quan trọng và race condition.
Nguồn tham khảo
Kiểm bằng lab: mọi kết quả trong bảng so sánh ba database và bảng chi phí cô lập. Từ kiến thức chung, chưa đối chiếu lần này: SQLite mặc định tắt kiểm foreign key (PRAGMA foreign_keys) và dùng kiểu dữ liệu linh hoạt (type affinity); Testcontainers; MySQL thực hiện TRUNCATE bằng cách xoá và tạo lại bảng.
Số đo chi tiết
Chạy bằng script lab/test/ ngày 2026-10-23 trên MacBook M2: SQLite qua node:sqlite của Node 24 (tính năng thử nghiệm), MySQL 8.0 và PostgreSQL 17 trong Docker.
Chi phí cô lập mỗi test
100 test, schema 10 bảng có foreign key; mỗi test thêm 1 user, 1 địa chỉ, 1 danh mục, 1 sản phẩm, 5 đơn hàng kèm dòng chi tiết rồi đọc lại (14 câu lệnh).
Hai lượt:
| Cách | MySQL 8.0 | PostgreSQL 17 |
|---|---|---|
| Rollback | 4,7–5,0 ms | 5,6–6,1 ms |
| TRUNCATE 10 bảng | 231–244 ms | 8,9–9,1 ms (một câu TRUNCATE cho cả 10 bảng) |
| DELETE 10 bảng | 44–45 ms | 8,1–9,1 ms |
| Database mới từ template | 48–50 ms |
Ở hai cách TRUNCATE và DELETE, thân test chạy ngoài transaction nên mỗi câu lệnh tự commit, cũng góp phần làm MySQL chậm hơn (Phân tích bài toán).
Code đã dùng để chạy thử
Chạy bằng npm run lab -- test sau npm run lab:up, khoảng 1 phút. Xem Lab.
lab/test/differences.mjs: cùng câu lệnh trên SQLite, MySQL, PostgreSQL
js
// Vì sao test bằng SQLite không thay được database thật: chạy cùng một số câu lệnh trên SQLite (node:sqlite, trong RAM),
// MySQL 8.0 và PostgreSQL 17, xem câu nào được chấp nhận và trả về gì.
// Chạy với: node --no-warnings test/differences.mjs (node:sqlite còn là tính năng thử nghiệm trong Node 24)
import { DatabaseSync } from 'node:sqlite'
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
ensureDb('mysql', 'test_diff')
ensureDb('pg', 'test_diff')
const lite = new DatabaseSync(':memory:')
const my = await mysql.createConnection({ ...MYSQL, database: 'test_diff' })
const p = new pg.Client({ ...PG, database: 'test_diff' })
await p.connect()
const SETUP = [
'DROP TABLE IF EXISTS orders',
'DROP TABLE IF EXISTS users',
'CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(20) NOT NULL UNIQUE)',
'CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, amount INT NOT NULL, FOREIGN KEY (user_id) REFERENCES users (id))',
"INSERT INTO users VALUES (1, 'an@example.com')",
'INSERT INTO orders VALUES (1, 1, 100), (2, 1, 250)'
]
const CASES = [
['Chèn chuỗi vào cột INT', "INSERT INTO orders VALUES (10, 1, 'abc')"],
['Chuỗi dài hơn VARCHAR(20)', "INSERT INTO users VALUES (2, 'mot-dia-chi-rat-dai@example.com')"],
['Foreign key trỏ tới user không tồn tại', 'INSERT INTO orders VALUES (11, 999, 100)'],
['UNIQUE khác hoa thường', "INSERT INTO users VALUES (3, 'AN@example.com')"],
["So sánh 'a' = 'A'", "SELECT CASE WHEN 'a' = 'A' THEN 'bằng nhau' ELSE 'khác nhau' END AS r"],
['GROUP BY thiếu cột trong SELECT', 'SELECT user_id, id FROM orders GROUP BY user_id'],
['Chia số nguyên 7 / 2', 'SELECT 7 / 2 AS r']
]
const run = {
SQLite: async (sql) => (/^\s*select/i.test(sql) ? lite.prepare(sql).all() : (lite.prepare(sql).run(), 'OK')),
'MySQL 8.0': async (sql) => { const [r] = await my.query(sql); return Array.isArray(r) ? r : 'OK' },
'PostgreSQL 17': async (sql) => { const r = await p.query(sql); return r.command === 'SELECT' ? r.rows : 'OK' }
}
for (const [name, fn] of Object.entries(run)) {
if (name === 'SQLite') { lite.exec('PRAGMA foreign_keys = OFF') } // mặc định của SQLite
for (const s of SETUP) await fn(s)
}
const show = (r) => (r === 'OK' ? 'chấp nhận' : JSON.stringify(r.map((x) => Object.values(x).join(', ')).join(' | ')))
console.log('\nPhép thử'.padEnd(42) + Object.keys(run).map((n) => n.padEnd(34)).join(''))
for (const [label, sql] of CASES) {
const cells = []
for (const fn of Object.values(run)) {
try { cells.push(show(await fn(sql)).slice(0, 32)) } catch (e) { cells.push(('LỖI: ' + e.message).slice(0, 32)) }
}
console.log(label.padEnd(41) + cells.map((c) => c.padEnd(34)).join(''))
}
await my.end()
await p.end()lab/test/isolation.mjs: chi phí của bốn cách cô lập
js
// Cô lập dữ liệu giữa các test: chi phí mỗi test của bốn cách, trên schema 10 bảng có foreign key.
// rollback: mỗi test chạy trong một transaction, cuối test ROLLBACK
// truncate: cuối test TRUNCATE mọi bảng
// delete: cuối test DELETE mọi bảng (theo thứ tự con trước cha)
// template (chỉ PostgreSQL): mỗi test tạo database mới từ một database mẫu (CREATE DATABASE … TEMPLATE)
// Mỗi "test" thêm 1 user, 5 đơn hàng, vài dòng ở các bảng khác rồi đọc lại.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const TESTS = 100
const ms = (v) => v.toFixed(1).replace('.', ',') + ' ms'
const TABLES = ['users', 'addresses', 'products', 'categories', 'orders', 'order_items', 'payments', 'shipments', 'reviews', 'coupons']
const SCHEMA = [
'CREATE TABLE users (id INT PRIMARY KEY, email VARCHAR(100) NOT NULL UNIQUE)',
'CREATE TABLE addresses (id INT PRIMARY KEY, user_id INT NOT NULL REFERENCES users (id), line VARCHAR(200) NOT NULL)',
'CREATE TABLE categories (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL)',
'CREATE TABLE products (id INT PRIMARY KEY, category_id INT NOT NULL REFERENCES categories (id), name VARCHAR(100) NOT NULL)',
'CREATE TABLE coupons (id INT PRIMARY KEY, code VARCHAR(20) NOT NULL UNIQUE)',
'CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL REFERENCES users (id), total INT NOT NULL)',
'CREATE TABLE order_items (order_id INT NOT NULL REFERENCES orders (id), product_id INT NOT NULL REFERENCES products (id), qty INT NOT NULL, PRIMARY KEY (order_id, product_id))',
'CREATE TABLE payments (id INT PRIMARY KEY, order_id INT NOT NULL REFERENCES orders (id), amount INT NOT NULL)',
'CREATE TABLE shipments (id INT PRIMARY KEY, order_id INT NOT NULL REFERENCES orders (id), status VARCHAR(20) NOT NULL)',
'CREATE TABLE reviews (id INT PRIMARY KEY, product_id INT NOT NULL REFERENCES products (id), user_id INT NOT NULL REFERENCES users (id), stars INT NOT NULL)'
]
// MySQL bỏ qua REFERENCES viết sau cột (xem MySQL chương 3), nên chuyển thành FOREIGN KEY ở mức bảng.
const toMy = (s) => s.replace(/(\w+) INT NOT NULL REFERENCES (\w+) \((\w+)\)/g, '$1 INT NOT NULL').replace(/\)$/, '') +
[...s.matchAll(/(\w+) INT NOT NULL REFERENCES (\w+) \((\w+)\)/g)].map(([, c, t, k]) => `, FOREIGN KEY (${c}) REFERENCES ${t} (${k})`).join('') + ')'
const DROP_ORDER = ['reviews', 'shipments', 'payments', 'order_items', 'orders', 'coupons', 'products', 'categories', 'addresses', 'users']
async function body(q) {
await q("INSERT INTO users VALUES (1, 'an@example.com')")
await q("INSERT INTO addresses VALUES (1, 1, 'Hà Nội')")
await q("INSERT INTO categories VALUES (1, 'Áo')")
await q("INSERT INTO products VALUES (1, 1, 'Áo thun')")
for (let i = 1; i <= 5; i++) {
await q(`INSERT INTO orders VALUES (${i}, 1, ${i * 100})`)
await q(`INSERT INTO order_items VALUES (${i}, 1, 1)`)
}
await q('SELECT COUNT(*) FROM orders WHERE user_id = 1')
}
async function bench(name, setup, each, teardown) {
await setup()
const t0 = performance.now()
for (let i = 0; i < TESTS; i++) await each()
const per = (performance.now() - t0) / TESTS
if (teardown) await teardown()
return [name, per]
}
ensureDb('mysql', 'test_iso')
ensureDb('pg', 'test_iso')
// ---------- MySQL ----------
const my = await mysql.createConnection({ ...MYSQL, database: 'test_iso' })
const mq = (s) => my.query(s)
const myReset = async () => { await mq('SET FOREIGN_KEY_CHECKS = 0'); for (const t of DROP_ORDER) await mq(`DROP TABLE IF EXISTS ${t}`); await mq('SET FOREIGN_KEY_CHECKS = 1'); for (const s of SCHEMA) await mq(toMy(s)) }
const myRes = []
myRes.push(await bench('rollback', myReset, async () => { await mq('BEGIN'); await body(mq); await mq('ROLLBACK') }))
myRes.push(await bench('truncate', myReset, async () => { await body(mq); await mq('SET FOREIGN_KEY_CHECKS = 0'); for (const t of TABLES) await mq(`TRUNCATE TABLE ${t}`); await mq('SET FOREIGN_KEY_CHECKS = 1') }))
myRes.push(await bench('delete', myReset, async () => { await body(mq); for (const t of DROP_ORDER) await mq(`DELETE FROM ${t}`) }))
await my.end()
// ---------- PostgreSQL ----------
const p = new pg.Client({ ...PG, database: 'test_iso' })
await p.connect()
const pq = (s) => p.query(s)
const pgReset = async () => { await pq(`DROP TABLE IF EXISTS ${DROP_ORDER.join(', ')} CASCADE`); for (const s of SCHEMA) await pq(s) }
const pgRes = []
pgRes.push(await bench('rollback', pgReset, async () => { await pq('BEGIN'); await body(pq); await pq('ROLLBACK') }))
pgRes.push(await bench('truncate', pgReset, async () => { await body(pq); await pq(`TRUNCATE ${TABLES.join(', ')}`) }))
pgRes.push(await bench('delete', pgReset, async () => { await body(pq); for (const t of DROP_ORDER) await pq(`DELETE FROM ${t}`) }))
// Database mẫu: tạo schema một lần trong test_tpl, mỗi test CREATE DATABASE … TEMPLATE test_tpl rồi DROP.
const admin = new pg.Client({ ...PG, database: 'postgres' })
await admin.connect()
await admin.query('DROP DATABASE IF EXISTS test_tpl')
await admin.query('CREATE DATABASE test_tpl')
const tpl = new pg.Client({ ...PG, database: 'test_tpl' })
await tpl.connect()
for (const s of SCHEMA) await tpl.query(s)
await tpl.end()
let k = 0
pgRes.push(await bench('template', async () => {}, async () => {
const name = `t_${k++}`
await admin.query(`CREATE DATABASE ${name} TEMPLATE test_tpl`)
const c = new pg.Client({ ...PG, database: name })
await c.connect()
await body((s) => c.query(s))
await c.end()
await admin.query(`DROP DATABASE ${name}`)
}))
await admin.query('DROP DATABASE test_tpl')
await admin.end()
await p.end()
console.log(`\n=== Chi phí trung bình mỗi test (${TESTS} test, schema 10 bảng có foreign key; phần thân test chạy 14 câu lệnh) ===`)
console.log('cách MySQL 8.0 PostgreSQL 17')
for (const name of ['rollback', 'truncate', 'delete', 'template']) {
const a = myRes.find((r) => r[0] === name), b = pgRes.find((r) => r[0] === name)
console.log(`${name.padEnd(13)} ${(a ? ms(a[1]) : 'không có').padEnd(14)} ${b ? ms(b[1]) : ''}`)
}