こんにちは、Xイノベーション本部ソフトウェアデザインセンターの中村です。
本記事は電通国際情報サービス Advent Calendar 2023の12月8日の記事です。
皆さんはデータベースアクセスを行うアプリケーションのユニットテストやインテグレーションテストをどのように実施していますか?絶対の正解はありませんが、テストの効率性や正確性などを考慮して様々な工夫や検討がなされる領域だと思います。例えば次のような戦略があります。
- データベースアクセス部分をモックにする
- 運用時のデータベースよりも高速なインメモリデータベースを使う(例えばH2 Database Engineなど)
- 開発者のローカル環境に運用環境と同等のテスト用データベースをインストールする
本記事では、Testcontainersを使ってデータベースアクセスを伴うテストを効率的に実施する方法をコードを交えながら紹介します。
本記事で伝えたいこと
最初に、本記事で伝えたいことを簡単にまとめておきます。
Testcontainersとは?
Testcontainersは、Dockerコンテナで実行できるデータベースなどのソフトウェアに関して軽量で使い捨て可能なインスタンスを提供するためのオープンソースフレームワークです。
このフレームワークを使うと、運用環境と同等のデータベースインスタンスをテスト環境で何度でも簡単に再現し利用できます。
Testcontainersでは、Java、Go、.NET、Node.jsなど多数のプログラミング言語がサポートされています。
前提
本記事の前提は以下のとおりです。
- Testcontainers for Javaを使う
- Dockerを動かす環境としてはDocker Desktopを使う
- プログラミング言語にはKotlinを使う
- データベースにはPostgreSQLを使う
- データベースアクセスのフレームワークにはKomapperを使う
- ビルドツールにはGradle Wrapperを使う(Gradleのバージョンは以下のとおり)
./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は、MySQL、PostgreSQL、Oracle 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) }
ポイントは以下のとおりです。
ビジネスロジック
データベース上の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 }
このクラスのポイントは次のとおりです。
- システムプロパティからJDBC接続URLを取得しKomapperのデータベースインスタンスを作成する
- データベースインスタンスはテストクラスのコンストラクタに渡す
- 最初のテストを実行する前に一度だけ
cusotmers
テーブルを作成する - テストメソッドの実行前にトランザクションを開始し、終了後にロールバックを行う(テスト間の影響を生じさせない)
ビジネスロジックのテストコード
ビジネスロジックのテストコードは次のようになります。
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) } }
このクラスのポイントは次のとおりです
テストメソッドの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で執筆されました)