Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

Testcontainersで単体テスト実行時に必要なデータベースを自動的に用意する

この記事は Kotlin Advent Calendar 2024 10日目の記事です。前回はtsukakeiさんのExposedでStatementInterceptorを使ってSQL文実行前後に処理を差し挟むでした。

先日出した合同誌でも触れましたが、Testcontainersを使うと単体テスト実行時に必要なデータベースを自動的に用意することができます。TestcontainersといえばSelenide (Selenium)でもお世話になりましたが、ブラウザだけではなくデータベースも対応しているんですね。

合同誌掲載ケースではDevelocityのTest Distributionで遠隔テストを実行するのに利用しましたが、手元やGitHub Actionsなどでテストをするにも便利なので紹介します。なお掲載コードはKoinとKotest、Postgresを前提としています。

Testcontainersがなぜ便利なのか

Testcontainersはテストに利用するための外部プロセスをお手軽に利用するためのライブラリです。たとえば従来だとPostgresに依存したテストを実行するには実行環境にPostgresをインストールすること(GitHub Actionsなら services を使ってPostgresを起動する、など)が必要でしたが、ポート番号を環境変数で受け渡すとか、データベース側がちゃんと起動したか確認するとかの手間があり不便です。Testcontainersを使うことでプログラムからデータベースを起動できるので諸々をテストコードとして管理できるようになり、メンテナンス可能性が上がります。

TestcontainersをKotestで使う

公式のQuickStartにはKotestに特化したものがないので説明します。前提としてデータベースに接続するための接続プールをKoinで初期化しているものとします:

// src/main/kotlin/com/example/DatabaseModule.kt
@Module
@ComponentScan
class DatabaseModule {
  @Single
  fun createDatasource(): HikariDataSource { ... }
}

テスト実行時はこれを上書きして利用することにします。java-test-fixtures プラグインを使えばテストコードではないけどテストから利用するコードをテストコードから分離して管理できますので、これを利用すると良いでしょう。

// build.gradle.kts
plugins {
  `java-test-fixtures`
  ...
}

dependencies {
  testFixturesImplementation("org.testcontainers:postgresql:1.20.4")
  ...
}

TestFixtureとテストコードは次のようになります:

// src/testFixtures/kotlin/com/example/DatabaseModuleForTest.kt
fun createTestcontainerModule() = module {
  single<HikariDataSource> {
    // TODO: ここにTestcontainers利用コードを書く
  }
}
// src/test/kotlin/com/example/MyTest.kt
class MyTest : DescribeSpec({
  beforeSpec {
    startKoin {
      modules(DatabaseModule.module + createTestcontainerModule())
    }
  }
  afterSpec {
    stopKoin()
  }
  // TODO: ここにデータベースに依存したテストを書く
})

Postgres公式イメージを使う

Testcontainersの使い方のひとつとして、Postgres公式イメージを使ってデータベースを起動できます。スキーマがなにもない状態で起動しますから、ExposedのSchemaUtilsあたりで初期化する必要はあります。

fun createTestcontainerModule() = module {
  single<HikariDataSource> {
    val container = PostgreSQLContainer("postgres:17-alpine").apply {
      withLogConsumer(Slf4jLogConsumer(logger))
      start()
    }
    val config = HikariConfig().apply {
      jdbcUrl = container.jdbcUrl
      username = container.username
      password = container.password
      driverClassName = container.driverClassName

      // TODO: 他の設定をする

      validate()
    }

    return HikariDataSource(config)
  }
}

なお Slf4jLogConsumer はTestcontainersのログをSLF4Jのロガーに渡すための設定です。SLF4Jを利用していない場合は他の LogConsumer を使うことになるでしょう。

独自のコンテナを使う

すでにコンテナレジストリにある独自のコンテナを使うこともできます。Postgres互換のイメージであることを明示するために asCompatibleSubstituteFor("postgres") の実行が必要です。

fun createTestcontainerModule() = module {
  single<HikariDataSource> {
    val imageName = DockerImageName.parse(IMAGE_NAME).asCompatibleSubstituteFor("postgres")
    val container = PostgreSQLContainer(imageName).apply {
      ...
      start()
    }
    ...
  }
}

コンテナのライフサイクルを考える

PostgreSQLContainerAutoCloseable インタフェースを実装しています。つまりコンテナのライフサイクルをプログラムから管理する必要があります。KotestはJUnit5の基盤で動いているので、Testcontainers組み込みのJUnit4対応は期待できません。オフィシャルサイトを参考に、自分で管理を考える必要があります。

ところでテストコードで Koin を使う場合、すでに KoinApplication のライフサイクルを管理しているはずです。Specごとに KoinApplication を作るとか、テストケースごとに KoinApplication を作るとかの方法がありますが、コンテナのライフサイクルもこれと同じにできるなら、Koin moduleが閉じられたタイミングでコンテナを止めるようにするのがシンプルでしょう。

fun createTestcontainerModule() = module {
  single<PostgreSQLContainer> {
    ...
  } onClose {
    it.close()
  }
  ...
}

またKoinApplication とコンテナのライフサイクルをあわせてしまうとテストの実行パフォーマンスに影響がある場合は、コンテナをSingletonとして扱うことになります。この場合はコンテナの停止はTestcontainersに任せられるので、明示的に close() を呼ぶ必要はありません。

まとめ

Testcontainersを使うとデータベースに依存したテストケースをより簡単に実行できます。ちょっと前だとH2 Databaseなどのオンメモリデータベースを使っていましたが、Testcontainersだと本番環境と同じRDBMSを使えるのでより安心ですね。Postgresに依存するコードをテストする際にお試しください!