LoginSignup
2
0

Testcontainers + ZIO Test によるテスタブルな開発で生産性を爆上げする

Last updated at Posted at 2023-12-07

Scala Advent Calendar 2023 の 8 日目を担当します aoyagi です。

今回のサンプルは 以前の記事 のテストコード部分となります。
解説よりまずコードが見たい、という方は以下リポジトリの src/test/scala/com/example/secondary を参照してください。

2024年12月12日更新
タイムリーなニュースですが本記事を投稿した3日後に Testcontainers の開発元である AtomicJar が Docker に買収されたという発表がありました。

Docker 側のブログに簡単な FAQ がありますがライセンス変更等の予定はないそうです。

はじめに

Testcontainers は様々なプログラミング言語で利用できる非常に有用なライブラリです。
ScalaZIO Test はよく分からない...という方も是非ご一読の上雰囲気だけでも掴んでいただければと思います。
また、記事を通して レイヤードアーキテクチャ との相性が良い Scala + ZIO の良さも知っていただければ幸いです。

Testcontainers との出会い

Scala3 + ZIO のプロジェクトテンプレート を使用して開発をスタートしたところ依存関係に testcontainers-scala-postgresql なるものを見つけ、containers の文字列にインフラエンジニアの血が騒いだものの暫く放置していました。

実装も進みレイヤードアーキテクチャの抽象化の恩恵でテストが気持ちよく書けるようになったものの、インフラストラクチャ層のテストが辛い...とモヤモヤしていた折にふと思い出して使ってみたところ、積年の悩みが一発で解消され生産性も爆上がり(当人比)し今では快適なテストライフを送ることができています。

そもそも何が辛かったのか

アプリケーション開発においてデータストア(データリポジトリ)との連携は欠かせません。
データアクセス層、パーシステンス層、インフラストラクチャ層などアーキテクチャパターンによって呼び方こそ異なりますが、アプリケーション外部へのデータの保管と操作を行うレイヤーは様々なアプリケーションで存在しほぼ必須の要件であると思います。

データの保管先も RDBMS や KVS(Key Value Store) など各種あるのが当たり前の昨今では、異なるデータストアを併用しているケースも多々あるのではないでしょうか。
ORM(OR マッパー)でインピーダンス・ミスマッチを解消し型安全性を担保しても、データストアの実体がなければアプリケーションは機能せず「実際に動くのか...?」という不安はつきまといます。
テストはしたいが環境の構築待ちで業を煮やし本来担当ではないインフラ構築の領域にまで足を踏み入れた、というエンジニアも数知れずいるのではないかと思います(実体験)。

過去には SQLiteH2 といった軽量データベースをアプリケーションに組み込んで代用するというケースも見受けられましたが、実環境とは異なるソリューションのため独自構文や独自関数までは表現できなかったり、接続のためのドライバも切り替える必要があったりと辛みは残りました。

近年(といってももうかなり経ちましたが)Docker の登場によりコンテナ時代が到来し、軽量として扱われなかったデータストア達もローカルで気軽に扱えるようになりエンジニアの開発体験は格段に向上しました。
私の過去の記事 1 2 では SkaffoldKubernetes を使って本番と同等レベルのコンテナ環境をローカルに瞬時に立ち上げる方法を紹介しましたが、それでもアプリケーションのテストにおいては以下のような効率の悪さを日々感じていました。

  • テストの前に環境を起動しておく必要がある
  • テスト対象以外の全てのテーブルが作成される(ので変更検知時のコンテナのリビルドが遅い)
  • テスト前にどのデータストアを起動するか選別していたが結局面倒になり全て起動しリソースを食う

アプリケーションとデータストアの連携テストにおいて、UT と ITA の中間くらいのピンポイントな結合テストができないことに Too much(過剰) を感じていました。
テストコマンドを実行した時だけ最小限のコンポーネントを持ったコンテナが起動し、テストが終わればコンテナも終了してくれれば...という要望に応えたライブラリ、それが Testcontainers です。

Testcontainers とは

私が調べるより Chat-GPT の方が詳しいので聞いておきました。

  1. Dockerコンテナの自動起動と停止: テストの前後にDockerコンテナを自動的に起動および停止します。これにより、テストが実行されるたびに同じ環境が再現されます。
  2. 複数のプログラミング言語のサポート: Testcontainersは主にJavaで使用されますが、他のプログラミング言語にもバインディングがあります。これにより、異なるプロジェクトやチームで共通の統合テストの基盤として使用できます。
  3. 豊富な統合: Testcontainersは、多くのデータベース(MySQL、PostgreSQL、MongoDBなど)やメッセージブローカー(RabbitMQ、Kafkaなど)など、様々な種類のコンテナをサポートしています。
  4. 拡張性とカスタマイズ: Testcontainersは拡張可能であり、独自のカスタムコンテナを作成することもできます。これにより、特定のアプリケーションやサービスに合わせてテスト環境を構築できます。

公式のトップページにあるように、 Java Go .NET Node.js Python Rust Haskell Ruby Clojure Elixir といった多種多様なプログラミング言語で使用でき、もちろん Java の親戚である ScalaKotlin でも利用できます(アイコンが並んでいないのが少し残念ですが...)。

Testcontainers の環境構成

Testcontainers の実行には基本的には Docker 環境を使用しますが、必ずしも必須というわけではなく、 Testcontainers Desktop を使用すればコンテナランタイムの切り替え3Testcontainers Cloud へ接続してクラウド上で実行することもできます。
無料枠もあるようなのでご興味のある方は Pricing を参照してください。

使い方は至って簡単で、各種プログラミング言語の依存関係に Testcontainers ライブラリを追加し、起動したいコンテナを立ち上げるための簡単なコードを書くだけです。

ZIO Test と Testcontainers の連携

以降は Testcontainers for Java のラッパーである Testcontainer for Scala に関する説明となりますので、他の言語をご利用の方は公式ページのそれぞれのガイドを参照してください。

Scala で Testcontainers を利用する場合は build.sbt に以下の依存関係を追加します。
今回利用するデータベースのコンテナは PostgreSQL とします。

build.sbt
libraryDependencies += "com.dimafeng" %% "testcontainers-scala-postgresql" % "0.41.0" % Test

Testcontainers は実行する際にフェールセーフに環境をクリーンアップする testcontainers/ryuk というコンテナが Docker Hub から Pull されサイドカーコンテナとして動作します。
Ryuk はテストが異常終了した場合でも確実にクリーンアップすることを保証しますが、 Docker のボリュームやネットワークなどのリソースを削除するという性質上、環境によっては特権モードで実行する必要があります。
そのため、必要に応じて ryuk.container.privilegedtrue を設定します。
Java の場合は resources フォルダに .testcontainers.properties を配置し下記のようにデフォルトの構成を上書きします。

src/test/resources/.testcontainers.properties
ryuk.container.privileged=true

コンテナの特権モードはホストカーネルにアクセスできてしまうため無闇に使用することは避けるべきですが、 Testcontainers で Pull できるイメージがライブラリのモジュールに依存するためセキュリティ上問題となる可能性は低いとは思います。
とはいえ Ryuk 自体が利用できなかったり自動のクリーンアップが既に組み込まれているケースもあるかもしれません。
その場合は環境変数 TESTCONTAINERS_RYUK_DISABLEDtrue を設定することで Ryuk を無効化することができます。

※ ちなみに私の環境は前述した通り少し特殊なこともあり Ryuk を無効にしないと動作しませんでした。私のように tc.testcontainers/ryuk:0.5.1: Could not start container が発生し Ryuk の起動でプロセスが止まってしまった場合は強制終了後に docker stop $(docker ps -q) でコンテナを削除し、 Ryuk を無効化するよう環境変数を設定してください。

その他の構成のカスタマイズについては公式ドキュメントを参照してください。

以降ではテストコードに依存関係を注入( provide )する Testcontainers のデータソースを ZIO で定義しています。
zio-scala3-quickstart.g8 プロジェクトテンプレートの通りですが、ZLayer[PostgreSQLContainer, Nothing, DataSourceBuilder] が示す通り、 PostgreSQLContainer を使用して DatasourceBuilder を生成するレイヤーを実装しています。
ZIO.acquireRelease ではリソースの安全な取得と解放を表現しています。
C# でいうところの using に近いですが、より柔軟性がありかつ非同期で実行されます)
"schema.sql" は PostgreSQL コンテナで実行したい SQL ファイルで properties ファイルと同様 src/test/resources/ 配下に配置します。

PostgresContainer.scala
import com.dimafeng.testcontainers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName
import zio._

object PostgresContainer:

  def make(
      imageName: String = "postgres:alpine"
    ) =
    ZIO.acquireRelease {
      ZIO.attempt {
        val c = new PostgreSQLContainer(
          dockerImageNameOverride = Option(imageName).map(DockerImageName.parse)
        ).configure { a =>
          a.withInitScript("schema.sql")
          ()
        }
        c.start()
        c
      }
    } { container =>
      ZIO.attempt(container.stop()).orDie
    }
DataSourceBuilder.scala
import java.sql.{ Connection, DriverManager, SQLException }
import java.util.Properties
import javax.sql.DataSource

import com.dimafeng.testcontainers.PostgreSQLContainer
import io.getquill.context.ZioJdbc.DataSourceLayer
import org.postgresql.ds.PGSimpleDataSource
import zio._

trait DataSourceBuilder:
  def dataSource: DataSource

final class DataSourceBuilderLive(
    container: PostgreSQLContainer
  ) extends DataSourceBuilder:

  val dataSource: DataSource =
    val ds = new PGSimpleDataSource()
    ds.setUrl(container.jdbcUrl)
    ds.setUser(container.username)
    ds.setPassword(container.password)
    ds

object DataSourceBuilderLive:
  val layer: ZLayer[PostgreSQLContainer, Nothing, DataSourceBuilder] =
    ZLayer(
      ZIO.service[PostgreSQLContainer].map(container => DataSourceBuilderLive(container))
    )

Scala + ZIO によるインフラストラクチャ層の実装

以下のようなテーブルのデータを操作することを考えます。

前述の PostgreSQL コンテナに流す SQL(DDL) は以下の通りです。

src/test/resources/schema.sql
CREATE TABLE IF NOT EXISTS character (
    character_id TEXT PRIMARY KEY,
    character_name TEXT NOT NULL,
    nicknames TEXT[],
    origin_type TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS role (
    character_id TEXT PRIMARY KEY REFERENCES character(character_id) ON DELETE CASCADE,
    role_type TEXT NOT NULL,
    ship_name TEXT NOT NULL
);

テスト対象のインターフェースである CharactersRepository は、ヘキサゴナルアーキテクチャの Ports に該当します。

src/main/scala/com/example/ports/secondary/CharactersRepository.scala
trait CharactersRepository {
  def add(data: Character): IO[SecondaryError, CharacterId]
  def delete(id: CharacterId): IO[SecondaryError, Long]
  def getAll(): IO[SecondaryError, List[Character]]
  def filter(origin: Origin): IO[SecondaryError, List[Character]]
  def getById(id: CharacterId): IO[SecondaryError, Option[Character]]
  def update(id: CharacterId, data: Character): IO[SecondaryError, Option[Unit]]
}

実装は Adapters に以下の2つのサービスを用意しています。

サービス名 説明
CharactersRepositoryLive DB と接続する本番用サービス
CharactersRepositoryMock インメモリでデータを操作する Mock サービス(初期データあり)

Live は PostgreSQL と接続するための依存関係を持ちますが、 Mock は単体でも動作するので依存関係を持ちません。
ZIO ではそれぞれのレイヤーの依存関係を以下のように表現します。

CharactersRepositoryLive.scala
object CharactersRepositoryLive {
  val layer: URLayer[Quill.Postgres[SnakeCase], CharactersRepository] = ZLayer {...}
}
CharactersRepositoryMock.scala
object CharactersRepositoryMock {
  val layer: ULayer[CharactersRepository] = ZLayer {...}
}

少しとっつきにくく感じるかも知れませんがこの定義は後述するテストコードの実装で力を発揮します。
ORM に zio-protoquill を使用しているため Quill.Postgres[SnakeCase] が接続のために依存するオブジェクトになります。
Quill ではテーブル定義に一致する case class を用意すれば Scala のコードで簡単にデータ操作できます。なお、実際の DB スキーマから case class を自動生成する Code Generator もあります(但し Scala2.13 です)。

// 抜粋&簡略化したコードです
import io.getquill._

case class Character(characterId: String, characterName: String, nicknames: List[String], originType: String)
case class Role(characterId: String, roleType: String, shipName: String)

def getAll(): IO[SecondaryError, List[Character]] = run {
    quote {
      query[Character]
        .leftJoin(query[Role])
        .on((c, r) => c.characterId == r.characterId)
        .map((c, r) => c)
    }}
    .refineOrDie {
      case e: SQLException => RepositoryError(e)
    }
}

ZIO Test によるテストコードの記述と実行

全ての下準備が終わったのでテストコードを書いていきます。
ZIO のテストフレームワークである ZIO Test を build.sbt に追加します。

build.sbt
libraryDependencies ++= Seq(
  "dev.zio" %% "zio-test"     % zioVersion % Test,
  "dev.zio" %% "zio-test-sbt" % zioVersion % Test,
)
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")

ZIO Test では ZIOSpecDefault を継承したテストオブジェクトを作成し、 override def spec にテストスイートを記述します。
依存関係の注入は provideShared メソッドで行いますが、いくつか種類があるので気になる方は公式ドキュメントを参照してください。
先ほどのインフラストラクチャ層で定義した通りの依存性を注入しないとコンパイルは通りません。
CharactersRepository において、 Live では Quill.Postgres.fromNamingStrategy(SnakeCase) の注入が求められますが、 Mock は不要です。
また、 Testcontainers の起動は PostgresContainer DataSourceBuilderレイヤーを追加するだけです。

CharactersRepositoryLiveSpec.scala
import com.example.application.models.CharactersData._
import com.example.ports.secondary.CharactersRepository
import com.example.adapters.secondary.datastore.postgresql.CharactersRepositoryLive

import io.getquill._
import io.getquill.jdbczio.Quill

import zio.test._
import zio.test.Assertion._
import zio.test.TestAspect._
import zio._

import postgresql._

object CharactersRepositoryLiveSpec extends ZIOSpecDefault {

  val containerLayer  = ZLayer.scoped(PostgresContainer.make())
  val dataSourceLayer = ZLayer(ZIO.service[DataSourceBuilder].map(_.dataSource))
  val postgresLayer   = Quill.Postgres.fromNamingStrategy(SnakeCase)
  val repoLayer       = CharactersRepositoryLive.layer

  override def spec =
    suite("character repository test with postgres test container")(

      test("save characters returns their ids") {
        for {
          id1 <- CharactersRepository.add(
            Character(
              CharacterId("100000"),
              "James Holden",
              List("Jim", "Hoss"),
              Origin.EARTH,
              Some(Role.Captain("Rocinante")))
          )
          id2 <- CharactersRepository.add(
            Character(
              CharacterId("200000"),
              "Naomi Nagata",
              Nil,
              Origin.BELT,
              Some(Role.Engineer("Rocinante")))
          )
          id3 <- CharactersRepository.add(
            Character(
              CharacterId("300000"),
              "Amos Burton",
              Nil,
              Origin.EARTH,
              Some(Role.Mechanic("Rocinante")))
          )
        } yield assert(id1)(equalTo(CharacterId("100000")))
        && assert(id2)(equalTo(CharacterId("200000")))
        && assert(id3)(equalTo(CharacterId("300000")))
      },

      test("get all returns 3 characters") {
        for {
          list <- CharactersRepository.getAll()
        } yield assert(list)(hasSize(equalTo(3)))
      },
      
    ).provideShared(
      containerLayer,
      DataSourceBuilderLive.layer,
      dataSourceLayer,
      postgresLayer,
      repoLayer,
    ) @@ sequential

}
CharactersRepositoryMockSpec.scala
import com.example.application.models.CharactersData._
import com.example.ports.secondary.CharactersRepository
import com.example.adapters.secondary.datastore.postgresql.CharactersRepositoryMock

import zio.test._
import zio.test.Assertion._
import zio.test.TestAspect._
import zio._

object CharactersRepositoryMockSpec extends ZIOSpecDefault {

  val repoLayer = CharactersRepositoryMock.layer

  override def spec =
    suite("character mock repository test")(

      test("save characters returns their ids") {
        for {
          id1 <- CharactersRepository.add(
            Character(
              CharacterId("800000"),
              "Kuroyagi san",
              List("kuro", "KURO"),
              Origin.EARTH,
              Some(Role.Engineer("GOAT")))
          )
          id2 <- CharactersRepository.add(
            Character(
              CharacterId("900000"),
              "Shiroyagi san",
              Nil,
              Origin.BELT,
              Some(Role.Engineer("GOAT")))
          )
        } yield assert(id1)(equalTo(CharacterId("800000")))
        && assert(id2)(equalTo(CharacterId("900000")))
      },

      test("get all returns 3 characters") {
        for {
          list <- CharactersRepository.getAll()
        } yield assert(list)(hasSize(equalTo(9)))
      },

    ).provideShared(
      repoLayer,
    ) @@ sequential

}

いよいよテストの実行です。
ZIO Test には sbt testsbt Test/run の2通りの実行方法がありますが、後者の方がプロンプト上でテスト対象を選ぶことができるのでオススメです。
テストケースごとに細かく実行したい場合は sbt test XXX -- -t \"bar\" を使うとさらにテスト対象を絞り込むことができます。
以下では LiveMock をそれぞれのパターンで実行しています。
Live では PostgreSQL コンテナが起動し 3 秒ほどでテストが終了しました。
テストの中では PostgreSQL のコンテナに CREATE 文が流れ、 CharactersRepositoryLive のメソッドにより INSERTDELETE といった各種データ操作が実際に行われています。
(上のサンプルよりテストケースは増やしています。詳細は冒頭の GitHub リポジトリを参照してください)

# sbt test 全てのテストを実行
# sbt testOnly 任意のテストを実行
# 末尾に -- -t \"bar\" をつけることで特定のラベル(文字列)を含むテストケースのみを実行可能
$ sbt testOnly com.example.secondary.CharactersRepositoryLiveSpec

+ character repository test with postgres test container
  + save characters returns their ids
  + get all returns 3 characters
  + delete first character
  + get character that has Origin.BELT
  + update character 3
5 tests passed. 0 tests failed. 0 tests ignored.

Executed in 2 s 848 ms

[info] Completed tests

(私は Ryuk を無効にしていますが) docker ps を実行してみても起動した PostgreSQL コンテナはきちんと終了しています。

$ docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

さて、 Mock のテストはというとコンテナが立ち上がらないので 1 秒もかかりませんでした。
先程とは違い Test/run コマンドを使っています。

# sbt Test/run プロンプトで実行したいテストを選択
# sbt Test/runMain 特定のテストを実行
$ sbt Test/run
Multiple main classes detected. Select one to run:
 [1] com.example.secondary.CharactersRepositoryLiveSpec
 [2] com.example.secondary.CharactersRepositoryMockSpec

Enter number: 2
2
[info] running (fork) com.example.secondary.CharactersRepositoryMockSpec
[info] + character mock repository test
[info]   + save characters returns their ids
[info]   + get all returns 3 characters
[info]   + delete first character
[info]   + get character Origin.BELT
[info]   + update character 3
[info] 5 tests passed. 0 tests failed. 0 tests ignored.
[info] Executed in 335 ms

今回はテストケース間でデータ操作の前後関係を持たせているため @@ sequential をつけて順次実行するようにしています。
そのため ZIO Test の真の良さをお伝えできていませんが、本来 ZIO Test はデフォルトでテストを非同期に同時実行するため非常に高速です。
インメモリでデータを保持する実装も ZIO ではサクッと作れるのですが今回の記事の趣旨とは異なるため割愛します。
テストの結果としては、テストケース通りメモリ上でデータ操作が正しく行えたことが確認できました。

おわりに

Testcontainers を用いることで手軽にデータストアとの結合テストが実現できることを紹介しました。
ローカル開発環境だけではなく CI に組み込みたい、というご要望も当然あるかと思いますが、公式ドキュメントに記載がありますので気になる方はご参照ください。
Ryuk 周りはまだ私もよく分かっておらず Mac のせいなのかリモートの Docker Engine 連携のせいなのかは現在調査中ですが、動かせるようになったら情報を共有します。

本記事は以上です。
Testcontainers にはまだまだ魅力が多く Selenium による UI テストなど紹介しきれていない機能もありますが、この記事が少しでも皆様のテスト体験の向上に繋がれば幸いです。

  1. Skaffold + MicroK8s で快適なコンテナ開発環境を構築する(WSL/Ubuntu編)

  2. Skaffold + MicroK8s で快適なコンテナ開発環境を構築する(Mac編)

  3. コンテナランタイムには Docker API との互換性が必要です。ランタイムの要件についてはhttps://java.testcontainers.org/supported_docker_environment/ を参照してください。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0