如果你在測試中使用模擬資料庫,你其實只是在驗證 repository 層是否呼叫了正確的方法。你並沒有測試資料能否在當機後存活、唯一約束是否真的阻止重複資料,或者transaction 失敗時是否會回滾。

這個差別很重要。模擬的 save() 只會回傳你預設的結果。真正的 save() 可能會deadlock、默默截斷字串,或者寫入一個比主庫慢三秒的 replica。你的測試套件全綠,但使用者正在遺失資料。

為什麼模擬資料庫測試會給你虛假的信心

大多數持久化測試長這樣:

// 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);
});

這個測試證明你的 SQL 語法正確,而且 mapper 不會拋出例外。但它完全無法證明任何關於持久化的事。

真正的風險藏在「查詢執行了」和「資料真的持久化了」之間的鴻溝。你的 ORM 會在回傳前 flush 寫入 buffer嗎?連線池會不會重複使用一個已經rollback 的 transaction?ON CONFLICT DO NOTHING 會不會吞掉你預期會成功的 insert?

你不可能用一個隨傳隨到回傳 { id: 1 } 的模擬物件找出這些 bug。

往返測試:最簡單也誠實的測試

最基礎的持久化測試,同時也是最誠實的。寫入資料、讀回來、驗證是否一致。

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',
  });
});

這能抓出明顯的錯誤:漏掉的 commit、錯誤的欄位對應、序列化 bug。它也能抓出隱晦的問題。如果你的 create 方法在寫入前對 email 做雜湊,但 findById 查詢前沒有,這個測試就會失敗。模擬測試則不會。

在約束實際運作的地方測試它

資料庫約束就是邏輯。它們跟其他任何程式分支一樣,值得被測試。

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);
});

這個測試在唯一真正執行該規則的地方記錄了業務邏輯。如果後來有人為了「修復」不穩定的 migration 而移除唯一索引,這個測試會立刻大叫。模擬物件會開心地讓兩個使用者共用同一個 email,因為它根本不知道索引是什麼。

transaction不是理論問題

你的應用程式很可能把多筆寫入包在一個 transaction裡。測試這個 transaction時,應該要證明部分失敗會讓資料庫維持一致性。

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);
});

這個測試能讓你免於凌晨三點被叫起來,只因為下游 API 超時,導致扣款已經 commit 但入帳沒有,帳戶少錢。模擬物件無法模擬transaction 隔離。只有真正的資料庫可以。

隔離:最困難的部分

每個人都會提出的反對理由是速度和 flaky。如果每個測試都連 PostgreSQL,你的測試套件會慢到爬行,而且測試之間會互相干擾。

有三個實際可行的解法,而且你很可能三個都會用到。

Template databases。 PostgreSQL 和大多數正經的資料庫都支援 CREATE DATABASE ... TEMPLATE。你先用 schema 初始化一個資料庫,然後在每個測試檔案毫秒級地複製它。

// 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);
}

每個測試檔案拿到一個全新的資料庫。不需要清理邏輯。跑完後把複製品 drop 掉就好。

以 transaction 作為隔離邊界。 如果 template databases 太沉重,那就讓每個測試跑在一個 transaction裡,結束時回滾。大多數資料庫客戶端都明確提供這個功能。

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
});

問題在於,如果程式碼本身也使用 transaction,可能會跟外層的rollback transaction衝突。透過 SAVEPOINT 的巢狀 transaction通常能解決,但不是每個 driver 都處理得乾淨。

Testcontainers 確保 CI 一致性。 如果你在本地用 SQLite 跑測試,線上用 PostgreSQL,那你等於在測試不同的資料庫。用 Testcontainers 在 Docker 裡為每次 CI 啟動真正的資料庫。

import { PostgreSqlContainer } from '@testcontainers/postgresql';

const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());

這會讓測試多花 2-3 秒。但它能消滅整類只在 Postgres 15 且 standard_conforming_strings = on 時才會出現的 bug。

該模擬什麼,又不該模擬什麼

我並不是主張完全不要用 mock。在你無法控制的邊界上使用 mock 是對的:Stripe API 呼叫、S3 上傳、SMTP 伺服器。這些都很慢、很貴,而且有 rate limit。

你的資料庫在這個意義上並不是外部邊界。它是你運維的基礎設施。它的行為是確定性的。它的 schema 是你寫的程式碼。模擬它,等於用一個幻想版本來測試你實際運行的系統。

權衡在於速度。真實資料庫測試需要 20-50ms,模擬測試只要 2-5ms。如果你有一萬個持久化測試,這個差距就很重要。但大多數團隊沒有一萬個持久化測試。他們大概只有一百個,而誠實值得這點等待。

Schema drift 測試能抓出 code review 漏掉的 bug

還有一種測試效果好到讓人慚愧,但幾乎沒人寫。在 migration 跑完後,驗證你的 TypeScript 型別是否仍然符合 schema。

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');
});

這個測試能抓出 migration 把某個欄位改成 NOT NULL,但你的 TypeScript 還以為它是 optional 的情況。手動維護很繁瑣,所以才有 kysely-codegendrizzle-kit generate 這類工具存在。但即使是小手動版本,也能幫你避免一次 production 事故。

常見問題

我該刪掉所有模擬資料庫測試嗎?

不。在純邏輯測試中保留 mock,例如驗證某個 service 是否正確格式化查詢字串,這種測試跟資料庫無關。但在任何以持久化行為為重點的地方,換成真實資料庫測試。

我該怎麼在測試中處理 migration?

對 template database 跑一次 migration,然後複製。或者如果 migration 很快,也可以每個 test suite 跑一次。絕對不要在 CI 中跳過 migration,然後祈禱 schema 剛好一致。

那 read replica 呢?

如果你的應用程式寫入主庫、從 replica 讀取,測試也應該這樣做。在 Testcontainers 裡啟動兩個連線,並且用跟 production 一樣的方式配置路由。Lag 造成的過期讀取是真實的 bug。

這也適用於 NoSQL 資料庫嗎?

是的。原則相同。模擬的 MongoDB client 不會告訴你,你的 compound index 是否真的支援你以為能支援的查詢。真正的 client 會。

今天就寫一個往返測試

選出你最常改動的 repository 方法。刪掉它的 mock。寫一個測試:建立一筆資料、讀回來、驗證欄位是否一致。計時。如果不到 50ms,再寫一個。

大多數團隊會發現,真實資料庫測試並不是測試套件變慢的原因。真正的原因通常是為了單元測試啟動 headless browser、在 CI 中呼叫真實的付款 API,或是沒有平行化測試檔案。資料庫測試誠實、夠快,而且能抓出讓你睡不著覺的 bug。