🐳

testcontainers-scala で快適なインテグレーションテストを実現する

2023/12/22に公開

この記事は、3-shake Advent Calendar 2023 の 22 日目の記事です。

はじめに

私の所属する株式会社スリーシェイクでは、Reckoner というデータパイプライン構築の SaaS を開発しています。

https://reckoner.io/

「SaaSをつなぐ。業務が変わる。ビジネスが進化する。」

直感的なユーザーインターフェイスで、多種多様な SaaS のデータをつなぎ合わせることで、データ活用・データの民主化を実現します。

img.png

課題

Reckoner では、データの取得・加工・保存部分を Scala で実装しており、データの連携先として、MySQL や PostgreSQL などの RDBMS に対応しています。
開発の初期段階ではこれらの RDBMS を繋ぎこむインテグレーションテストの際はクラウド上に立てたインスタンスを使用していましたが、インスタンスのコストがかかることや、テストを実行する際に開発者が各々の環境で認証情報などをセットアップする必要があり、負担がありました。

解決策

上記の課題を解決する手段を探していたところ、TestContainers というフレームワークを見つけました。

https://testcontainers.com/

TestContainers は、Docker コンテナを使用したインテグレーションテストを実現するためのフレームワークです。

テストコード上に直接コンテナの定義を記述することで、テストがコンテナに依存してることを表現し、テスト実行時のコンテナの起動・停止は自動で行われるので、開発者はテスト実行時のコンテナの管理を意識する必要がなくなります。

TestContainers は様々な言語に対応しており、Scala に対応したライブラリとして testcontainers-scala があります。
今回はこの testcontainers-scala を使用して、インテグレーションテストを実装する方法についてご紹介します。

https://github.com/testcontainers/testcontainers-scala

testcontainers-scala の使い方

前提条件

今回の記事は以下の環境を前提としています。

  • マシン: Macbook Pro (M1, 2020)
  • OS: macOS Ventura (13)
  • Docker: OrbStack 1.2.0
  • Java: zulu-11.68.17
  • ビルドツール: sbt 1.9.7
  • Scala: 2.13.12

ライブラリ依存の追加

build.sbt に testcontainers-scala のライブラリ依存を追加します。

lazy val my_project = (project in file("my_project"))
  .settings(
    libraryDependencies ++= Seq(
      "org.scalatest" %% "scalatest" % "3.2.17" % Test,
      "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.41.0" % Test,
    )
  )

testcontainers-scala は一部のテストフレームワークのインテグレーションを提供しており、Reckoner では scalatest を使用しているため今回は testcontainers-scala-scalatest を使用します。

テストコードの準備

my_project/src/test/scala に適当なテストコードを作成します。

import org.scalatest.funsuite.AnyFunSuite

class ExampleSpec extends AnyFunSuite {

  test("TODO") {
    ???
  }
}

次に TestContainers を使う準備をします。
ExampleSpecTestContainerForAll トレイトをミックスインします。

import com.dimafeng.testcontainers.ContainerDef
import com.dimafeng.testcontainers.scalatest.TestContainersForAll
import org.scalatest.funsuite.AnyFunSuite

class ExampleSpec extends AnyFunSuite with TestContainersForAll {

  type Containers = ???

  def startContainers(): Containers = ???

  test("TODO") {
    ???
  }
}

TestContainerForAll をミックスインすることで、テストスイートの開始時にコンテナを起動時、終了時にコンテナを停止することができます。
次はテスト実行時に起動するコンテナの定義を書いていきます。

単体のコンテナの起動

今回は nginx コンテナを起動して、HTTP リクエストを送信するテストを実装します。
完成形は以下のようになります。

import java.net.http.HttpClient
import java.net.http.HttpResponse.BodyHandlers

import com.dimafeng.testcontainers.GenericContainer
import com.dimafeng.testcontainers.scalatest.TestContainersForAll
import org.scalatest.funsuite.AnyFunSuite
import org.testcontainers.containers.wait.strategy.Wait

class ExampleSpec extends AnyFunSuite with TestContainersForAll {

  type Containers = GenericContainer

  // テストスイートの実行時に nginx コンテナが起動するように設定
  def startContainers(): Containers = {
    val container = GenericContainer(
      dockerImage = "nginx:latest",
      exposedPorts = Seq(80),                             // コンテナ側のポート番号
      waitStrategy = Wait.forHttp("/").forStatusCode(200) // エンドポイントが 200 を返すまで待機
    )
    container.start() // コンテナを起動
    container
  }

  test("nginx コンテナに HTTP リクエストを送信できること") {
    // コンテナを使うテストは withContainers 内で実行する
    withContainers { case nginx =>
      val httpClient = HttpClient.newHttpClient()
      val request = java.net.http.HttpRequest
        .newBuilder()
        // `host` メソッドで対象コンテナのホスト名を取得
        // `mappedPort` メソッドで対象コンテナのポート番号を取得
        .uri(java.net.URI.create(s"http://${nginx.host}:${nginx.mappedPort(80)}/"))
        .build()
      val response = httpClient.send(request, BodyHandlers.ofString())
      assert(response.body() contains "<title>Welcome to nginx!</title>")
    }
  }
}

startContainers メソッドにはコンテナの定義を記述します。
GenericContainer を使用して nginx コンテナの定義を追加します。[1]
waitStrategy はコンテナが起動してからサービスを利用できるまで待機するための設定です。
今回は nginx が起動してから HTTP リクエストを受け付けられるようになるまで待機するように設定しています。[2]

テストコードで起動したコンテナを利用する場合は withContainers メソッド内でテストを実装します。
withContainers メソッドの高階関数の引数から、起動したコンテナの情報にアクセスすることが可能です。
nginx.host ではコンテナのホスト名、nginx.mappedPort(80) ではコンテナのポート番号を取得しています。

テストを実行するとコンテナが起動し、nginx に HTTP リクエストを送信してレスポンスが得られることを確認できます。

20:07:12.362 [pool-92-thread-5] INFO  tc.nginx:latest - Creating container for image: nginx:latest
20:07:12.417 [pool-92-thread-5] INFO  tc.nginx:latest - Container nginx:latest is starting: 0526a4a0bd818cf43bfc88fa6f6f8e7bf1e21730ad8b78002f600aa3fdf2e468
20:07:12.868 [pool-92-thread-5] INFO  tc.nginx:latest - Container nginx:latest started in PT0.50651S
20:07:13.133 [pool-92-thread-5] DEBUG o.t.utility.ResourceReaper - Removed container and associated volume(s): nginx:latest
[info] ExampleSpec:
[info] - nginx コンテナに HTTP リクエストを送信できること
[info] Run completed in 6 seconds, 891 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

モジュールを使用したコンテナの起動

MySQL や PostgreSQL といった RDBMS のコンテナを起動する際は、testcontainers-scala が提供しているモジュールライブラリを使うとより便利です。

https://github.com/testcontainers/testcontainers-scala/blob/master/docs/src/main/tut/setup.md#modules

testcontainers-scala-postgresql を使用して PostgreSQL コンテナを使うとします。
build.sbt にライブラリ依存を追加します。

lazy val my_project = (project in file("my_project"))
  .settings(
    libraryDependencies ++= Seq(
      "org.scalatest" %% "scalatest" % "3.2.17" % Test,
      "com.dimafeng" %% "testcontainers-scala-scalatest"  % "0.41.0" % Test,
      "com.dimafeng" %% "testcontainers-scala-postgresql" % "0.41.0" % Test,
    )
  )

テストコードは以下のように実装します。

import com.dimafeng.testcontainers.PostgreSQLContainer
import com.dimafeng.testcontainers.scalatest.TestContainersForAll
import org.scalatest.funsuite.AnyFunSuite
import org.testcontainers.utility.DockerImageName

class ExampleSpec extends AnyFunSuite with TestContainersForAll {

  type Containers = PostgreSQLContainer

  def startContainers(): Containers = {
    val postgres = PostgreSQLContainer(dockerImageNameOverride = DockerImageName.parse("postgres:15"))
    // データベースの初期化スクリプトを実行
    postgres.container.withInitScript("init_it.sql")
    postgres.container.start()
    postgres
  }

  test("postgres") {
    withContainers { case postgres =>
      println(s"JDBC URL: ${postgres.jdbcUrl}")
      println(s"Username: ${postgres.username}")
      println(s"Password: ${postgres.password}")

      // 実際にはここで DB にアクセスしてテスト実行
    }
  }
}

PostgreSQLContainer を使用して PostgreSQL コンテナの定義を追加します。
データベースを使ったテストをする際にはテストデータなどが必要になることが多いです。
TestContainers では withInitScript メソッドを使って、コンテナ起動時に実行する初期化スクリプトを指定することができます。[3]
my_project/src/test/resources/init_it.sql に初期化スクリプトを配置します。

create table employee
(
    id         bigint primary key,
    name       varchar(255) not null,
    section_id bigint       not null
);

insert into employee
values (1, 'aaa', 100),
       (2, 'bbb', 100),
       (3, 'ccc', 200),
       (4, 'ddd', 200),
       (5, 'eee', 300),
       (6, 'fff', 300);

PostgreSQL にアクセスするための JDBC URL や認証情報は postgres.jdbcUrlpostgres.username などのプロパティから取得できます。
テストを実行すると以下のようにコンテナが起動し、初期化スクリプトが実行され、TestContainers が設定した JDBC URL や認証情報が表示されます。

21:21:18.930 [pool-100-thread-1] INFO  tc.postgres:15 - Container postgres:15 started in PT16.299357S
21:21:18.932 [pool-100-thread-1] INFO  tc.postgres:15 - Container is started (JDBC URL: jdbc:postgresql://localhost:32777/test?loggerLevel=OFF)
21:21:18.954 [pool-100-thread-1] INFO  org.testcontainers.ext.ScriptUtils - Executing database script from init_it.sql
21:21:18.971 [pool-100-thread-1] DEBUG tc.postgres:15 - Trying to create JDBC connection using org.postgresql.Driver to jdbc:postgresql://localhost:32777/test?loggerLevel=OFF with properties: {password=test, user=test}
21:21:19.150 [pool-100-thread-1] DEBUG o.t.jdbc.JdbcDatabaseDelegate - false returned as updateCount for SQL: create table employee ( id bigint primary key, name varchar(255) not null, section_id bigint not null )
21:21:19.151 [pool-100-thread-1] DEBUG o.t.jdbc.JdbcDatabaseDelegate - false returned as updateCount for SQL: insert into employee values (1, 'aaa', 100), (2, 'bbb', 100), (3, 'ccc', 200), (4, 'ddd', 200), (5, 'eee', 300), (6, 'fff', 300)
21:21:19.157 [pool-100-thread-1] INFO  org.testcontainers.ext.ScriptUtils - Executed database script from init_it.sql in 203 ms.

...

JDBC URL: jdbc:postgresql://localhost:32777/test?loggerLevel=OFF
Username: test
Password: test

テスト実行中のデータベースを確認すると以下のようにテーブルが作成されています。

img.png

まとめ

もう少し踏み込んだ内容について書こうと思いましたが、アドベントカレンダーの期日に間に合わそうなのでここまで…みなさんは記事を書くのは計画的に…

TestContainers を使用することで、開発者はインテグレーションテストのためのセットアップに時間をかけることなく実行することができるようになりました。
また、実際にクラウド上にインスタンスを立てる必要もなくなったので、テストに使用していたインスタンスは停止し、コスト削減にもつながりました。

TestContainers を使って快適なインテグレーションテストを実現しましょう!

おわりに

Reckoner では Scala エンジニアを募集しています!

株式会社スリーシェイクの採用情報はこちらをご覧ください。

https://jobs-3-shake.com/

https://hrmos.co/pages/threeshake/jobs?category=1661609359626186756

脚注
  1. testcontainers-scala のドキュメント内 では GenericContaier.Def を使ったコードが紹介されていますが、GenericContainer を直に使うほうがラップしているライブラリである TestContainers Java の高度な API を使いやすいためこの形にしています。 ↩︎

  2. その他の waitStrategy については TestContainers Java のドキュメント を参照してください。 ↩︎

  3. postgres.container で TestContainers Java の API を呼び出しています。私の知る限り testcontainers-scala の v0.41.0 時点では初期化スクリプトを指定するメソッドが用意されていないためこのような形になっています。 ↩︎

GitHubで編集を提案

Discussion