Testcontainers と sqldef を用いて Spring Boot のテストでリアルDBを使えるようにする
アルダグラムでエンジニアをしている @sukechannnn です!
最近 自作OSに入門するべく検証機として FFF-PCM2B を買ったのですが、これを触ることが目的と化してします(ちっちゃくてかわいい)。
この記事は 株式会社アルダグラム Advent Calendar 2023 9日目です。
今年の11月にリリースした新規プロダクト「KANNAレポート」のバックエンドは Kotlin + Spring Boot で開発しています。既存のサービスである「KANNAプロジェクト」とは別でサーバーを立てており、マイクロサービス構成になっています。
今回新規でサーバーを立てるに当たって、Rails + RSpec のようにリアルなDBを用いたテストを実現すべく調査したところ、Testcontainers と sqldef を使っていい感じにできたので紹介します。
要約
- sqldef を使うことで SQL の CREATE 文を使ってスキーマを定義し宣言的にDBマイグレーションを管理できる
- Testcontainer を使うことでテスト実行時にコンテナを立ち上げてテスト用DBを立てられる
- sqldef のスキーマ定義用SQLファイルを Testcontainer でも利用すると、サーバーのDBとテストDBで同じスキーマを利用できて便利!
技術構成
使っている技術構成は以下のような感じです。
- Kotlin
- Spring Boot
- jOOQ
- JUnit5
- Testcontainers
- sqldef
- PostgreSQL
特に Testcontainers と sqldef は今回のメイントピックなので簡単に紹介します。
Testcontainers
Testcontainers は Java 向けのライブラリで、JUnit のために軽量で使い捨て可能な Docker コンテナをサクッと立ち上げられます。データベース、ウェブサーバー、ブラウザなど、任意のサービスを実行しているコンテナを簡単にセットアップできます。
今回はテスト実行時に PostgreSQL のコンテナを立ち上げて、リアルDBを用いたテストをするのに利用します。
JUnit5 では、テスト用のクラスに @Testcontainers
アノテーションをつけて、static フィールドとしてコンテナを @Container
アノテーション付きで定義することで、テスト開始時に自動でコンテナを起動させることができます(Kotlin の場合は @JvmStatic
を使う)。
Spring Boot と組み合わせて利用する場合は、以下のように @DynamicPropertySource
を使って Spring の DB の繋ぎ先も一緒に変えると良いです。
@Testcontainers
@SpringBootTest
class SampleTest {
companion object {
@Container
val postgreSQLContainer: PostgreSQLContainer<Nothing> = PostgreSQLContainer<Nothing>("postgres:15")
@JvmStatic
@DynamicPropertySource
fun setProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", postgreSQLContainer::getPassword)
}
}
@Test
fun `some test`() {
...
}
sqldef
MySQL, PostgreSQL, SQLite3, SQL Server のマイグレーションをスキーマベースで行える Go 製のツールです。Ruby で作られた Ridgepole とだいたい同じことができるのですが、sqldef は SQL の CREATE 文を用いてスキーマを定義することができます。
簡単に動作を説明すると(README の通りですが)、
以下のようなSQLを schema.sql のようなファイルに書き、それを sqldef に渡すと users テーブルを作ってくれます。
CREATE TABLE public.users (
id bigint NOT NULL,
name text,
age integer
);
users テーブルに role カラムを追加したくなったら、schema.sql の CREATE 文に書き足してマイグレーションを実行すると、差分を見てカラムを追加してくれます。
CREATE TABLE public.users (
id bigint NOT NULL,
name text,
age integer,
+ role integer
);
カラムを削除する場合も同様です。
以下の例では、age を削除していますが、この状態でマイグレーションを実行すると age カラムが消えます。
CREATE TABLE public.users (
id bigint NOT NULL,
name text,
- age integer,
role integer
);
このように、宣言的にDBマイグレーションをすることができます。
Testcontainers と sqldef を組み合わせる
先ほど説明した Testcontainers の設定で自動でポスグレコンテナを立ち上げることはできるようになりましたが、DBが立ち上がっただけでテーブルなどは何も作られていません。せっかくならコンテナが立ち上がった時に一緒にDBマイグレーションもしたいですね。
ここで、sqldef で使っているマイグレーション用の SQL ファイル db/schema.sql を利用します。
@Container
付きで定義するコンテナ定義には一緒にデータベース名や username などを渡せるのですが、そのオプションの中に withInitScript
があり、初期化するための SQL を渡すことができます。この withInitScript
に db/schema.sql を渡すことで、実装とテストで同じスキーマを参照してマイグレーションすることができます。
以下の追加部分で withInitScript("db/schema.sql")
としているのがその部分です。JVM が読み込めるように src/main/resources/
以下に置いてあります。
@Testcontainers
@SpringBootTest
class SampleTest {
companion object {
@Container
val postgreSQLContainer: PostgreSQLContainer<Nothing> = PostgreSQLContainer<Nothing>("postgres:15")
+ .apply {
+ withDatabaseName("test")
+ withUsername("username")
+ withPassword("password")
+ withInitScript("db/schema.sql")
+ start()
+ }
@JvmStatic
@DynamicPropertySource
fun setProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", postgreSQLContainer::getPassword)
}
}
@Test
fun `some test`() {
...
}
sqldef 側はどうしてるかというと、ローカルでは Docker コンテナにして実行するようにしています。
FROM debian:bullseye
RUN apt update \
&& apt install -y wget postgresql-client \
&& wget https://github.com/k0kubun/sqldef/releases/download/v0.16.13/psqldef_linux_amd64.tar.gz -O psqldef.tar.gz \
&& tar -zxvf psqldef.tar.gz -C /bin \
&& rm psqldef.tar.gz \
&& apt clean
COPY src/main/resources/db/ /volume
COPY db_migration.sh /
RUN chmod +x ./db_migration.sh
db_migration.sh は、sqldef が database の作成には対応していないので、最初だけ作成できるようにするための一工夫です。
#!/bin/bash
# If the target database does not exist, create it.
INIT_DB="SELECT 'CREATE DATABASE $DB_NAME' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$DB_NAME')\gexec"
echo $INIT_DB | PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -U $DB_USER postgres
# Run db migration from schema file
PGPASSWORD=$DB_PASSWORD psqldef -h $DB_HOST -U $DB_USER $DB_NAME --file=$SCHEMA_PATH
上記を簡単に実行できるように Makefile を作って、make 経由で実行しています。
.PHONY: db-migrate
DB_MIGRATE_COMMAND = docker compose run --rm sqldef psqldef
DB_HOST = db
DB_USER = username
DB_PASSWORD = password
DB_NAME = development
db-migrate:
${DB_MIGRATE_COMMAND} -h ${DB_HOST} -U ${DB_USER} -W ${DB_PASSWORD} ${DB_NAME} --file=./volume/schema.sql
これで、sqldef でマイグレーションしたDBの状態と、Testcontainer が立ち上がった時のDB状態が必ず一致するようになりました。マイグレーション漏れなどを気にすることなく、安全にテストができます。
もうちょい楽に Testcontainers を使う
上記の例だと、テストファイルに毎回 @Container
や @DynamicPropertySource
の設定を書かないといけなくて面倒です。そこで、以下のように Base クラスを作成し、DB接続が必要なテストではそれを継承することで、簡単に DB と繋いでテストできるようにしました。
package com.sample.app
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers
object TestDatabaseContainer {
@Container
val postgreSQLContainer: PostgreSQLContainer<Nothing> = PostgreSQLContainer<Nothing>("postgres:15")
.apply {
withDatabaseName("test")
withUsername("username")
withPassword("password")
withInitScript("db/schema.sql")
start()
}
}
package com.sample.app
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
/**
* このクラスを継承したクラスは、テスト実行時に Testcontainers の DB と接続してテストすることができる
*/
@SpringBootTest
abstract class AbstractContainerBaseTest {
companion object {
@JvmStatic
@DynamicPropertySource
fun setProperties(registry: DynamicPropertyRegistry) {
// デバッグ時に DB にログインしたい場合、Testcontainer を使っていると毎回 host/port が変わるので、
// 以下のように標準出力に出しておくと便利です。
println("TestDatabaseContainer host: ${TestDatabaseContainer.postgreSQLContainer.host}")
println("TestDatabaseContainer port: ${TestDatabaseContainer.postgreSQLContainer.firstMappedPort}")
registry.add("spring.datasource.url", TestDatabaseContainer.postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", TestDatabaseContainer.postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", TestDatabaseContainer.postgreSQLContainer::getPassword)
}
}
}
テストで利用する側は、AbstractContainerBaseTest クラスを継承するだけでDBが自動で立ち上がります。設定がぐっと減っていい感じですね。
@SpringBootTest
class SampleTest : AbstractContainerBaseTest() {
@Autowired
lateinit var userRepository: UserRepository
@Autowired
lateinit var context: DSLContext
@BeforeEach
fun createExcelTemplate() {
userRepository.create(id = 1, name = "test")
}
@AfterEach
override fun cleanDB() {
context.deleteFrom(USER).execute()
}
@Test
fun `test user with db data`() {
...
まとめ
Testcontainers と sqldef を使って宣言的なDBマイグレーションを本番とテストで両立させる方法について紹介しました。プロダクト立ち上げよーいどんのタイミングでこの設定を行ったため、当たり前にDBと繋いだテストを書くことができています。
テストファイル毎にまっさらなコンテナが立ち上がるので、データベース内のデータの状態を気にすることなくテストが実行できており、CIの結果も今のところ安定しています。
やはり実際のデータベースを用いたテストは信頼性が高いので、とてもおすすめです!
株式会社アルダグラムのTech Blogです。 世界中のノンデスクワーク業界における現場の生産性アップを実現する現場DXサービス「KANNA」を開発しています。 採用情報はこちら: herp.careers/v1/aldagram0508/
Discussion