Scala Advent Calendar 2023 の 8 日目を担当します aoyagi です。
今回のサンプルは 以前の記事 のテストコード部分となります。
解説よりまずコードが見たい、という方は以下リポジトリの src/test/scala/com/example/secondary
を参照してください。
2024年12月12日更新
タイムリーなニュースですが本記事を投稿した3日後に Testcontainers の開発元である AtomicJar が Docker に買収されたという発表がありました。
- AtomicJar Blog - AtomicJar is now part of Docker!
- Docker Blog - Docker whale-comes AtomicJar, maker of Testcontainers
Docker 側のブログに簡単な FAQ がありますがライセンス変更等の予定はないそうです。
はじめに
Testcontainers
は様々なプログラミング言語で利用できる非常に有用なライブラリです。
Scala
や ZIO Test
はよく分からない...という方も是非ご一読の上雰囲気だけでも掴んでいただければと思います。
また、記事を通して レイヤードアーキテクチャ
との相性が良い Scala + ZIO の良さも知っていただければ幸いです。
Testcontainers との出会い
Scala3 + ZIO のプロジェクトテンプレート を使用して開発をスタートしたところ依存関係に testcontainers-scala-postgresql
なるものを見つけ、containers の文字列にインフラエンジニアの血が騒いだものの暫く放置していました。
実装も進みレイヤードアーキテクチャの抽象化の恩恵でテストが気持ちよく書けるようになったものの、インフラストラクチャ層のテストが辛い...とモヤモヤしていた折にふと思い出して使ってみたところ、積年の悩みが一発で解消され生産性も爆上がり(当人比)し今では快適なテストライフを送ることができています。
そもそも何が辛かったのか
アプリケーション開発においてデータストア(データリポジトリ)との連携は欠かせません。
データアクセス層、パーシステンス層、インフラストラクチャ層などアーキテクチャパターンによって呼び方こそ異なりますが、アプリケーション外部へのデータの保管と操作を行うレイヤーは様々なアプリケーションで存在しほぼ必須の要件であると思います。
データの保管先も RDBMS や KVS(Key Value Store) など各種あるのが当たり前の昨今では、異なるデータストアを併用しているケースも多々あるのではないでしょうか。
ORM(OR マッパー)でインピーダンス・ミスマッチを解消し型安全性を担保しても、データストアの実体がなければアプリケーションは機能せず「実際に動くのか...?」という不安はつきまといます。
テストはしたいが環境の構築待ちで業を煮やし本来担当ではないインフラ構築の領域にまで足を踏み入れた、というエンジニアも数知れずいるのではないかと思います(実体験)。
過去には SQLite
や H2
といった軽量データベースをアプリケーションに組み込んで代用するというケースも見受けられましたが、実環境とは異なるソリューションのため独自構文や独自関数までは表現できなかったり、接続のためのドライバも切り替える必要があったりと辛みは残りました。
近年(といってももうかなり経ちましたが)Docker
の登場によりコンテナ時代が到来し、軽量として扱われなかったデータストア達もローカルで気軽に扱えるようになりエンジニアの開発体験は格段に向上しました。
私の過去の記事 1 2 では Skaffold と Kubernetes を使って本番と同等レベルのコンテナ環境をローカルに瞬時に立ち上げる方法を紹介しましたが、それでもアプリケーションのテストにおいては以下のような効率の悪さを日々感じていました。
- テストの前に環境を起動しておく必要がある
- テスト対象以外の全てのテーブルが作成される(ので変更検知時のコンテナのリビルドが遅い)
- テスト前にどのデータストアを起動するか選別していたが結局面倒になり全て起動しリソースを食う
アプリケーションとデータストアの連携テストにおいて、UT と ITA の中間くらいのピンポイントな結合テストができないことに Too much(過剰) を感じていました。
テストコマンドを実行した時だけ最小限のコンポーネントを持ったコンテナが起動し、テストが終わればコンテナも終了してくれれば...という要望に応えたライブラリ、それが Testcontainers です。
Testcontainers とは
私が調べるより Chat-GPT の方が詳しいので聞いておきました。
- Dockerコンテナの自動起動と停止: テストの前後にDockerコンテナを自動的に起動および停止します。これにより、テストが実行されるたびに同じ環境が再現されます。
- 複数のプログラミング言語のサポート: Testcontainersは主にJavaで使用されますが、他のプログラミング言語にもバインディングがあります。これにより、異なるプロジェクトやチームで共通の統合テストの基盤として使用できます。
- 豊富な統合: Testcontainersは、多くのデータベース(MySQL、PostgreSQL、MongoDBなど)やメッセージブローカー(RabbitMQ、Kafkaなど)など、様々な種類のコンテナをサポートしています。
- 拡張性とカスタマイズ: Testcontainersは拡張可能であり、独自のカスタムコンテナを作成することもできます。これにより、特定のアプリケーションやサービスに合わせてテスト環境を構築できます。
公式のトップページにあるように、 Java
Go
.NET
Node.js
Python
Rust
Haskell
Ruby
Clojure
Elixir
といった多種多様なプログラミング言語で使用でき、もちろん Java の親戚である Scala
や Kotlin
でも利用できます(アイコンが並んでいないのが少し残念ですが...)。
Testcontainers の環境構成
Testcontainers の実行には基本的には Docker 環境を使用しますが、必ずしも必須というわけではなく、 Testcontainers Desktop
を使用すればコンテナランタイムの切り替え3や Testcontainers Cloud
へ接続してクラウド上で実行することもできます。
無料枠もあるようなのでご興味のある方は Pricing を参照してください。
使い方は至って簡単で、各種プログラミング言語の依存関係に Testcontainers ライブラリを追加し、起動したいコンテナを立ち上げるための簡単なコードを書くだけです。
ZIO Test と Testcontainers の連携
以降は Testcontainers for Java のラッパーである Testcontainer for Scala に関する説明となりますので、他の言語をご利用の方は公式ページのそれぞれのガイドを参照してください。
Scala で Testcontainers を利用する場合は build.sbt
に以下の依存関係を追加します。
今回利用するデータベースのコンテナは PostgreSQL とします。
libraryDependencies += "com.dimafeng" %% "testcontainers-scala-postgresql" % "0.41.0" % Test
Testcontainers は実行する際にフェールセーフに環境をクリーンアップする testcontainers/ryuk
というコンテナが Docker Hub
から Pull されサイドカーコンテナとして動作します。
Ryuk はテストが異常終了した場合でも確実にクリーンアップすることを保証しますが、 Docker のボリュームやネットワークなどのリソースを削除するという性質上、環境によっては特権モードで実行する必要があります。
そのため、必要に応じて ryuk.container.privileged
に true
を設定します。
Java の場合は resources フォルダに .testcontainers.properties
を配置し下記のようにデフォルトの構成を上書きします。
ryuk.container.privileged=true
コンテナの特権モードはホストカーネルにアクセスできてしまうため無闇に使用することは避けるべきですが、 Testcontainers で Pull できるイメージがライブラリのモジュールに依存するためセキュリティ上問題となる可能性は低いとは思います。
とはいえ Ryuk 自体が利用できなかったり自動のクリーンアップが既に組み込まれているケースもあるかもしれません。
その場合は環境変数 TESTCONTAINERS_RYUK_DISABLED
に true
を設定することで 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/
配下に配置します。
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
}
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)
は以下の通りです。
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
に該当します。
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 ではそれぞれのレイヤーの依存関係を以下のように表現します。
object CharactersRepositoryLive {
val layer: URLayer[Quill.Postgres[SnakeCase], CharactersRepository] = ZLayer {...}
}
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
に追加します。
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
の レイヤーを追加するだけです。
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
}
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 test
と sbt Test/run
の2通りの実行方法がありますが、後者の方がプロンプト上でテスト対象を選ぶことができるのでオススメです。
テストケースごとに細かく実行したい場合は sbt test XXX -- -t \"bar\"
を使うとさらにテスト対象を絞り込むことができます。
以下では Live
と Mock
をそれぞれのパターンで実行しています。
Live
では PostgreSQL コンテナが起動し 3 秒ほどでテストが終了しました。
テストの中では PostgreSQL のコンテナに CREATE
文が流れ、 CharactersRepositoryLive
のメソッドにより INSERT
や DELETE
といった各種データ操作が実際に行われています。
(上のサンプルよりテストケースは増やしています。詳細は冒頭の 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 テストなど紹介しきれていない機能もありますが、この記事が少しでも皆様のテスト体験の向上に繋がれば幸いです。
-
コンテナランタイムには Docker API との互換性が必要です。ランタイムの要件についてはhttps://java.testcontainers.org/supported_docker_environment/ を参照してください。 ↩