💽

TestcontainersでRDBを使ったテストを快適にする仕組み

に公開

なぜSQLのテストが必要なのか

ユニットテストでDBアクセスをモックするのは一般的なプラクティスです。テストの高速化に有効ですが、SQLロジック自体はテストされないという課題があります。

複雑なJOINや集計処理、サブクエリを含むSQLは、意図通りに動作しているか実際にRDBで実行してみないとわかりません。また、パフォーマンスチューニングのためにSQLを書き換えたとき、テストがなければ「最適化によって挙動が壊れていないか」を確認するのが困難です。

「シンプルなSQLだけ書いて、複雑な処理はアプリケーション側で行えばよいのでは」という考え方もあるでしょう。しかし、大量のデータをアプリケーション側に持ってきて処理すると、データ転送のオーバーヘッドやインデックスの恩恵を受けられないなどの問題があり、パフォーマンス劣化を起こしがちです。

結局、パフォーマンスを保ちつつ複雑なロジックをテストするには、実際のRDBを使ったテストが必要になります。

SQLテストのハードル

しかし、SQLテストを書くには多くのハードルがあります。

  • 環境構築が面倒: ローカルでRDBをセットアップし、マイグレーションを実行し、テスト用DBを用意する
  • テストデータの準備が大変: リレーションを持った複雑な大量のテストデータを手動でINSERTする必要がある
  • テスト間の干渉: 前のテストのデータが残っていると、次のテストが失敗する
  • CIでの実行が難しい: ローカルと同じDB環境をCIで再現するのが大変

この記事では、私たちのプロジェクトで実践している「SQLテストのハードルを下げるための工夫」を紹介します。

どんなテストコードになるのか

まず、私達のプロジェクトではどのようなSQLテストを書いているのか、疑似コードで紹介します。

ブログ記事を公開するpublishPost関数のテストです。

// apps/…/publish-post.test.ts
import { database } from '@myapp/database';
import { databaseFactory } from '@myapp/database-testing';
import { Result } from '@myapp/result';
import { beforeEach, describe, expect, test } from 'vitest';

import { publishPost } from './publish-post';

describe('publishPost', () => {
  const getMaybeAffectedResources = async () => {
    const posts = await database().query.posts.findMany();
    return { posts };
  };

  const execute = async (...parameters: Parameters<typeof publishPost>) => {
    const before = await getMaybeAffectedResources();
    const returns = await publishPost(...parameters);
    const after = await getMaybeAffectedResources();
    return { returns, before, after };
  };

  describe('下書きの記事が存在する場合', () => {
    const postId = 1;
    const userId = 100;

    beforeEach(async () => {
      await databaseFactory.posts.create({
        id: postId,
        userId,
        title: 'テスト記事',
        content: '記事の本文',
        status: 'draft',
      });
    });

    test('エラーなく成功します', async () => {
      const { returns } = await execute({ postId });

      expect(Result.isSuccess(returns)).toBe(true);
    });

    test('記事のステータスがpublishedになります', async () => {
      const { before, after } = await execute({ postId });

      expect(before.posts[0]?.status).toBe('draft');
      expect(after.posts[0]?.status).toBe('published');
    });

    test('publishedAtが設定されます', async () => {
      const { before, after } = await execute({ postId });

      expect(before.posts[0]?.publishedAt).toBe(null);
      expect(after.posts[0]?.publishedAt).toBeInstanceOf(Date);
    });});
});

テストコードを読み進める中で、「DBの起動やモックを行う処理が見当たらない」と疑問に思った方もいるでしょう。実は、これらの処理はすべてVitestのsetupFilesやglobalSetupにまとめて記述しており、テストコード側では意識しなくてよい構成になっています。

この仕組みにより、各テストファイルで個別にモック処理を記述する必要がなくなり、テストコードは「何をテストするか」という本質的な部分に集中できます。環境構築に関するコードを極力テストの外に追い出すことで、テスト自体がシンプルになり、可読性と保守性が向上します。

以降の章では、DBのセットアップとクリーンアップ・テストデータの生成などの処理をどのように実装しているのか、具体的なコード例を交えながら詳しく解説します。

どのような仕組みで実現しているのか

主要なコンポーネントは以下の通りです。

  • Testcontainers: Dockerコンテナーを使ったテスト環境を提供するライブラリ(今回はMySQLコンテナーを利用)
  • Drizzle ORM: 型安全なクエリビルダー
  • @praha/drizzle-factory: テストデータを簡単に作成するためのプラハ開発のOSS
  • Vitest: テストランナー。グローバルセットアップ機能でTestcontainersの起動・破棄を管理

私たちのプロジェクトではモノレポ構成を採用しています。これらテスト関連の処理は共通パッケージである@myapp/database-testingにまとめ、各アプリケーションから利用できるようにしています。

Vitestの設定

このテストコードを書くアプリケーションでは、テストランナー(今回はVitest)を以下のように設定します。

// vitest.config.ts
export default defineConfig({
  test: {
    globalSetup: 'vitest.global.ts',
    setupFiles: './vitest.setup.ts',
    fileParallelism: false,  // DB競合を避けるため並列実行を無効化
  },
});
// vitest.global.ts
import { useDatabaseTesting } from '@myapp/database-testing/global';

const { setupDatabase, teardownDatabase } = useDatabaseTesting();

export const setup = async (project: TestProject) => {
  await setupDatabase(project);
};

export const teardown = async () => {
  await teardownDatabase();
};
// vitest.setup.ts
import { setupDatabaseTesting } from '@myapp/database-testing';

setupDatabaseTesting();

DB接続のモックとテスト間のデータクリーンアップ

setupDatabaseTesting()は、以下の機能を提供します。

  • vi.mock(): プロダクション用のdatabase()関数をTestcontainers接続に差し替え
  • beforeAll(): inject()で接続情報を取得し、DB接続を確立
  • afterEach(): 全テーブルをTRUNCATEしてデータをクリーンアップ

VitestのsetupFilesで実行することで、各テストファイルで個別にモックやクリーンアップ処理を記述する必要がなくなります。

// packages/database-testing/src/index.ts
import { createConnection } from 'mysql2/promise';
import { afterAll, afterEach, beforeAll, inject, vi } from 'vitest';

import type { ConnectionOptions, Connection, RowDataPacket } from 'mysql2/promise';

// Vitestの型定義を拡張して、inject()を型安全に使えるようにする
declare module 'vitest' {
  export interface ProvidedContext {
    databaseConnectionOptions: ConnectionOptions;
  }
}

export const setupDatabaseTesting = () => {
  let connection: Connection | undefined = vi.hoisted(() => undefined);

  // プロダクション用のdatabase関数をテスト用に差し替え
  vi.mock('@myapp/database', async () => {
    const mockDatabase = () => createDatabase(connection!);

    return {
      ...await vi.importActual('@myapp/database'),
      database: mockDatabase,
    };
  });

  beforeAll(async () => {
    // provide()で保存した接続情報(後述)を型安全に取得
    const connectionOptions = inject('databaseConnectionOptions');
    connection = await createConnection(connectionOptions);

    // TRUNCATEで使用するINFORMATION_SCHEMA.TABLE_ROWSがキャッシュされないように設定
    await connection.query('SET information_schema_stats_expiry = 0');
  });

  afterEach(async () => {
    // データが存在するテーブルのみを検索(パフォーマンス最適化)
    const [tables] = await connection!.execute<RowDataPacket[]>(`
      SELECT INFORMATION_SCHEMA.TABLES.TABLE_NAME
      FROM INFORMATION_SCHEMA.TABLES
      WHERE INFORMATION_SCHEMA.TABLES.TABLE_SCHEMA = DATABASE()
      AND 0 < INFORMATION_SCHEMA.TABLES.TABLE_ROWS
    `);

    // 外部キー制約を一時的に無効化してTRUNCATE
    await connection!.query('SET foreign_key_checks = 0');
    for (const { TABLE_NAME } of tables) {
      await connection!.query(`TRUNCATE TABLE \`${TABLE_NAME}\``);
    }
    await connection!.query('SET foreign_key_checks = 1');
  });

  afterAll(async () => {
    await connection!.end();
  });
};

DB環境の起動と破棄

useDatabaseTesting()は2つの関数を提供します。

  • setupDatabase(): MySQLコンテナーの起動、マイグレーション実行、接続情報のprovide()
  • teardownDatabase(): コンテナーの停止

VitestのglobalSetupでこれらを呼び出すことで、テスト実行前後にDB環境のセットアップとクリーンアップが自動的に行われます。

// packages/database-testing/src/global/index.ts
import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql';
import { createConnection, type ConnectionOptions } from 'mysql2/promise';
import type { TestProject } from 'vitest/node';

export const useDatabaseTesting = () => {
  let container: StartedMySqlContainer;

  const setupDatabase = async ({ provide }: TestProject) => {
    // MySQL 8.0コンテナーを起動
    container = await new MySqlContainer('mysql:8.0').start();

    const connectionOptions: ConnectionOptions = {
      host: container.getHost(),
      port: container.getMappedPort(3306),
      database: container.getDatabase(),
      user: container.getUsername(),
      password: container.getUserPassword(),
    };

    // テストランナーのコンテキストに接続情報を保存
    provide('databaseConnectionOptions', connectionOptions);

    // マイグレーション実行
    const connection = await createConnection(connectionOptions);
    // NOTE: getMigrationStatements()は独自のマイグレーション取得関数です。あなたのプロジェクトのマイグレーションファイルを読み込む処理に置き換えてください。
    for (const statement of await getMigrationStatements()) {
      await connection.query(statement);
    }
    await connection.end();
  };

  const teardownDatabase = async () => {
    await container.stop();
  };

  return { setupDatabase, teardownDatabase };
};

テストデータのファクトリー

databaseFactoryは、テストデータを生成・挿入するFactory群のエントリーポイントです。以下のような特長があります。

  • テストで検証したい値だけ指定すればよく、テストコードがシンプルになる
  • テーブルのカラムが増えてもデフォルト値を更新するだけで済み、メンテナンスが楽
  • リレーション先のレコードが自動生成されるため、外部キーの管理が不要

まず、composeFactory()を使って、すべてのテーブルFactoryを1つのエントリーポイントにまとめます。

// packages/database-testing/src/index.ts
import { composeFactory } from '@praha/drizzle-factory';
import { usersFactory } from './factories/users';
import { postsFactory } from './factories/posts';
import { commentsFactory } from './factories/comments';
// ... その他のFactory

export const databaseFactory = composeFactory({
  users: usersFactory,
  posts: postsFactory,
  comments: commentsFactory,
  // ... その他のFactory
})(database);

各テーブルに対応するFactoryはdefineFactory()で定義されています。databaseFactoryは、テストに必要なデータだけ指定して残りはデフォルト値で埋める仕組みです。Drizzle ORMのスキーマから型が推論されるため、型安全です。

// packages/database-testing/src/factories/users.ts
import { database } from '@myapp/database';
import { defineFactory } from '@praha/drizzle-factory';

export const usersFactory = defineFactory({
  schema: database.schema,
  table: 'users',
  resolver: ({ sequence }) => {
    const now = new Date();

    return {
      id: sequence,
      name: `User ${sequence}`,
      email: `user${sequence}@example.com`,
      createdAt: now,
      updatedAt: now,
    };
  },
});

また、use()APIを使うことで外部キー制約を自動的に満たすことができます。

// packages/database-testing/src/factories/posts.ts
import { database } from '@myapp/database';
import { defineFactory } from '@praha/drizzle-factory';

export const postsFactory = defineFactory({
  schema: database.schema,
  table: 'posts',
  resolver: ({ sequence, use }) => {
    const now = new Date();

    return {
      id: sequence,
      // usersFactoryを使ってuserを作成し、そのIDを使用
      // テストでuserIdを指定しなければ、自動的にuserレコードが作成される
      userId: () => use(usersFactory).create()
        .then((user) => user.id),
      title: `Post ${sequence}`,
      content: `Content for post ${sequence}`,
      status: 'draft',
      publishedAt: null,
      createdAt: now,
      updatedAt: now,
    };
  },
});
// packages/database-testing/src/factories/comments.ts
export const commentsFactory = defineFactory({
  schema: database.schema,
  table: 'comments',
  resolver: ({ sequence, use }) => {
    const now = new Date();

    return {
      id: sequence,
      content: `Comment ${sequence}`,
      // テストでpostIdを指定しなければ、自動的にpostレコードが作成される
      // さらにpostFactory内でuserFactoryも使われるため、userレコードも自動的に作成される
      postId: () => use(postsFactory).create()
        .then((post) => post.id),
      createdAt: now,
      updatedAt: now,
    };
  },
});

これにより、以下のようなことが可能になります。

// commentsだけ作成すれば、postとuserも自動的に作成される
await databaseFactory.comments.create({
  content: 'Great article!',
});

// もちろん、postIdを明示的に指定することもできる
await databaseFactory.comments.create({
  content: 'Another comment',
  postId: 1,
});

@praha/drizzle-factoryは他にもtraitsを使ったバリエーションの作成など、便利な機能を提供しています。くわしくはDrizzle ORMでテストデータの生成を簡単にするをご覧ください。

SQLテストのハードルを下げ、チームの生産性を向上させる

この記事では、SQLテストのハードルを下げるために実践している工夫を紹介しました。

  • TestcontainersでRDB環境をセットアップすることで、ローカルやCIで一貫したDB環境を提供する
  • Vitestの機能を活用してDB接続のモックやテスト間のデータクリーンアップをすることで、テストコードをシンプルに保つ
  • @praha/drizzle-factoryを使ってテストデータ生成の簡略化することで、テストコードを書きやすさ・保守性を向上させる

SQLのテストを書くのは、技術的に正しいと分かっていても手間がかかって敬遠されがちです。ただ実現可能にするだけでなく、パッケージが提供するモジュールやそのAPIをこだわり抜いて設計し開発者体験を高める工夫を取り入れることで、チーム全体の生産性や安心感も大きく変わります。

GitHubで編集を提案
PrAha

Discussion