テストでデータベースをモック化していると、リポジトリ層が正しいメソッドを呼び出しているかどうかをテストしているに過ぎない。クラッシュからデータが復元されるか、ユニーク制約が実際に重複をブロックするか、何かが失敗したときにトランザクションがロールバックされるかどうかはテストできていない。

この違いは重要だ。モック化された save() は、指示したものを何でも返す。本物の save() はデッドロックしたり、文字列を静かに切り詰めたり、プライマリから3秒遅れのレプリカに書き込んだりする可能性がある。テストスイートはグリーンでも、ユーザーのデータは失われている。

なぜモック化されたデータベースのテストは偽の安心感を与えるのか

多くの永続性テストはこう書かれている:

// 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が構文的に有効で、マッパーが例外を投げないことを証明するだけだ。永続性については何も証明しない。

本当の危険は、「クエリが実行された」と「データが耐久性を持つ」の間の隙間にある。ORMは返す前に書き込みバッファをフラッシュしているか?コネクションプールは既にロールバックされたトランザクションを再利用していないか?ON CONFLICT DO NOTHING は、成功すると期待していた挿入を飲み込んでいないか?

そういったバグは、命令通りに { id: 1 } を返すモックでは絶対に見つけられない。

ラウンドトリップテスト:最もシンプルで正直なテスト

最も基本的な永続性テストは、同時に最も正直なテストでもある。データを書き込み、読み戻し、一致することを検証する。

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

これは明らかな失敗を捕捉する:コミットの欠落、誤ったカラムマッピング、シリアライザのバグ。微妙なものも捕捉する。create メソッドが書き込み前にメールアドレスをハッシュ化するのに対し、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);
});

このテストは、実際にそのルールを強制する唯一の場所で、ビジネスルールを文書化する。後で誰かが不安定なマイグレーションを「修正」するためにユニークインデックスを削除した場合、このテストは叫ぶ。モックは喜んで2人のユーザーに同じメールアドレスを共有させるだろう。なぜなら、モックにはインデックスの概念がないからだ。

トランザクションの振る舞いは理論ではない

アプリケーションではおそらく、複数の書き込みをトランザクションでラップしている。そのトランザクションのテストは、部分的な失敗がデータベースを整合性のある状態に保つことを証明すべきだ。

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がタイムアウトし、デビットはコミットされたがクレジットはコミットされなかったために口座からお金が消えている、深夜3時のページャーからあなたを救うテストだ。モックはトランザクション分離をモデル化できない。本物のデータベースにしかできない。

分離:難しい部分

誰もが挙げる異論は速度と不安定性だ。すべてのテストがPostgreSQLにアクセスすれば、スイートは這うように遅くなり、テスト同士が干渉し合う。

実用的な答えが3つあり、おそらく3つすべてを使うことになるだろう。

テンプレートデータベース。 PostgreSQLとほとんどの本格的なデータベースは CREATE DATABASE ... TEMPLATE をサポートしている。スキーマで1つのデータベースを初期化し、ミリ秒単位でテストファイルごとにクローンする。

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

各テストファイルは新鮮なデータベースを得る。クリーンアップロジックは不要だ。実行終了時にクローンを削除する。

分離境界としてのトランザクション。 テンプレートデータベースが重すぎる場合は、各テストをトランザクション内で実行し、最後にロールバックする。ほとんどのデータベースクライアントはこれを明示的に提供している。

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

落とし穴は、それ自体がトランザクションを使用するコードが、外側のロールバックトランザクションと衝突する可能性があることだ。SAVEPOINT によるネストされたトランザクションは通常これを解決するが、すべてのドライバーがきれいに処理するわけではない。

CIの等価性のためのTestcontainers。 ローカルではSQLiteに対してテストを実行し、本番ではPostgreSQLに対して実行している場合、異なるデータベースをテストしていることになる。Testcontainersを使用して、すべてのCI実行でDocker内に本物のデータベースを立ち上げる。

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 のときにのみ再現するバグの entire class を排除する。

モックすべきものとそうでないもの

私はモックをゼロにしろと主張しているわけではない。モックは、コントロールできない境界で正しい:Stripe APIの呼び出し、S3へのアップロード、SMTPサーバー。これらは遅く、高価で、レート制限されている。

あなたのデータベースは同じ意味では外部境界ではない。それはあなたが運用するインフラストラクチャだ。その振る舞いは決定論的だ。そのスキーマはあなたが書いたコードだ。それをモック化することは、実際に実行しているシステムの空想バージョンに対してテストすることを意味する。

トレードオフは速度だ。本物のデータベースのテストは20〜50ミリ秒かかる。モック化されたものは2〜5ミリ秒だ。1万件の永続性テストがあれば、その差は重要だ。ほとんどのチームは1万件の永続性テストを持っていない。100件程度だろう。そしてその正直さは待つに値する。

スキーマドリフトテストはコードレビューが見逃すバグを捉える

あまりにも効果的で、ほとんど誰も書かないテストがもう1つある。マイグレーション実行後、TypeScriptの型がスキーマとまだ一致していることを検証する。

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

これは、TypeScriptがまだオプションだと思っているカラムにマイグレーションが NOT NULL のデフォルトを追加するのを捕捉するテストだ。手動で保守するのは面倒だ。それが kysely-codegendrizzle-kit generate のようなツールが存在する理由だ。しかし、小さな手動版でさえ、本番インシデントを防いでくれる。

FAQ

モック化されたデータベースのテストをすべて削除すべきか?

いいえ。データベースが関係ない純粋なロジックテスト、たとえばサービスがクエリ文字列を正しくフォーマットするかどうかのテストでは、モックを残しておけ。永続性の振る舞いが重要な場所ではどこでも、本物のデータベースのテストに置き換えよ。

テストでのマイグレーションはどう扱うべきか?

テンプレートデータベースに対してマイグレーションを一度実行し、クローンする。速ければテストスイートごとに実行してもよい。CIでマイグレーションをスキップしてスキーマが一致することを願ってはならない。

リードレプリカはどうか?

アプリケーションがプライマリに書き込み、レプリカから読み込む場合、テストもそうすべきだ。Testcontainersで2つの接続を立ち上げ、本番と同じようにルーティングを設定する。遅延による古い読み込みは本物のバグだ。

これはNoSQLデータベースにも当てはまるか?

はい。原則は同じだ。モック化されたMongoDBクライアントは、複合インデックスがあなたが考えているクエリをサポートしているかどうかを教えてくれない。本物のものなら教えてくれる。

今日1つ、ラウンドトリップテストを書こう

最も頻繁に触るリポジトリメソッドを選べ。そのモックを削除し、行を作成して読み戻し、フィールドが一致することを検証するテストを書け。時間を計れ。50ミリ秒を下回れば、もう1つ書け。

ほとんどのチームは、本物のデータベースのテストがスイートが遅い理由ではないことを発見する。理由は通常、ユニットテストのためにヘッドレスブラウザを立ち上げたり、CIで本物の決済APIを呼び出したり、テストファイルを並列化していないことだ。データベースのテストは正直で、十分速く、あなたの睡眠を奪うバグを捕捉してくれる。