電通総研 テックブログ

電通総研が運営する技術ブログ

Testcontainersを使ってテストを効率化しよう

こんにちは、Xイノベーション本部ソフトウェアデザインセンターの中村です。

本記事は電通国際情報サービス Advent Calendar 2023の12月8日の記事です。

皆さんはデータベースアクセスを行うアプリケーションのユニットテストやインテグレーションテストをどのように実施していますか?絶対の正解はありませんが、テストの効率性や正確性などを考慮して様々な工夫や検討がなされる領域だと思います。例えば次のような戦略があります。

  • データベースアクセス部分をモックにする
  • 運用時のデータベースよりも高速なインメモリデータベースを使う(例えばH2 Database Engineなど)
  • 開発者のローカル環境に運用環境と同等のテスト用データベースをインストールする

本記事では、Testcontainersを使ってデータベースアクセスを伴うテストを効率的に実施する方法をコードを交えながら紹介します。

本記事で伝えたいこと

最初に、本記事で伝えたいことを簡単にまとめておきます。

  1. TestContainersのAPIよりもJDBCの接続URLを使おう
  2. データベースはデーモンモードで起動しよう
  3. テスト同士が影響しないようにテスト毎にロールバックを実行しよう

Testcontainersとは?

Testcontainersは、Dockerコンテナで実行できるデータベースなどのソフトウェアに関して軽量で使い捨て可能なインスタンスを提供するためのオープンソースフレームワークです。

このフレームワークを使うと、運用環境と同等のデータベースインスタンスをテスト環境で何度でも簡単に再現し利用できます。

Testcontainersでは、Java、Go、.NET、Node.jsなど多数のプログラミング言語がサポートされています。

前提

本記事の前提は以下のとおりです。

./gradlew -v

------------------------------------------------------------
Gradle 8.5
------------------------------------------------------------

Build time:   2023-11-29 14:08:57 UTC
Revision:     28aca86a7180baa17117e0e5ba01d8ea9feca598

Kotlin:       1.9.20
Groovy:       3.0.17
Ant:          Apache Ant(TM) version 1.10.13 compiled on January 4 2023
JVM:          21.0.1 (Azul Systems, Inc. 21.0.1+12-LTS)
OS:           Mac OS X 12.6 x86_64

データベースアクセスのフレームワークであるKomapperについて少し補足をします。Komapperは、MySQLPostgreSQLOracle Databaseなどの代表的なリレーショナルデータベースに対応したO/Rマッピングフレームワークです。Kotlin製の代表的なO/RマッピングフレームワークであるExposedほどの知名度はありませんが機能性や使いやすさでは負けていません(と、Komapperの作者である私は考えています😁)。Komapperでは様々なデータベースとの稼働確認をTestcontainersを用いて行っています。

本記事にはデータベース、フレームワーク、ビルドツールに強く依存した説明はありませんので適宜お好みの環境に置き換えてお読みいただけます。ただし、プログラミング言語についてはJVM言語以外に読み替えるのは難しいかもしれません。

本記事で示すサンプルコードはGetting started with Testcontainers for Javaを下敷きにしています。ぜひ見比べてみてください。

Gradleの設定

以下のようなbuild.gradle.ktsを用意します。

plugins {
    application
    id("com.google.devtools.ksp") version "1.9.21-1.0.15"
    kotlin("jvm") version "1.9.21"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    // Komapperの設定
    platform("org.komapper:komapper-platform:1.15.0").let {
        implementation(it)
        ksp(it)
    }
    implementation("org.komapper:komapper-starter-jdbc")
    implementation("org.komapper:komapper-dialect-postgresql-jdbc")
    ksp("org.komapper:komapper-processor")

    // Testcontainersの設定
    testImplementation(platform("org.testcontainers:testcontainers-bom:1.19.3"))
    testRuntimeOnly("org.testcontainers:postgresql")

    // JUnitの設定
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2")
}

tasks.getByName<Test>("test") {
    useJUnitPlatform()

    // GradleのプロパティをJavaのシステムプロパティに引き継ぐ
    val jdbcUrl = project.property("jdbc.url") ?: error("jdbc.url not found")
    systemProperty("jdbc.url", jdbcUrl)
}

ポイントは以下のとおりです。

  • 利用するライブラリ(Kotlin、Komapper、Testcontainers、JUnit)への依存を示す
  • JDBCの接続URLをGradleのプロパティを介してアプリケーションに渡す

ビジネスロジック

データベース上のcustomersテーブルに対応するCustomerエンティティクラスを定義します。

package example

import org.komapper.annotation.KomapperEntity
import org.komapper.annotation.KomapperId
import org.komapper.annotation.KomapperTable

@KomapperEntity
@KomapperTable("customers")
data class Customer(@KomapperId val id: Long, val name: String)

Komapperが認識できるようにKomapperのアノテーションを付与しています。

Customerエンティティクラスを使ってデータの追加や取得を行うサービスクラスは次のようになります。

package example

import org.komapper.core.dsl.Meta
import org.komapper.core.dsl.QueryDsl
import org.komapper.jdbc.JdbcDatabase

class CustomerService(private val db: JdbcDatabase) {
    // Customerエンティティクラスのメタモデル
    private val c = Meta.customer

    fun createCustomer(customer: Customer) {
        db.runQuery {
            // 対応するSQL: insert into customers (id, name) values (?, ?)
            QueryDsl.insert(c).single(customer)
        }
    }

    fun getAllCustomers(): List<Customer> {
        return db.runQuery {
            // 対応するSQL: select t0_.id, t0_.name from customers as t0_
            QueryDsl.from(c)
        }
    }
}

データの追加や取得にはKomapperのAPIを使います。APIから発行するSQLはコード内のコメントに示したとおりです。

テストコード

Getting started with Testcontainers for Javaでは、テストを制御するコードとテストコードが同じクラスに記述されていますが、本記事では分離して示します。

テストを制御するためのコード

テストを制御するコードは以下のようになります。このコードは一度書けば複数のテストクラスから再利用できます。

package example

import org.junit.jupiter.api.extension.AfterTestExecutionCallback
import org.junit.jupiter.api.extension.BeforeAllCallback
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.ParameterContext
import org.junit.jupiter.api.extension.ParameterResolver
import org.komapper.core.dsl.Meta
import org.komapper.core.dsl.QueryDsl
import org.komapper.jdbc.JdbcDatabase
import org.komapper.tx.core.TransactionProperty
import org.komapper.tx.jdbc.JdbcTransactionSession

class DatabaseTestSupport :
    BeforeAllCallback,
    BeforeTestExecutionCallback,
    AfterTestExecutionCallback,
    ParameterResolver {

    companion object {
        @Volatile
        private var initialized: Boolean = false
        // Komapperのデータベース
        private val db = JdbcDatabase(url = System.getProperty("jdbc.url") ?: error("jdbc.url not found"))
        // Komapperのトランザクションマネージャー
        private val transactionManager = run {
            val session = db.config.session as JdbcTransactionSession
            session.transactionManager
        }
    }

    // 最初のテストを実行する前に一度だけ cusotmers テーブルを作成する
    override fun beforeAll(context: ExtensionContext) {
        if (!initialized) {
            initialized = true
            db.runQuery {
                // 対応するSQL: create table if not exists customers (id bigint not null, name text not null, constraint pk_customers primary key(id))
                QueryDsl.create(Meta.customer)
            }
        }
    }

    // テストメソッドの実行前にトランザクションを開始する
    override fun beforeTestExecution(context: ExtensionContext) {
        transactionManager.begin(TransactionProperty.Name(context.displayName))
    }

    // テストメソッドの実行後にトランザクションをロールバックする
    override fun afterTestExecution(context: ExtensionContext) {
        transactionManager.rollback()
    }

    // テストクラスのコンストラクタの型をチェックする
    override fun supportsParameter(
        parameterContext: ParameterContext,
        extensionContext: ExtensionContext,
    ): Boolean = parameterContext.parameter.type === JdbcDatabase::class.java

    // テストクラスのコンストラクタに db を渡す
    override fun resolveParameter(
        parameterContext: ParameterContext,
        extensionContext: ExtensionContext,
    ): Any = db
}

このクラスのポイントは次のとおりです。

ビジネスロジックのテストコード

ビジネスロジックのテストコードは次のようになります。

package example

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.komapper.jdbc.JdbcDatabase

@ExtendWith(DatabaseTestSupport::class)
class CustomerServiceTest(db: JdbcDatabase) {
    
    // テスト対象のサービスクラスをインスタンス化する
    private val customerService = CustomerService(db)

    @Test
    fun shouldGetCustomers() {
        customerService.createCustomer(Customer(1L, "George"))
        customerService.createCustomer(Customer(2L, "John"))

        val customers = customerService.getAllCustomers()
        assertEquals(2, customers.size)
    }
}

このクラスのポイントは次のとおりです

  • @ExtendWithで上述したDatabaseTestSupportクラスを指定する
  • コンストラクタでデータベースインスタンスを受け取りサービスクラスのコンストラクタに渡す

テストメソッドのshouldGetCustomersではCustomerエンティティを2件追加した後で全件を取得し、取得した件数が2件であったかどうかを検証しています。

プロパティの設定

gradle.propetiesにjdbc.urlをキーとしてJDBCの接続URLを設定します。

jdbc.url=jdbc:tc:postgresql:15-alpine:///test?TC_DAEMON=true

接続URLのポイントは次のとおりです。

  • tc:がTestcontainersを使うことを示す
  • postgresql:15-alpineで利用するデータベースとDockerイメージが決まる
  • testは接続先のデータベースの名前(Testcontainersのデフォルトのデータベース名)
  • TC_DAEMON=trueはデータベースをデーモンモードで動かすことを示す

TestcontainersはJDBCドライバが接続URLから自動検出される機能を使ってコンテナを自動で起動します。

データベースをデーモンモードで動かすとJVMがシャットダウンするまで起動したままにできます。つまり、テストメソッドやテストクラスごとにデータベースインスタを起動/停止するよりもテストの実行時間を短縮できます。

テストの実行

以下のコマンドをターミナルに打ち込むことでテストを実行できます。このとき、gradle.propetiesに記載したjdbc.urlプロパティが暗黙的に利用されます。

./gradlew test

利用するDockerイメージは簡単に変更できます。例えば、latestを使いたい場合はプロパティで次のように接続URLを明示的に指定します。

./gradlew test -Pjdbc.url=jdbc:tc:postgresql:latest:///test?TC_DAEMON=true

通常の接続URLを指定すれば、Testcontaners上のデータベースではなくローカルのデータベースに繋ぐこともできます。

./gradlew test -Pjdbc.url=jdbc:postgresql://localhost:5432/komapper?user=postgres

テストが成功したことの確認

テストが成功すると「BUILD SUCCESSFUL」の文字がコンソールに出力されます。ただこれだけでは本当にテストが実施されたのか、テストが成功したのか確証を持てないかもしれません。その場合は以下のコマンドを打つなどしてテストで生成されるレポートをブラウザで開きましょう。

open build/reports/tests/test/classes/example.CustomerServiceTest.html

ブラウザ上で以下のような表示を確認できれば間違いなくテストが成功していると言えます。

上記ではGradleコマンドをターミナルに打ち込んでテストを実行すると説明しましたが、IntelliJ IDEAなどのIDEからテストを実行しても構いません。その場合、次のような表示をIDE上で確認できればテストが成功しています。

本記事で伝えたいこと(再)

Testcontainersのオススメの使い方をサンプルコードを交えて紹介しました。
以下、改めて本記事で伝えたい3点について説明をします。

TestContainersのAPIよりもJDBCの接続URLを使おう

TestcontainersのAPIを直接呼びだすサンプルコードをよく見かけますが、TestcontainersはJDBCの接続URLから特定のデータベースを起動できます。接続URLを使うことでDockerイメージを切り替えたりローカルの環境に接続したりが簡単になります。

なお、TestcontainersのAPIを直接呼びだす方法を否定したいわけではありません。要件によっては直接APIを呼びだす方法のほうが使いやすいこともあるでしょう。

データベースはデーモンモードで起動しよう

接続URLのオプションでTC_DAEMON=trueパラメータを指定するとデーモンモードで起動できます。デーモンモードにするとデータベースインスタンスの起動と終了をテストを通して一度きりにできるのでテストの実行時間を短縮できます。

TestcontainersのAPIを直接呼びだす場合は、Singleton containersに記載の方法で同等のことが実現できます。

テスト同士が影響しないようにテスト毎にロールバックを実行しよう

デーモンモードで起動すると、あるテストにおけるデータベースの変更が他のテストに影響することがあり得ます。テスティングフレームワークやデータベースアクセスのフレームワークを組み合わせて、テスト実行前のトランザクション開始と終了時のロールバックを徹底しましょう。

なお、テストによってはシーケンスのインクリメントやDDLの実行などロールバックできない変更を加えることがあるかもしれません。その場合は別途対応が必要になります。

最後に

本記事で紹介したコードはGitHub Actions上で特別な設定をすることなく動作します。サンプルコードを含めたリポジトリ https://github.com/nakamura-to/testcontainers-demo には、GitHub Actionsのワークフローも含めています。参考にしていただければ幸いです。

本記事をお読みいただきありがとうございました。

私たちのアドベントカレンダーでは土曜日と日曜日はお休みしているので、次回の記事は月曜日に公開予定です。12月11日、月曜日の記事もぜひご覧ください。

私たちは一緒に働いてくれる仲間を募集しています!

フルサイクルエンジニア

執筆:@nakamura.toshihiro、レビュー:Ishizawa Kento (@kent)
Shodoで執筆されました