Testcontainers入門:インテグレーションテストでH2 Databaseを使うのはもうやめよう

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

よくある光景:ローカルでは通るのにCIでは「真っ赤」になるテスト

インテグレーションテストを書いたことがある人なら、一度はこんな経験があるはずです。H2 Databaseを使用したローカル環境では100%パスするのに、本番環境と同じPostgreSQLにデプロイした途端、SQL構文エラーが発生する。あるいは、チームで共有のデータベースサーバーを使っているために、テストデータが干渉し合い、結果がめちゃくちゃになってしまう…。

6ヶ月以上、大規模プロジェクトでTestcontainersを導入してきた経験から、これが環境を統一する最善の方法だと確信しています。手動でのインストール作業は不要で、Testcontainersが必要なバージョンのDockerコンテナを自動的に起動してくれます。テストが終われば自動でクリーンアップされるため、不安定なテスト(Flaky tests)の発生率をほぼ0%に抑えることができました。

なぜDocker ComposeよりもTestcontainersの方が「価値がある」のか?

Docker Composeで十分ではないか?」思う方もいるでしょう。その答えは「動的な制御能力」にあります。Testcontainersを使えば、JavaやGoのコード内でコンテナのライフサイクルを直接管理できます。

最大のメリットは、ランダムなポートマッピング機構です。もしローカルでPostgresが5432ポートで動いていても、Testcontainersは空いている別のポート(例:32768)を自動で見つけて新しいコンテナを起動します。これにより、ポートの競合を完全に回避できます。また、Ryukという「戦場掃除屋」のサイドカーコンテナが付属しており、テストプロセスが突然クラッシュしても、不要なコンテナを確実に破棄してリソースを解放してくれます。

Docker APIの設定中に長いJSONレスポンスを扱う際は、JSON Formatterを使って構成を素早く確認するのがおすすめです。生のコードを一行ずつ追うよりも、大幅に時間を短縮できます。

実践:PostgreSQLとRedisの統合

ここではSpring Bootプロジェクトでのセットアップ例を紹介します。他の言語でもロジックは同様です。

1. 必要なライブラリの追加

pom.xmlに依存関係を定義します。最適化された関数を利用するために、使用するデータベースの種類に応じた適切なモジュールを選択してください。

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.7</version>
    <scope>test</scope>
</dependency>

2. 動的なコンテナ構成

URLをハードコードしてはいけません。コンテナが正常に起動した後、Testcontainersに接続情報を返させます。プロジェクト全体で共有できるように、BaseIntegrationTestクラスを作成するのが一般的です。

@Testcontainers
public abstract class BaseIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
            .withDatabaseName("test_db")
            .withUsername("user")
            .withPassword("pass");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
            .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }
}

@DynamicPropertySourceの仕組みが鍵となります。これにより、Dockerが割り当てた正確なポートを、ランタイム時にSpringアプリケーションへ注入できます。

3. テストケースの作成

この時点でのテスト作成は、本物のデータベースを操作するのと変わりません。ここでのテストがパスすれば、本番環境でも正しく動作すると確信を持てます。

class UserServiceTest extends BaseIntegrationTest {
    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUserSuccessfully() {
        User user = new User("admin", "[email protected]");
        userRepository.save(user);
        
        assertNotNull(userRepository.findByUsername("admin"));
    }
}

ビルドを遅延させないための3つの最適化手法

Testcontainersは非常に便利ですが、注意しないとCIのビルド時間が急増します。私が実践している最適化方法は以下の通りです。

  • Singleton Containerの使用: テストクラスごとに新しいコンテナを起動しないでください。テストスイート全体で単一のスタティックなコンテナを使い回しましょう。あるプロジェクトでは、これによりビルド時間を15分から6分に短縮できました。
  • Alpineイメージを優先: フル版ではなく、postgres:15-alpineのような軽量版を常に使用してください。イメージサイズが小さいほどCIサーバーでのプルが速くなり、帯域幅とディスク容量を節約できます。
  • Dockerへの十分なリソース割り当て: Postgres、Redis、Kafkaを同時に動かす場合は、Docker Desktopに少なくとも4GBのRAMを割り当ててください。メモリ不足は、コンテナの起動遅延やクラッシュの主な原因になります。

Lời kết

Testcontainersは単なるツールではなく、テスト環境を完全に制御するという考え方そのものです。環境の差異に悩む時間を費やす代わりに、コードで環境を定義しましょう。Jenkinsでの動的ポート設定や接続で困っていることがあれば、ぜひコメント欄で質問してください。一緒に解決しましょう。

Share: