🧪

Testcontainers と sqldef を用いて Spring Boot のテストでリアルDBを使えるようにする

2023/12/15に公開

アルダグラムでエンジニアをしている @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

https://testcontainers.com/

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

https://github.com/sqldef/sqldef

MySQL, PostgreSQL, SQLite3, SQL Server のマイグレーションをスキーマベースで行える Go 製のツールです。Ruby で作られた Ridgepole とだいたい同じことができるのですが、sqldef は SQL の CREATE 文を用いてスキーマを定義することができます。

簡単に動作を説明すると(README の通りですが)、
以下のようなSQLを schema.sql のようなファイルに書き、それを sqldef に渡すと users テーブルを作ってくれます。

PostgreSQLの場合
CREATE TABLE public.users (
    id bigint NOT NULL,
    name text,
    age integer
);

users テーブルに role カラムを追加したくなったら、schema.sql の CREATE 文に書き足してマイグレーションを実行すると、差分を見てカラムを追加してくれます。

PostgreSQLの場合
CREATE TABLE public.users (
    id bigint NOT NULL,
    name text,
    age integer,
+    role integer
);

カラムを削除する場合も同様です。
以下の例では、age を削除していますが、この状態でマイグレーションを実行すると age カラムが消えます。

PostgreSQLの場合
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

Discussion