Jika Anda me-mock database dalam tes, Anda sedang menguji bahwa lapisan repository memanggil metode yang benar. Anda tidak menguji bahwa data bertahan dari crash, bahwa unique constraint benar-benar memblokir duplikat, atau bahwa transaction di-rollback ketika ada yang gagal.
Perbedaan itu penting. save() yang di-mock mengembalikan apa pun yang Anda perintahkan. save() yang nyata bisa deadlock, memotong string secara diam-diam, atau menulis ke replica yang tertinggal tiga detik di belakang primary. Suite tes Anda akan hijau sementara pengguna Anda kehilangan data.
Mengapa tes database yang di-mock memberikan rasa percaya diri palsu
Kebanyakan tes persistensi terlihat seperti ini:
// A test that verifies syntax, not physics
jest.mock('./db', () => ({
query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));
it('saves a user', async () => {
const repo = new UserRepository();
const user = await repo.create({ name: 'Ada' });
expect(user.id).toBe(1);
});
Tes ini membuktikan SQL Anda valid secara sintaksis dan mapper Anda tidak melempar error. Tes ini tidak membuktikan apa pun tentang persistensi.
Bahaya sebenarnya ada di celah antara “query berjalan” dan “data bersifat durable.” Apakah ORM Anda mem-flush write buffer sebelum mengembalikan hasil? Apakah connection pool Anda menggunakan kembali transaction yang sudah di-rollback? Apakah ON CONFLICT DO NOTHING menelan insert yang Anda harapkan berhasil?
Anda tidak akan menemukan bug-bug itu dengan mock yang mengembalikan { id: 1 } sesuai perintah.
Tes round-trip: tes jujur paling sederhana
Tes persistensi paling dasar juga yang paling jujur. Tulis data, baca kembali, assert bahwa data cocok.
import { createConnection } from './db';
it('persists and retrieves a user', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });
const found = await repo.findById('user-1');
expect(found).toEqual({
id: 'user-1',
name: 'Ada',
email: 'ada@example.com',
});
});
Ini menangkap kegagalan yang jelas: commit yang hilang, mapping kolom yang salah, bug serializer. Ini juga menangkap yang lebih halus. Jika metode create Anda melakukan hash pada email sebelum menulis tetapi findById tidak melakukan hash sebelum query, tes ini gagal. Tes yang di-mock tidak akan.
Menguji constraint di tempatnya berada
Constraint database adalah logika. Mereka layak mendapat tes sama seperti branch lain dalam kode Anda.
it('enforces unique emails at the database level', async () => {
const db = await createConnection();
const repo = new UserRepository(db);
await repo.create({ id: 'user-1', email: 'ada@example.com' });
await expect(
repo.create({ id: 'user-2', email: 'ada@example.com' })
).rejects.toThrow(/unique constraint/i);
});
Tes ini mendokumentasikan aturan bisnis di satu-satunya tempat yang benar-benar menegakkannya. Jika seseorang nanti menghapus unique index untuk “memperbaiki” migration yang flaky, tes ini akan berteriak. Mock dengan senang hati akan membiarkan dua pengguna berbagi email karena mock tidak memiliki konsep index.
Perilaku transaction bukanlah hal teoritis
Aplikasi Anda mungkin membungkus beberapa write dalam satu transaction. Tes untuk transaction itu harus membuktikan bahwa kegagalan parsial membuat database tetap konsisten.
it('rolls back the entire transfer on failure', async () => {
const db = await createConnection();
const accounts = new AccountRepository(db);
await accounts.create({ id: 'A', balance: 100 });
await accounts.create({ id: 'B', balance: 50 });
// Force a failure mid-transaction by violating a check constraint
const transfer = db.transaction(async (trx) => {
const acc = new AccountRepository(trx);
await acc.debit('A', 30); // succeeds
await acc.debit('B', 9999); // exceeds balance, throws
});
await expect(transfer).rejects.toThrow();
const a = await accounts.findById('A');
const b = await accounts.findById('B');
expect(a.balance).toBe(100); // rollback preserved original state
expect(b.balance).toBe(50);
});
Ini adalah tes yang menyelamatkan Anda dari page jam 3 pagi di mana akun kehilangan uang karena downstream API timeout dan debit ter-commit tetapi kredit tidak. Mock tidak bisa memodelkan isolasi transaction. Hanya database nyata yang bisa.
Isolasi: bagian yang sulit
Keberatan yang semua orang utarakan adalah kecepatan dan flakiness. Jika setiap tes mengenai PostgreSQL, suite Anda akan merangkak, dan tes-tes akan saling mengganggu.
Ada tiga jawaban praktis, dan Anda mungkin akan menggunakan ketiganya.
Template database. PostgreSQL dan kebanyakan database serius mendukung CREATE DATABASE ... TEMPLATE. Anda menginisialisasi satu database dengan skema Anda, lalu mengkloningnya per file tes dalam milidetik.
// test-setup.ts
import { execSync } from 'child_process';
let templateCreated = false;
export async function createIsolatedDatabase() {
if (!templateCreated) {
execSync('createdb test_template');
execSync('psql test_template -f schema.sql');
templateCreated = true;
}
const dbName = `test_${process.pid}_${Date.now()}`;
execSync(`createdb -T test_template ${dbName}`);
return connect(dbName);
}
Setiap file tes mendapat database baru. Tidak perlu logika cleanup. Di akhir run, hapus klon-klonnya.
Transaction sebagai batas isolasi. Jika template database terlalu berat, jalankan setiap tes di dalam transaction dan rollback di akhir. Kebanyakan database client mengekspos ini secara eksplisit.
afterEach(async () => {
await db.query('ROLLBACK');
});
it('inserts a row', async () => {
await db.query('BEGIN');
await repo.create({ id: 'x' });
expect(await repo.findById('x')).toBeDefined();
// rolled back by afterEach
});
Masalahnya adalah kode yang sendirinya menggunakan transaction mungkin bertabrakan dengan transaction rollback luar. Transaction bersarang melalui SAVEPOINT biasanya menyelesaikan ini, tetapi tidak setiap driver menanganinya dengan bersih.
Testcontainers untuk parity CI. Jika tes Anda berjalan melawan SQLite secara lokal dan PostgreSQL di produksi, Anda sedang menguji database yang berbeda. Gunakan Testcontainers untuk menjalankan database nyata di Docker untuk setiap run CI.
import { PostgreSqlContainer } from '@testcontainers/postgresql';
const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());
Ini menambah 2-3 detik ke run tes. Ini menghilangkan seluruh kelas bug yang hanya direproduksi di Postgres 15 dengan standard_conforming_strings = on.
Apa yang harus di-mock, dan apa yang tidak
Saya tidak mengadvokasi untuk nol mock. Mock tepat di batasan yang tidak Anda kendalikan: panggilan API Stripe, upload S3, server SMTP. Itu lambat, mahal, dan dibatasi rate-nya.
Database Anda bukan batasan eksternal dalam arti yang sama. Itu adalah infrastruktur yang Anda operasikan. Perilakunya deterministik. Skemanya adalah kode yang Anda tulis. Me-mock-nya berarti menguji terhadap versi fantasi dari sistem yang benar-benar Anda jalankan.
Trade-off-nya adalah kecepatan. Tes database nyata memakan 20-50ms. Tes yang di-mock memakan 2-5ms. Jika Anda memiliki sepuluh ribu tes persistensi, perbedaan itu penting. Kebanyakan tim tidak memiliki sepuluh ribu tes persistensi. Mereka memiliki seratus, dan kejujurannya sebanding dengan menunggunya.
Tes schema drift menangkap bug yang lolos dari code review
Ada satu tes lagi yang memalukan efektifnya dan hampir tidak ada yang menuliskannya. Setelah migration Anda berjalan, assert bahwa tipe TypeScript Anda masih cocok dengan skema.
import { sql } from './db';
it('types match the database schema', async () => {
const columns = await sql`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
`;
const userColumns = new Map(columns.map((c) => [c.column_name, c]));
// If someone adds a NOT NULL column without updating the type, this fails
expect(userColumns.get('email').is_nullable).toBe('NO');
expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});
Ini adalah tes yang menangkap migration yang menambahkan default NOT NULL ke kolom yang TypeScript Anda masih anggap opsional. Ini membosankan untuk dipelihara secara manual, itulah sebabnya alat seperti kysely-codegen atau drizzle-kit generate ada. Tetapi bahkan versi manual kecil pun akan menyelamatkan Anda dari incident produksi.
FAQ
Haruskah saya menghapus semua tes database yang di-mock?
Tidak. Pertahankan mock untuk tes logika murni di mana database tidak relevan, seperti menguji bahwa sebuah service memformat query string dengan benar. Ganti dengan tes database nyata di mana pun perilaku persistensi adalah intinya.
Bagaimana cara saya menangani migration dalam tes?
Jalankan migration terhadap template database sekali, lalu klon. Atau jalankan per test suite jika mereka cepat. Jangan pernah melewatkan migration di CI dan berharap skemanya cocok.
Bagaimana dengan read replica?
Jika aplikasi Anda menulis ke primary dan membaca dari replica, tes Anda juga harus begitu. Jalankan dua koneksi di Testcontainers dan konfigurasikan routing dengan cara yang sama seperti produksi. Stale reads yang disebabkan oleh lag adalah bug nyata.
Apakah ini berlaku untuk database NoSQL?
Ya. Prinsipnya sama. Client MongoDB yang di-mock tidak akan memberi tahu Anda apakah compound index Anda mendukung query yang Anda pikir didukung. Yang nyata akan.
Jalankan satu tes round-trip hari ini
Pilih metode repository yang paling sering Anda sentuh. Hapus mock-nya. Tulis tes yang membuat baris, membacanya kembali, dan mengassert bahwa field-fieldnya cocok. Ukur waktunya. Jika memakan waktu di bawah 50ms, tulis satu lagi.
Kebanyakan tim menemukan bahwa tes database nyata bukan alasan suite mereka lambat. Alasannya biasanya menjalankan headless browser untuk unit test, atau memanggil API pembayaran nyata di CI, atau tidak memparalelkan file tes. Tes database jujur, cukup cepat, dan mereka menangkap bug yang membuat Anda kehilangan tidur.