概要

H2 などのインメモリデータベースでテストを書いていると、「本番は PostgreSQL なのにテストでは H2 で通ってしまう」「PostgreSQL 固有の関数を使うとテストできない」という場面に遭遇します。Testcontainers は Docker コンテナをテストのライフサイクルに合わせて自動的に起動・破棄するライブラリで、本番と同じデータベースエンジンでテストを実行できます。この記事では、PostgreSQLContainer を使って UserRepository の CRUD を検証する結合テストを題材に、@Testcontainers と @Container の使い方、テーブル初期化のパターン、テストごとのデータクリーンアップを整理します。Docker が動く環境であれば特別な設定なしに使えるため、CI/CD パイプラインにも組み込みやすいのが大きな利点です。

使いどころ

PostgreSQL の JSONB カラムを使ったリポジトリクラスのテストを、本番と同じエンジンで検証する

Flyway で作成したスキーマの上で実データを挿入してクエリ結果をテストする

CI/CD パイプラインで外部 DB サーバーを用意せずに結合テストを行う

ユニーク制約やインデックス、トランザクション境界など本番 DB 固有の挙動を確認する

マイグレーション適用後の初期データや参照整合性を自動テストで継続確認する

コード例

UserRepositoryIntegrationTest.java
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;

@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb").withUsername("testuser").withPassword("testpass");

    record User(String id, String name, String email) {}

    static class UserRepository {
        private final String url, user, pass;
        UserRepository(String url, String user, String pass) {
            this.url = url; this.user = user; this.pass = pass;
        }
        Connection conn() throws SQLException { return DriverManager.getConnection(url, user, pass); }

        void save(User u) throws SQLException {
            try (var c = conn(); var ps = c.prepareStatement(
                "INSERT INTO users(id,name,email) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name,email=EXCLUDED.email")) {
                ps.setString(1, u.id()); ps.setString(2, u.name()); ps.setString(3, u.email()); ps.executeUpdate();
            }
        }
        Optional<User> findById(String id) throws SQLException {
            try (var c = conn(); var ps = c.prepareStatement("SELECT id,name,email FROM users WHERE id=?")) {
                ps.setString(1, id);
                try (var rs = ps.executeQuery()) {
                    return rs.next() ? Optional.of(new User(rs.getString(1), rs.getString(2), rs.getString(3))) : Optional.empty();
                }
            }
        }
        List<User> findAll() throws SQLException {
            var list = new ArrayList<User>();
            try (var c = conn(); var st = c.createStatement(); var rs = st.executeQuery("SELECT id,name,email FROM users ORDER BY id")) {
                while (rs.next()) list.add(new User(rs.getString(1), rs.getString(2), rs.getString(3)));
            }
            return list;
        }
    }

    static UserRepository repo;

    @BeforeAll static void initSchema() throws SQLException {
        repo = new UserRepository(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
        try (var c = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
             var st = c.createStatement()) {
            st.execute("CREATE TABLE IF NOT EXISTS users(id VARCHAR(50) PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(200))");
        }
    }

    @BeforeEach void clean() throws SQLException {
        try (var c = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
             var st = c.createStatement()) { st.execute("TRUNCATE TABLE users"); }
    }

    @Test @DisplayName("保存して取得できる")
    void saveAndFind() throws SQLException {
        repo.save(new User("U001", "田中太郎", "[email protected]"));
        var found = repo.findById("U001");
        assertTrue(found.isPresent());
        assertEquals("田中太郎", found.get().name());
    }

    @Test @DisplayName("存在しない ID は空を返す")
    void notFound() throws SQLException {
        assertTrue(repo.findById("U999").isEmpty());
    }

    @Test @DisplayName("複数件を全件取得できる")
    void findAll() throws SQLException {
        repo.save(new User("U001", "田中", "[email protected]"));
        repo.save(new User("U002", "鈴木", "[email protected]"));
        assertEquals(2, repo.findAll().size());
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

Testcontainers 1.19+ は Java 17 を推奨。text block で SQL を読みやすく書け、record でテストデータを簡潔に表現できる。

Java 17
// Java 17: @Testcontainers + text block で簡潔に
@Testcontainers
class UserRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    static final String CREATE_TABLE = """
        CREATE TABLE IF NOT EXISTS users (
            id    VARCHAR(50) PRIMARY KEY,
            name  VARCHAR(100) NOT NULL,
            email VARCHAR(200)
        )
        """;
}

Library Comparison

Testcontainers本番と同じ DB エンジンでテストしたいとき。Docker コンテナを自動管理する。CI/CD でも外部 DB 不要。Docker が必要。コンテナ起動に数秒かかり、単体テストほどの速度は出ない。
H2(インメモリ)Docker が使えない環境や ANSI SQL の範囲で十分なテスト。起動が極めて速い。PostgreSQL 固有の機能がサポートされず、本番との挙動差異が残る。
組み込み PostgreSQL(embedded-postgres)Docker なしで PostgreSQL バイナリを直接起動したいとき。OS ごとの互換性問題がある。コミュニティが小さい。

注意点

テスト実行環境に Docker が必要。Docker が動かない環境ではテストがスキップされる

コンテナの起動に数秒〜十数秒かかる。static フィールドで共有するか、テストスイートの分割を検討する

@Container を static にする場合、テスト間のデータ汚染を防ぐため @BeforeEach で TRUNCATE するか @AfterEach でロールバックする

PostgreSQLContainer のデフォルトイメージバージョンが固定されていない場合がある。本番と同じバージョンを明示指定する

testcontainers の依存は testImplementation に限定すること

CI 環境の Docker ソケット権限やメモリ制限で不安定になることがある。ローカルで通っても CI 設定は別途確認が必要

複数コンテナを組み合わせる統合テストは診断が難しくなる。まずは DB 単体で安定させてから広げる方が安全

FAQ

Testcontainers のテストが遅い場合、高速化する方法はありますか。

コンテナを static フィールドで共有し、Reusable Containers 機能を有効にすると起動コストを大幅に削減できます。

MySQL や Oracle でも使えますか。

はい。MySQLContainer、OracleContainer など主要 DB のモジュールが公式に提供されています。

GitHub Actions で追加設定は必要ですか。

ランナーに Docker がプリインストールされているため追加設定なしで動作します。

Flyway や Liquibase と併用できますか。

はい。コンテナ起動後にマイグレーションを適用してからテストを流す構成が一般的です。アプリ起動時に自動適用させる方法でも構いません。

単純な CRUD テストでも Testcontainers を使うべきですか。

本番 DB との方言差や制約差が問題になりやすいなら有効です。一方で、単純なリポジトリの基本動作確認なら H2 のような軽量手段を先に使う方が回しやすいこともあります。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます