logo
Published on

Testcontainers入門 - Dockerを使ったDB統合テストの実践

Authors

この記事では人力に加え、AIも活用して執筆しています。

tl;dr

ビジネスロジック周りの単体テストではDBなど外部サービスをモックしてテストを行うことが多いのですが、そもそもDBの結合部の問題が起きていれば何の意味も持たないと思っています。

なのでテストの信頼性をあげるためにも、個人的にはDBとの結合部は可能な限りテストを回しておきたいと考えています。

ただ個人的に厄介なのが、テスト基盤の構築です。

これまでの職場でもDBとの結合部のテストを厚めに書くことも多かったのですが、テストを並列で動かした場合のテストの干渉の問題などもありなかなか苦戦することが多かったです。

そこで実際にDBとの結合部のテスト時の課題を解決するため、テスト実行時にDockerコンテナで独立したDB環境を起動できるTestcontainersを導入しました。

この記事では、Testcontainersの基本的な概念から、従来のテスト手法が抱える問題点、そしてTypeScript/NestJS + TypeORM環境での具体的な実装パターン、導入のメリットと注意点までをメモ書き程度ですがまとめます。

Testcontainersとは?

Testcontainersは、Dockerコンテナをプログラムコードから制御し、統合テストで利用するためのライブラリです。MySQL, PostgreSQL, Redisといったミドルウェアのコンテナをテストのライフサイクルに合わせて起動・破棄できます。

https://testcontainers.com/

これにより、テストごとにクリーンで独立した、本番に近い環境を用意することが可能になり、テストの信頼性を大幅に向上させることができます。

従来の統合テストにおける課題

Testcontainersの利点を理解するために、まずは従来のテスト手法が抱えていた課題を振り返ります。

1. モック・スタブによるテストの限界

単体テストで多用されるモックやスタブは、DBとの結合部分のテストには限界があります。

// モックを使ったリポジトリのテスト例
const mockRepository = {
  save: vi.fn().mockResolvedValue(mockData),
  find: vi.fn().mockResolvedValue([mockData])
};

この方法では、SQLの構文ミス、テーブルの制約違反(NOT NULL, UNIQUEなど)、複雑なクエリの挙動といった、データベース内部で発生する問題を検知できません。

モックせずにSQLiteを用いたりメモリベースで実装する方法もあるかと思いますが、DBの種類やバージョンが異なってしまうのでどこまで正確にテストできるのかという問題もあります。

2. 共有データベース利用時の問題点

開発チームで単一のテスト用データベースを共有するケースもありますが、これも問題を生みがちです。

  • テストの競合: 複数の開発者やCIジョブが同時にテストを実行すると、データが干渉し、予期せぬ失敗を引き起こします。並行して実行する場合、順序依存などが発生しやすくなる
  • 環境の汚染: テストの失敗によってゴミデータが残ると、以降のテストが失敗する原因となり、定期的なクリーンアップが必要になります。

3. 開発環境とCI環境の差異

「ローカルでは動くのにCIでは失敗する」という問題の多くは、環境の差異に起因します。DBのバージョンや細かな設定の違いが、テスト結果に影響を与えてしまいます。


Testcontainersを使ったテスト実装 (TypeScript/NestJS)

それでは、実際にTestcontainersを使ってリポジトリのテストを実装する方法を見ていきましょう。ここではTypeScript/NestJSとTypeORM、MySQLを例に解説します。

1. テストのセットアップ (beforeAll)

beforeAllフック内でMySQLコンテナを起動し、そのコンテナの接続情報を用いてDataSource(DB接続)を確立します。TestcontainersがDockerイメージの取得からコンテナ起動、ポートフォワーディングまでを自動で処理してくれます。

// my-repository.spec.ts
import { Test } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { MySqlContainer, type StartedMySqlContainer } from '@testcontainers/mysql';

// コンテナ起動に時間がかかる場合があるため、タイムアウトを延長
const TIMEOUT = 60000; // 60秒

describe('MyRepository with Testcontainers', () => {
  let repository: IMyRepository;
  let dataSource: DataSource;
  let container: StartedMySqlContainer;

  beforeAll(async () => {
    // 1. MySQLコンテナを起動
    container = await new MySqlContainer('mysql:8.0').start();

    // 2. コンテナの接続情報からDataSourceを生成
    dataSource = new DataSource({
      type: 'mysql',
      host: container.getHost(),
      port: container.getPort(),
      username: container.getUsername(),
      password: container.getUserPassword(),
      database: container.getDatabase(),
      entities: [MyEntity],
      synchronize: true, // テスト時にテーブルスキーマを自動生成
    });
    await dataSource.initialize();

    // 3. NestJSのDIコンテナを構築
    const module = await Test.createTestingModule({
      providers: [
        MyRepository,
        { provide: DataSource, useValue: dataSource },
      ],
    }).compile();

    repository = module.get<IMyRepository>(MyRepository);
  }, TIMEOUT);

  // ...
});

2. "本物"のDBに対するテストケース

セットアップが完了すれば、モックを使うことなく、実際のDBインスタンスに対してテストを実行できます。

// ...続き

it('正しくエンティティを保存し、取得できること', async () => {
  const entity = new MyEntity({ id: 'user-1', name: 'Taro Yamada' });

  // 実際のDBに対してINSERT文が実行される
  await repository.save(entity);

  // 実際のDBに対してSELECT文を実行して結果を検証
  const found = await repository.findById('user-1');

  expect(found).toBeDefined();
  expect(found.name).toBe('Taro Yamada');
});

it('DB制約違反の場合、正しくエラーがスローされること', async () => {
  // UNIQUE制約のあるカラムに重複した値をINSERTしようと試みる
  const entity1 = new MyEntity({ id: 'user-2', name: 'Jiro Suzuki', email: '[email protected]' });
  const entity2 = new MyEntity({ id: 'user-3', name: 'Saburo Tanaka', email: '[email protected]' });
  
  await repository.save(entity1);

  // 実際のDBが制約違反エラーを返すことを検証
  await expect(repository.save(entity2)).rejects.toThrow();
});

このように、SQLレベルでの動作やDB制約を正確にテストすることが可能です。

3. リソースのクリーンアップ (afterAll, afterEach)

テストの独立性を保つため、後片付けも重要です。

// ...続き

  // 全てのテストが完了したら、DB接続を閉じてコンテナを停止・破棄
  afterAll(async () => {
    await dataSource.destroy();
    await container.stop();
  });

  // 各テストケースの実行後にテーブルデータをクリア
  afterEach(async () => {
    const dbRepository = dataSource.getRepository(MyEntity);
    await dbRepository.clear();
  });

Testcontainers導入のメリット

  • 高い信頼性: 本番と同じ種類のデータベースエンジンに対してテストを実行するため、SQL構文やDB制約、トランザクションの挙動を正確に検証できます。
  • テストの独立性: テスト実行ごとにクリーンなコンテナが用意されるため、他のテストからの影響を完全に排除でき、並列実行も安全です。
  • 優れたポータビリティ: Dockerが動作する環境であれば、どの開発者のマシンでも、どのCIサーバーでも、全く同じテスト環境を再現できます。

実装時の注意点とベストプラクティス

タイムアウト設定

コンテナの初回プルや起動には数十秒かかることがあるため、Jestなどのテストランナーのタイムアウト設定を60000 (60秒) のように長めに設定することが推奨されます。

リソース管理

afterAllでコンテナのstop()メソッドを確実に呼び出し、不要なリソースが残らないようにします。

テスト戦略と実行方法

  • 単体テスト:

単体テストは疎結合で高速化つ頻繁に実行できるようにすべきものです。

そのため基本的にDBに依存しないビジネスロジックは、高速なモックベースのテストが適しています。

そもそも外部依存するテストは単体テストの範疇ではないので、基本的にモックすべきかと思います。

ただ、こと「DBの結合部の挙動テストしたい」というケースではモックでは意味のあるテストはできないと思っているので、私は単体テストとして扱っています。

さすがにローカルでも全部のテストを実行すると時間がかかるのでローカルで回す際にはDB結合のテストは除外したり、または確認したいもののみ手動で実行し、基本的にはCIでのみ実行するようにしています。

  • 統合テスト:

単体テストで書くと高速に実行できなくなるので、結合テスト部ではTestcontainersを利用して、実際のDBに対する統合テストを行う形も導入しやすいかと思います。

単体テストは実装すべき部分も多くカバーするのに時間がかかりますが、いわゆるテストピラミッド的には結合テストは単体テストよりも数も少なく、またより重要な部分を優先してテスト実装しやすいため、

導入初期段階などでは、Testcontainersを利用してDB結合部のテストを実装することは非常に有効なのではと思います。

CI/CD環境での考慮事項

  • GitHub Actionsでも利用が可能で、Dockerが動作する環境であれば問題なく動作します。
  • CI環境では、コンテナの起動時間が長くなる可能性があるため、タイムアウト設定をに注意が必要です。

まとめ

Testcontainersを導入することで、これまで多くの開発者を悩ませてきた統合テストの課題を解決し、より堅牢で信頼性の高いアプリケーション開発が可能になります。

セットアップには多少の学習コストが必要ですが、それに見合うだけの品質向上と将来的な手戻りの削減が期待できます。データベースとの連携が重要なプロジェクトでは、導入を検討する価値が非常に高い技術と言えるでしょう。

参考サイト