Testcontainersで実DB並列テスト基盤を作る|PostgreSQL × JUnitでMock不要のテストを高速化する

約58分で読めます by ぽんたぬき
Testcontainersで実DB並列テスト基盤を作る|PostgreSQL × JUnitでMock不要のテストを高速化する

Testcontainersで実DB並列テスト基盤を作る|PostgreSQL × JUnitでMock不要のテストを高速化する

1. はじめに|なぜ今「実DBでのテスト」なのか

1-1. Mockで書いたDBテストが本番で壊れる瞬間

「ローカルでも全テストがグリーン、ステージング環境にデプロイした瞬間にエラーが発生した」——そんな経験をお持ちのエンジニアは少なくないでしょう。原因を調べると、Mockで握りつぶしていたDB制約違反だったり、トランザクションのロールバック処理が抜けていたり、というケースが非常に多く見られます。

Mockを使ったDBテストには、構造的な限界があります。MockRepositoryを使ったテストでは「そのMockRepositoryの振る舞いを前提にしてUseCaseが正しく動くか」しか検証できません。実際のSQLが正しく書けているか、DB制約がアプリ側のロジックと整合しているか、トランザクションが意図通りに機能しているかは、まったく検証されていないのです。

本番環境に出てから初めて気づきやすいバグの典型例を挙げてみましょう。

  • NOT NULL制約違反:アプリ側でnullチェックをしていても、他の経路からnullが紛れ込む
  • UNIQUE制約違反:並列リクエストによるレースコンディション
  • 外部キー制約違反:削除順序の誤り、カスケード設定の抜け漏れ
  • ロールバック漏れ:例外発生時にトランザクションが中途半端な状態で確定してしまう

これらはすべて、実際のDBを使ったテストを書けば、本番デプロイ前に確実に検出できます。

1-2. H2などインメモリDBでは代替できない理由

「H2をPostgreSQL互換モードで使えば良いのでは?」という声もよく聞かれます。たしかに単純なCRUDテストであれば動きますが、実際の開発現場では以下のような非互換に頻繁に直面します。

  • JSONB型:PostgreSQL固有の型で、H2ではTEXTとしてしか扱えない
  • 全文検索tsvectorto_tsvector()などの関数はH2にない
  • ON CONFLICT DO UPDATE(Upsert):H2のMERGE構文とは挙動が異なる
  • ウィンドウ関数:部分的にサポートされているが、PostgreSQL特有の書き方では動かないことがある

「PostgreSQL互換モード」という設定があっても、それはあくまで「部分的な互換」に過ぎません。結局のところ、ステージング環境に出て初めて不具合に気づき、手戻りコストが発生するパターンが繰り返されます。

1-3. 2026年のトレンド:実DBテストへの回帰

2026年現在、実DBを使ったテストへの回帰が業界的なトレンドになっています。エムスリーのテックブログでも実践例として紹介されているように、Spring Boot 4 + Testcontainersの組み合わせが実践的スタンダードとして定着しつつあります。

特筆すべきは@ServiceConnectionアノテーションの登場です。以前は@DynamicPropertySourceでJDBC URLを手動で注入していましたが、このアノテーション一つでTestcontainersのコンテナ情報がSpring Bootに自動連携されるようになりました。設定ファイルを一切触らずに実PostgreSQL環境を起動できる時代になったのです。

1-4. この記事で作るもの(完成イメージ)

この記事では、コンテナ1個で、クラス単位の並列実行が回るテスト基盤をゼロから構築します。完成した基盤では以下のパフォーマンスが期待できます。

  • Migration全体の実行:約500ms(スイートで1回のみ)
  • Templateからのデータベース複製:約17ms
  • テストデータの初期化(並列10件):約27ms

使用する技術スタックは以下の通りです。

技術 バージョン
Kotlin 2.x
JUnit Jupiter 5.11+
Testcontainers 1.20+
PostgreSQL 18-alpine
Flyway 10.x
HikariCP 5.x
Spring Boot(オプション) 4.x

2. 事前準備|環境構築とプロジェクトセットアップ

2-1. 必要な環境と前提知識

まず、手元の環境でDockerが正常に動作していることを確認してください。

# Dockerの動作確認
if command -v docker &> /dev/null; then
    docker --version
    docker run --rm hello-world
else
    echo "警告: Dockerが見つかりません。Testcontainersを使用するにはDockerのインストールが必要です。"
    echo "インストール方法: https://docs.docker.com/get-docker/"
    exit 1
fi

macOSユーザーは Docker Desktop のほか、ColimやOrbStackも使用できます。Apple Siliconの場合はlinux/amd64エミュレーションが必要になるケースがありますが、現在のpostgres:18-alpineはマルチアーキテクチャイメージを提供しているため、基本的に問題ありません。

必要なJDKとビルドツールのバージョンは以下を目安にしてください。

# JDKバージョン確認(17以上推奨)
if command -v java &> /dev/null; then
    java --version
else
    echo "警告: Javaが見つかりません。JDK 17以上をインストールしてください。"
fi

# Gradleバージョン確認(8.x推奨)
if [ -f "./gradlew" ]; then
    ./gradlew --version
else
    echo "警告: gradlewが見つかりません。プロジェクトルートで実行してください。"
fi

前提知識として、JUnit 5の基本的なアノテーション(@Test@BeforeEachなど)と、JDBCの接続概念(URL、ユーザー名、パスワード)を理解していれば、この記事の内容を手順通りに再現できます。

2-2. 依存関係の追加

Gradle(Kotlin DSL)の場合:

// build.gradle.kts
dependencies {
    // Testcontainers BOM でバージョンを統一
    testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4"))
    testImplementation("org.testcontainers:testcontainers")
    testImplementation("org.testcontainers:postgresql")
    testImplementation("org.testcontainers:junit-jupiter")

    // JUnit Jupiter
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")

    // PostgreSQL JDBCドライバ
    testImplementation("org.postgresql:postgresql:42.7.4")

    // Flyway(マイグレーション管理)
    testImplementation("org.flywaydb:flyway-core:10.20.1")
    testImplementation("org.flywaydb:flyway-database-postgresql:10.20.1")

    // HikariCP(コネクションプール)
    testImplementation("com.zaxxer:HikariCP:5.1.0")
}

tasks.test {
    useJUnitPlatform()
    // Dockerが利用できない環境ではTestcontainersテストをスキップ
    val dockerAvailable = try {
        Runtime.getRuntime().exec(arrayOf("docker", "info")).waitFor() == 0
    } catch (e: Exception) {
        false
    }
    if (!dockerAvailable) {
        systemProperty("org.testcontainers.checks.disable", "true")
        exclude("**/*ContainerTest*", "**/*IntegrationTest*")
        logger.warn("警告: Dockerが見つからないため、Testcontainersを使用するテストをスキップします。")
    }
}

Maven の場合:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>1.20.4</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

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

2-3. サンプルプロジェクトの構成

今回構築するプロジェクトのディレクトリ構成です。

src/
├── main/
│   └── kotlin/
│       └── com/example/
│           ├── domain/
│           │   └── User.kt
│           └── repository/
│               └── UserRepository.kt
└── test/
    ├── kotlin/
    │   └── com/example/
    │       ├── support/
    │       │   ├── DatabaseContainer.kt   # シングルトンコンテナ
    │       │   ├── TestDatabase.kt        # ThreadLocal管理・DB複製
    │       │   └── TestDatabaseExtension.kt # JUnit Extension
    │       └── repository/
    │           ├── UserRepositoryTest.kt
    │           └── OrderRepositoryTest.kt
    └── resources/
        ├── db/migration/
        │   ├── V1__create_users.sql
        │   └── V2__create_orders.sql
        └── junit-platform.properties

題材として使用するスキーマは、外部キー・UNIQUE制約・シーケンスを含む現実的なものにします。

-- V1__create_users.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(100) NOT NULL,
    profile    JSONB,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- V2__create_orders.sql
CREATE TABLE orders (
    id         BIGSERIAL PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    status     VARCHAR(50) NOT NULL DEFAULT 'pending',
    amount     NUMERIC(12, 2) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    CONSTRAINT chk_amount_positive CHECK (amount > 0)
);

3. ステップ1|TestcontainersでPostgreSQLを起動する(最小構成)

3-1. まずは動かす:最小サンプルコード

まず、テストクラスにTestcontainersを組み込む最小構成を確認しましょう。

import org.junit.jupiter.api.Test
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager
import kotlin.test.assertEquals

@Testcontainers
class MinimalContainerTest {

    @Container
    val postgres = PostgreSQLContainer<Nothing>("postgres:18-alpine")

    @Test
    fun `PostgreSQLコンテナに接続してクエリを実行できる`() {
        DriverManager.getConnection(
            postgres.jdbcUrl,
            postgres.username,
            postgres.password
        ).use { conn ->
            val rs = conn.createStatement().executeQuery("SELECT 1")
            rs.next()
            assertEquals(1, rs.getInt(1))
        }
    }
}

これだけで実際のPostgreSQL 18コンテナが起動し、postgres.jdbcUrlでJDBC接続できます。テスト終了後はコンテナが自動的に停止・削除されます。

3-2. @Testcontainers / @Container アノテーションの使い方

@Containerアノテーションをインスタンスフィールドに付けるとテストメソッドごとにコンテナが起動・停止します。一方、staticフィールド(Kotlinではcompanion object内)に付けるとテストクラス全体で1個のコンテナが使われます。

@Testcontainers
class ContainerLifecycleTest {

    companion object {
        // クラス全体で1個のコンテナを共有(推奨)
        @Container
        @JvmStatic
        val postgres = PostgreSQLContainer<Nothing>("postgres:18-alpine")
    }

    @Test
    fun `test1`() { /* 同じコンテナを使用 */ }

    @Test
    fun `test2`() { /* 同じコンテナを使用 */ }
}

テストメソッドごとにコンテナを起動する方式はコストが高いため、基本的にはクラス単位で共有する方式を選択してください。

3-3. イメージバージョンは必ず固定する

latestタグを使うと、いつの間にかPostgreSQLのメジャーバージョンが上がり、テストが壊れるリスクがあります。必ずバージョンを固定しましょう。

// ❌ 避けるべき書き方
PostgreSQLContainer<Nothing>("postgres:latest")

// ✅ 推奨する書き方
PostgreSQLContainer<Nothing>("postgres:18-alpine")

alpineベースのイメージはサイズが小さく、CI環境でのpull時間を削減できるためおすすめです。

3-4. よくあるエラーと対処

Could not find a valid Docker environment
DockerデーモンまたはDockerソケットが見つからない場合に発生します。Docker Desktopが起動しているか確認してください。RootlessモードやPodmanを使用している場合は環境変数DOCKER_HOSTの設定が必要です。

Apple Silicon環境でのアーキテクチャ不一致
postgres:18-alpineはマルチアーキテクチャ対応ですが、一部の古いイメージではエラーが出ることがあります。DOCKER_DEFAULT_PLATFORM=linux/arm64を設定するか、withImagePullPolicyを明示してください。

起動タイムアウト
低スペックなCI環境では起動に時間がかかる場合があります。waitingForをカスタマイズすることで対処できます。

PostgreSQLContainer<Nothing>("postgres:18-alpine")
    .waitingFor(
        Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(120))
    )

4. ステップ2|「コンテナ1個」設計に切り替える

4-1. テストごとにコンテナを起動してはいけない

PostgreSQLコンテナの起動には、環境によって数秒から10秒以上かかります。テストクラスが10個あれば10回起動することになり、CI全体で数分のオーバーヘッドになります。100クラスになれば文字通り「テストを実行するより待つ時間の方が長い」状態になります。

スイート全体で1個のコンテナを使い回すことが、大規模テストスイートを現実的に運用するための第一条件です。

4-2. シングルトンコンテナパターンの実装

Kotlinのobject宣言(シングルトン)を使って、プロセス内でコンテナを1個だけ起動する仕組みを作ります。

// src/test/kotlin/com/example/support/DatabaseContainer.kt
import org.testcontainers.containers.PostgreSQLContainer

object DatabaseContainer {

    val instance: PostgreSQLContainer<Nothing> by lazy {
        PostgreSQLContainer<Nothing>("postgres:18-alpine")
            .withDatabaseName("test_master")
            .withUsername("test")
            .withPassword("test")
            .withCommand(
                "postgres",
                "-c", "fsync=off",
                "-c", "full_page_writes=off",
                "-c", "synchronous_commit=off"
            )
            .also { it.start() }
    }
}

lazyによる遅延初期化で、最初にアクセスされたときだけコンテナが起動されます。JVMが終了するまでコンテナは生き続けます。

4-3. Flyway Migrationは「1回だけ」実行する

スイート全体でMigrationを1回だけ実行し、その結果をtest_db_templateというデータベースとして保存しておきます。

object DatabaseContainer {

    val instance: PostgreSQLContainer<Nothing> by lazy {
        PostgreSQLContainer<Nothing>("postgres:18-alpine")
            // ...(省略)
            .also { container ->
                container.start()
                createTemplateDatabase(container)
            }
    }

    private fun createTemplateDatabase(container: PostgreSQLContainer<Nothing>) {
        // masterに接続してtemplateデータベースを作成
        getConnection(container, "test_master").use { conn ->
            conn.createStatement().execute(
                "CREATE DATABASE test_db_template"
            )
        }

        // templateデータベースにFlywayでMigrationを実行
        Flyway.configure()
            .dataSource(
                container.jdbcUrl.replace("/test_master", "/test_db_template"),
                container.username,
                container.password
            )
            .load()
            .migrate()  // これが1回だけ実行される
    }
}

4-4. PostgreSQL設定でテストを高速化する

withCommand()でPostgreSQLの起動オプションを上書きし、耐久性を犠牲にしてI/O性能を最大化します。

.withCommand(
    "postgres",
    "-c", "fsync=off",                // ディスクへの同期を無効化
    "-c", "full_page_writes=off",     // ページ書き込みを最小化
    "-c", "synchronous_commit=off",   // コミットの同期待ちを無効化
    "-c", "max_connections=200"       // 並列テスト用に接続数を増やす
)

⚠️ 重要な注意事項
これらの設定はデータ損失を引き起こすリスクがあります。テスト専用コンテナにのみ適用し、絶対に本番環境には持ち込まないでください。


5. ステップ3|並列実行のためのDB分離設計

5-1. 分離単位を決める

並列テストにおけるDB分離の粒度は設計の核心です。各リソースのライフサイクルを以下のように設計します。

リソース ライフサイクル 理由
コンテナ スイート全体で1個 起動コスト削減
Database スレッド単位で再利用 接続先が異なるため他スレッドのデータをブロック
スキーマ Templateから複製 Migration実行を1回に抑制(500ms → 17ms)
テストデータ テストごとにTRUNCATE+再投入 独立性の保証

5-2. なぜ「スキーマ分離」ではなく「Database分離」なのか

PostgreSQLのスキーマ分離(search_pathを切り替える方式)では、設定ミスや生SQLの書き方によって他スレッドのスキーマに到達できてしまいます。データ汚染が起きても「静かに成功してしまう」のが最も危険です。

Database分離であれば、接続先が物理的に異なります。誤ったデータベースへのアクセスはエラーになるため、問題が起きたときに必ずテストが失敗するという設計が実現できます。「静かに壊れない」ことが、堅牢なテスト基盤の核心的な設計思想です。

5-3. CREATE DATABASE ... TEMPLATE によるDB複製

PostgreSQLにはCREATE DATABASE ... TEMPLATEという、既存のデータベースを丸ごとコピーする機能があります。Migrationを実行済みのtest_db_templateを元に、スレッドごとのデータベースを高速に作成できます。

fun createDatabaseFromTemplate(
    conn: Connection,
    dbName: String,
    templateName: String = "test_db_template"
) {
    // TEMPLATE指定時、ソースDBへの接続が残っていると失敗する
    // → 事前に全接続を切断する
    conn.createStatement().execute("""
        SELECT pg_terminate_backend(pid)
        FROM pg_stat_activity
        WHERE datname = '$templateName' AND pid <> pg_backend_pid()
    """)

    conn.createStatement().execute(
        "CREATE DATABASE $dbName TEMPLATE $templateName"
    )
}

この複製処理のコストは約17msです。Flyway Migrationを毎回実行した場合の500msと比べると、約30倍の高速化を達成できます。

5-4. なぜ「トランザクションでロールバック」方式を採らなかったか

「テストをトランザクション内で実行し、テスト後にロールバックする」という方式も一般的です。しかし、以下の理由から今回の設計では採用しませんでした。

  1. アプリがコネクションプールから独自接続を取得してCommit/Rollbackする構造では、テスト側のトランザクションと分離されるため他接続の変更が見えない
  2. 複数トランザクションにまたがる失敗検証(例:外部キー制約がCommit時に発動するケース)が書けない
  3. テストフレームワーク側が内部で@Transactionalを管理するため、アプリ側のトランザクション制御と干渉しやすい

実際の開発現場では、アプリのトランザクション挙動そのものをテストしたいケースが多くあります。Database分離方式の方がより自然な形でそれを実現できます。


6. ステップ4|JUnitの並列実行を設定する

6-1. junit-platform.properties の設定

# src/test/resources/junit-platform.properties

# 並列実行を有効化
junit.jupiter.execution.parallel.enabled=true

# デフォルト(メソッドレベル)は順次実行
junit.jupiter.execution.parallel.mode.default=same_thread

# クラスレベルは並列実行
junit.jupiter.execution.parallel.mode.classes.default=concurrent

# 並列度の設定(固定値またはCPUコア数に応じた動的設定)
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.0

6-2. 「メソッドは順次・クラスは並列」にする理由

クラス単位での並列実行を選択する最大の理由は、ThreadLocalによるDatabase割り当てのシンプルさにあります。

クラス内のテストメソッドが同一スレッドで順次実行される場合、「このスレッドはtest_db_xxxxを使う」というマッピングをThreadLocalで一意に管理できます。メソッドレベルで並列実行すると、同じクラス内のメソッドが異なるスレッドで動き、ThreadLocalの割り当てが複雑になります。

また、テストクラスが@BeforeEachで共有状態を設定し、テストメソッドがその状態を参照する設計は非常に一般的です。メソッド並列実行ではこの共有状態が競合します。

6-3. 並列度のチューニング

dynamic戦略はCPUコア数に係数を掛けた値を並列度として使用します。Apple Silicon(10コア)の実測では、係数を1.0〜1.5程度に設定した場合にパフォーマンスが最良でした。

並列度を上げすぎると逆に遅くなります。理由はDB接続数の競合と、コンテキストスイッチのオーバーヘッドです。max_connections(PostgreSQL側)と並列度(JUnit側)のバランスを取ることが重要です。

計算式の目安:

並列度 ≤ max_connections ÷ pool_size_per_thread

6-4. 並列にしてはいけないテストを隔離する

どうしても並列実行できないテスト(グローバルな状態を変更するもの、外部サービスとの通信など)には以下のアノテーションを使用します。

// そのテストクラスだけをシングルスレッドで実行
@Execution(ExecutionMode.SAME_THREAD)
class SequentialTest { ... }

// 共有リソースへの排他アクセスを宣言
@ResourceLock(value = "shared-resource", mode = ResourceAccessMode.READ_WRITE)
class ResourceLockTest { ... }

7. ステップ5|ThreadLocalによるDatabase再利用の実装

7-1. 設計方針:スレッドごとに1DBを払い出して使い回す

核心のアーキテクチャは以下の通りです。

テストクラスA(Thread-1)
  ├── テスト1: setup() → test_db_1001 複製・初期化(初回: ~50ms)
  ├── テスト2: setup() → test_db_1001 TRUNCATE+再投入(2回目以降: ~27ms)
  └── テスト3: setup() → test_db_1001 TRUNCATE+再投入

テストクラスB(Thread-2)
  ├── テスト1: setup() → test_db_1002 複製・初期化(初回: ~50ms)
  └── テスト2: setup() → test_db_1002 TRUNCATE+再投入

初回だけTemplateからデータベースを複製し、2回目以降はTRUNCATEとデータ再投入のみを行います。

7-2. TestDatabase クラスの実装

// src/test/kotlin/com/example/support/TestDatabase.kt
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import java.sql.Connection

class TestDatabase private constructor(
    private val container: PostgreSQLContainer<Nothing>
) {
    companion object {
        private val threadDatabaseCache = ThreadLocal<ThreadDatabaseState>()
        private val dbCounter = java.util.concurrent.atomic.AtomicInteger(1000)

        fun setup(container: PostgreSQLContainer<Nothing>): HikariDataSource {
            val cached = threadDatabaseCache.get()

            return if (cached != null) {
                // 2回目以降: TRUNCATEとデータ再投入のみ
                resetData(cached.dataSource)
                cached.dataSource
            } else {
                // 初回: Templateから新しいDBを複製
                val dbName = "test_db_${dbCounter.getAndIncrement()}"
                val newDataSource = createDatabaseFromTemplate(container, dbName)
                threadDatabaseCache.set(ThreadDatabaseState(dbName, newDataSource))
                newDataSource
            }
        }

        private fun createDatabaseFromTemplate(
            container: PostgreSQLContainer<Nothing>,
            dbName: String
        ): HikariDataSource {
            // masterに接続してDB複製
            getMasterConnection(container).use { conn ->
                // template使用中の接続を切断
                conn.createStatement().execute("""
                    SELECT pg_terminate_backend(pid)
                    FROM pg_stat_activity
                    WHERE datname = 'test_db_template'
                      AND pid <> pg_backend_pid()
                """)
                conn.createStatement().execute(
                    "CREATE DATABASE $dbName TEMPLATE test_db_template"
                )
            }

            // 新しいDBへの接続プールを作成
            val config = HikariConfig().apply {
                jdbcUrl = container.jdbcUrl.replace("/test_master", "/$dbName")
                username = container.username
                password = container.password
                maximumPoolSize = 5
                minimumIdle = 1
            }
            return HikariDataSource(config)
        }

        private fun resetData(dataSource: HikariDataSource) {
            dataSource.connection.use { conn ->
                // 全テーブルをTRUNCATE(順序を気にせずCASCADEで一括)
                conn.createStatement().execute("""
                    TRUNCATE TABLE orders, users RESTART IDENTITY CASCADE
                """)
                // 初期データを再投入
                reinsertInitialData(conn)
            }
        }

        private fun reinsertInitialData(conn: Connection) {
            // pg_dumpから抽出した初期データのSQL群を実行
            InitialDataHolder.insertStatements.forEach { sql ->
                conn.createStatement().execute(sql)
            }
        }
    }

    data class ThreadDatabaseState(
        val dbName: String,
        val dataSource: HikariDataSource
    )
}

7-3. JUnit Extension として組み込む

TestDatabase.setup()をテストの@BeforeEach相当で自動実行するJUnit Extensionを作成します。

// src/test/kotlin/com/example/support/TestDatabaseExtension.kt
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext

class TestDatabaseExtension : BeforeEachCallback {

    override fun beforeEach(context: ExtensionContext) {
        val dataSource = TestDatabase.setup(DatabaseContainer.instance)
        // ExtensionContextのStoreにDataSourceを保存し、テストから参照できるようにする
        context.getStore(ExtensionContext.Namespace.GLOBAL)
            .put("dataSource", dataSource)
    }
}

テストクラスでの使用は@ExtendWith1行で済みます。

@ExtendWith(TestDatabaseExtension::class)
class UserRepositoryTest {

    @Test
    fun `ユーザーを登録できる`(context: ExtensionContext) {
        val dataSource = context.getStore(ExtensionContext.Namespace.GLOBAL)
            .get("dataSource") as HikariDataSource
        // ...テストコード
    }
}

8. ステップ6|テストデータ初期化を最速にする

8-1. 初期化方式の実測比較

テストデータの初期化方式として、2つを比較検討しました。

方式 同時実行1件 同時実行10件
TRUNCATE+初期データ復元 11.41ms 27.20ms
DROP+CREATE TEMPLATE 9.51ms 47.15ms

単体では「DROP+CREATE TEMPLATE」がわずかに速いですが、並列度が上がるとTemplateへの同時接続切断処理が競合し、急激に遅くなります。この実測結果が示すように、**単体ベンチマークだけで方式を決定してはいけません。**並列環境での実測が不可欠です。

8-2. TRUNCATE の実装ポイント

テーブルを正しい順序でTRUNCATEするのは外部キー制約があると厄介ですが、CASCADEオプションで一括処理できます。

-- 全テーブルを1文で、シーケンスもリセット
TRUNCATE TABLE orders, users RESTART IDENTITY CASCADE;

RESTART IDENTITYを指定することで、BIGSERIALのシーケンスも初期値にリセットされます。これを忘れると、テスト間でIDが累積し、初期データのIDと食い違うバグが発生します。

8-3. pg_dump から初期データを抽出する

初期データはアプリ側のシードスクリプトやpg_dumpから抽出します。重要なのはINSERT文だけでなくsetval()も必ず抽出することです。

// pg_dumpの出力から必要な行だけを抽出
fun extractInitialData(dumpOutput: String): List<String> {
    return dumpOutput.lineSequence()
        .filter { line ->
            line.startsWith("INSERT INTO ") ||
            line.startsWith("SELECT pg_catalog.setval(")
        }
        .toList()
}

pg_dumpを実行する際は--column-insertsオプションを付けることで、列名を明示したINSERT文が生成され、スキーマ変更時の耐性が高まります。

pg_dump \
  --host=localhost \
  --port=5432 \
  --username=test \
  --data-only \
  --column-inserts \
  --no-privileges \
  test_db_template > initial_data.sql

setval()を拾い忘れた場合に何が起きるか説明します。TRUNCATE ... RESTART IDENTITYでシーケンスはリセットされますが、INSERT文でIDを明示的に指定した場合(INSERT INTO users (id, email, ...) VALUES (100, ...)など)、次のnextval()はシーケンスの現在値から始まるため、IDが100以降で衝突します。この種のバグはランダムなタイミングで発生し、非常に調査が困難です。


9. ステップ7|Spring Bootとの統合(オプション)

9-1. @ServiceConnection で設定を自動化する

Spring Boot 3.1以降(4.x含む)では@ServiceConnectionアノテーションにより、Testcontainersのコンテナ情報がSpring Bootに自動連携されます。

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {

    companion object {
        @Container
        @ServiceConnection
        @JvmStatic
        val postgres = PostgreSQLContainer<Nothing>("postgres:18-alpine")
    }

    @Autowired
    private lateinit var userService: UserService

    @Test
    fun `ユーザーの重複登録は例外になる`() {
        userService.register("test@example.com", "Alice")

        // 2回目の登録で例外が発生することを検証
        // MockではDBのUNIQUE制約を通らないため検証不可能
        assertThrows<DuplicateUserException> {
            userService.register("test@example.com", "Bob")
        }
    }
}

application.propertiesへのJDBC URLの記述が一切不要になります。

9-2. @DataJpaTest を使う場合の注意点

@DataJpaTestはデフォルトでインメモリDBに切り替えようとします。実DBを使用する場合は@AutoConfigureTestDatabaseで置き換えを無効化してください。

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryJpaTest {

    companion object {
        @Container
        @ServiceConnection
        @JvmStatic
        val postgres = PostgreSQLContainer<Nothing>("postgres:18-alpine")
    }
}

10. 動作確認|実際に並列で回してみる

10-1. テストを書いて実行する

Mockでは書けない、実DB特有のテスト例を示します。

@ExtendWith(TestDatabaseExtension::class)
class UserRepositoryTest {

    @Test
    fun `UNIQUE制約違反はDataIntegrityViolationExceptionになる`(ctx: ExtensionContext) {
        val repo = UserRepository(getDataSource(ctx))
        repo.save(User(email = "alice@example.com", name = "Alice"))

        // 同じemailで2件目を登録しようとすると例外になることを検証
        // Mockではこの検証が書けない(Mockは制約を知らないから)
        assertThrows<DataIntegrityViolationException> {
            repo.save(User(email = "alice@example.com", name = "Alice2"))
        }
    }

    @Test
    fun `外部キー制約でユーザー削除時にorderもCASCADE削除される`(ctx: ExtensionContext) {
        val repo = UserRepository(getDataSource(ctx))
        val orderRepo = OrderRepository(getDataSource(ctx))

        val user = repo.save(User(email = "bob@example.com", name = "Bob"))
        orderRepo.save(Order(userId = user.id, amount = 1000.0))

        repo.delete(user.id)

        // CASCADE削除されていることを確認
        assertEquals(0, orderRepo.countByUserId(user.id))
    }

    @Test
    fun `JSONBカラムへの保存と検索ができる`(ctx: ExtensionContext) {
        val repo = UserRepository(getDataSource(ctx))
        val profile = mapOf("age" to 30, "city" to "Tokyo")
        repo.save(User(email = "carol@example.com", name = "Carol", profile = profile))

        // JSONB演算子を使ったクエリの動作確認
        val results = repo.findByProfileCity("Tokyo")
        assertEquals(1, results.size)
    }
}

10-2. 並列で走っていることを確認する

テストログにスレッド名とDB名を出力することで、並列実行を視覚的に確認できます。

override fun beforeEach(context: ExtensionContext) {
    val dataSource = TestDatabase.setup(DatabaseContainer.instance)
    val dbName = DatabaseContainer.currentDbName()

    logger.info(
        "Thread: ${Thread.currentThread().name} | DB: $dbName | Test: ${context.displayName}"
    )
}

実行ログには以下のように出力されます(クラス単位で並列実行されていることが確認できます)。

Thread: ForkJoinPool-1-worker-1 | DB: test_db_1001 | Test: UserRepositoryTest
Thread: ForkJoinPool-1-worker-2 | DB: test_db_1002 | Test: OrderRepositoryTest
Thread: ForkJoinPool-1-worker-3 | DB: test_db_1003 | Test: ProductRepositoryTest

11. CI/CD環境での運用

11-1. GitHub Actions での設定例

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest  # DockerデーモンがデフォルトでAvailable

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      - name: Cache Gradle packages
        uses: actions/cache@v4
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}

      - name: Cache Docker images
        uses: actions/cache@v4
        with:
          path: /tmp/.docker-cache
          key: docker-${{ hashFiles('**/build.gradle.kts') }}

      - name: Run tests
        run: ./gradlew test --parallel
        env:
          TESTCONTAINERS_REUSE_ENABLE: "false"  # CIではReuse無効

11-2. withReuse(true) はCIで使わない

withReuse(true)はローカル開発時にコンテナの起動時間を節約できますが、CI環境では使用しないことを強く推奨します。並列パイプライン(同一Runnerで複数ジョブが動く場合)では、前のジョブが残したコンテナを次のジョブが使ってしまい、テストの独立性が失われます。

PostgreSQLContainer<Nothing>("postgres:18-alpine")
    .apply {
        // 環境変数で切り替える
        if (System.getenv("CI") == null) {
            withReuse(true)
        }
    }

11-3. 失敗時のデバッグ

コンテナのログをファイルに書き出すことで、CI失敗時の調査が容易になります。

PostgreSQLContainer<Nothing>("postgres:18-alpine")
    .withLogConsumer(
        Slf4jLogConsumer(LoggerFactory.getLogger("postgres-container"))
    )

12. トラブルシューティング&FAQ

12-1. コンテナ1個に絞っても本当に速いのか?

はい、十分に速いです。コスト内訳を確認しましょう。

  • コンテナ起動:約3〜5秒(スイートで1回のみ)
  • Flyway Migration:約500ms(スイートで1回のみ)
  • DB複製(CREATE DATABASE TEMPLATE):約17ms(スレッド初回のみ)
  • データ初期化(TRUNCATE+再投入):約27ms(テストごと、並列10件時)

テストが100件あっても、「コンテナ起動5秒 + Migration0.5秒 + DB複製17ms×スレッド数 + 初期化27ms×100」程度に収まります。

12-2. CREATE DATABASE ... TEMPLATE が失敗する

エラーメッセージ「source database is being accessed by other users」が出た場合、Templateデータベースへの既存接続が残っています。前述のpg_terminate_backendでアクティブ接続を切断してから実行してください。Flyway自身が接続を保持したままの場合もあるため、Migration完了後に接続プールをクローズすることが重要です。

12-3. コネクションプールが枯渇する

以下の計算式でmax_connectionsを設定してください。

max_connections ≥ 並列度 × スレッドあたりのプールサイズ + 管理用接続数(10程度)

例:並列度10 × プールサイズ5 + 10 = 60以上が必要です。PostgreSQLのデフォルトmax_connectionsは100なので、大規模な並列テストでは設定の見直しが必要です。

12-4. ローカルでは通るのにCIで落ちる

典型的な原因と対処法:

  • 実行順序依存:ランダム順実行(junit.jupiter.testmethod.order.default=RANDOM)で事前に検出する
  • タイムゾーン:JVMのデフォルトタイムゾーンとDBのタイムゾーンを明示的に揃える
  • 並列度の過多:CI環境はローカルよりCPUコア数が少ない場合が多い。dynamic.factorを下げる

12-5. テストが遅くなってきたときの調査手順

  1. JUnitの実行時間レポートを出力(--infoフラグ)
  2. 初期化時間と実テスト時間を分離して計測
  3. TRUNCATE対象テーブルが増えていないか確認
  4. コネクションプールの待ち時間(HikariCP メトリクス)を確認
  5. 並列度を上げすぎていないか確認(CPUコア数の1〜1.5倍が目安)

13. まとめ|4層設計で「速くてリアルな」テストを手に入れる

13-1. 設計の核心はこの4つ

この記事で構築したテスト基盤の本質は、以下の4層設計にあります。

  1. コンテナ1個——起動コストはスイートで一度だけ払う
  2. Database分離——誤アクセスを物理的にブロックし「静かに壊れない」設計
  3. ThreadLocal再利用——DB複製コストを初回だけに抑え、2回目以降はTRUNCATEのみ
  4. TRUNCATE初期化——並列度が上がっても競合しない最速の初期化方式

この4つを組み合わせることで、Mock不要・H2不要でありながら高速なテスト基盤が実現します。

13-2. Mockと実DBの使い分け指針

実DBテストが有効であることを示しましたが、すべてをMock禁止にする必要はありません。ベストプラクティスとして、以下の使い分けを推奨します。

対象 推奨アプローチ 理由
永続化層(Repository/DAO) 実DB DB制約・SQL・トランザクション全体を検証
外部API呼び出し Mock 外部サービスへの依存を排除
時刻・乱数 Mock 再現性の確保
複雑なビジネスロジック(ドメイン層) ユニットテスト(Mockで十分) 高速なフィードバックが重要

13-3. 次のステップ

今回はPostgreSQLを対象にしましたが、Testcontainersは他のミドルウェアにも同様の設計を適用できます。

  • MySQL / MariaDBMySQLContainerが利用可能。スキーマ分離戦略で類似の構成を組める
  • RedisGenericContainer("redis:7-alpine")でキャッシュ層の統合テスト
  • KafkaKafkaContainerでメッセージング基盤のE2Eテスト
  • LocalStack:AWS S3・SQS・DynamoDBのローカルエミュレーション

参考リンク:


段階的に理解を深めながら構築してきましたが、最終的に完成した基盤は「実際の開発現場で即使えるもの」になっているはずです。Mockで誤魔化してきたDB周りのテストを、一つずつ実DBテストに置き換えていくことで、本番デプロイへの自信が着実に高まっていくことを体感いただければ幸いです。

コメント

0/2000