はじめに
コドモンプロダクト開発部の安居です。私たちのチームでは、Spring Bootベースのマイクロサービスを複数開発・運用しています。その中でAPIの結合テストを行う際に、「テストの実行時間」や「ローカルでのテスト実行のしやすさ」に課題を感じていました。
この記事では、そうした課題をどのように解決したか、そしてそのために Kotest + SpringBootTest + Testcontainers をどのように活用したかをご紹介します。
目次
もともとのテスト構成とその課題
以前は以下のような構成でAPIの結合テストを実行していました。
- API Server:Spring BootアプリケーションをDockerで起動
- DB(PostgreSQL):Dockerで起動
- 外部API依存:WireMockをDockerで起動
- テスト実行:gauge-java + Playwright
この構成は「本番に近い環境を用意できる」というメリットはありましたが、いくつかの課題がありました。
課題1:テスト実行時間が長い
gauge-java + playwright
は実行前にビルドや依存インストールの時間がかかる- そのためCI/CDの所要時間が長くなり、生産性の低下に繋がっていました
課題2:ローカル環境での実行が難しい
- テスト用に複数のDockerコンテナを起動する必要があり、環境構築に手間がかかる
- チーム内で複数マイクロサービスを同時開発することもあり、環境の切り替えが煩雑
- ソース変更後のビルドを忘れたまま実行してしまうことでテストが失敗し、再実行…というパターンも頻発
KotestとSpringBootTestとTestcontainersを使った新しい構成
こうした課題を解決するため、現在は以下の構成に移行しました。
要素 | 旧構成 | 新構成 |
---|---|---|
API Server | Spring Boot + Docker | SpringBootTest |
DB | PostgreSQL + Docker | PostgreSQL + Testcontainers |
外部API | WireMock + Docker | WireMock Spring Boot |
テスト実行 | gauge-java + Playwright | Kotest |
移行のメリット
テスト実行がシームレスに
SpringBootTestでAPIサーバーを直接起動し、TestcontainersでDBも自動起動。ローカルでも簡単に実行できます。実行時間の短縮
gaugeやDocker起動などのオーバーヘッドがなくなり、CI/CDの所要時間が大幅に短縮されました。品質向上
ローカルでのテスト実行が容易になったことで、開発者がテストを頻繁に回すようになり、不具合の早期発見につながっています。
各技術の紹介
Testcontainers
Dockerを使って統合テスト用の依存コンテナ(DB、Kafka、Redisなど)を立ち上げるライブラリです。
ローカルでもCI環境でも、本番に近い状態でテストを行うことができます。
Dockerが動作する環境であれば使用可能で、GitHub Actionsの ubuntu-latest
や開発環境でも問題なく動作しています。
SpringBootTest
Spring Bootアプリケーション全体を立ち上げ、実際のBeanや設定を使って統合テストを行うためのアノテーションです。
Mockを使わないため、本番に近い構成でのテストが可能です。
WireMock Spring Boot
WireMock は単体でもモックサーバーとして利用できますが、Spring Boot アプリケーションと統合することで、テストや開発フローに自然に組み込むことができます。 インメモリでモックサーバーを立てることができ、テスト実行時にサーバーが即座に立ち上がり、終了後に自動で破棄されるため、軽快に動作します。
公式ドキュメント
サンプルコードの紹介
ここでは題材として『Productの作成と取得』ができる簡単なAPIサーバーを作成しました。
POST /products
に対して「DBに適切な情報が保存されていること」
GET /products/{id}
に対して「DBの情報を適切に返すこと」をテストしていきます。
※ サンプルコードを簡潔にするため、WireMock Spring Boot の設定は省略しています。
テーブルの作成
CREATE TABLE IF NOT EXISTS products ( product_id UUID default gen_random_uuid() PRIMARY KEY, name VARCHAR(255) NOT NULL, price INT NOT NULL );
必要な依存関係
dependencies { ........ 省略 // test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") //kotest val kotestVersion = "5.9.1" testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0") // testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") //liquibase implementation("org.liquibase:liquibase-core:4.19.0") implementation("org.liquibase:liquibase-groovy-dsl:3.0.2") liquibaseRuntime("org.postgresql:postgresql:42.3.1") liquibaseRuntime("org.liquibase:liquibase-core:4.19.0") liquibaseRuntime("org.liquibase:liquibase-groovy-dsl:3.0.2") liquibaseRuntime("info.picocli:picocli:4.6.1") testImplementation("org.postgresql:postgresql") }
${projectRoot}/src/test/resources/application.ymlにDB接続を追加
それぞれのシステムプロパティにはTestContainersが自動で割り当てた情報が入ります。
spring: r2dbc: url: r2dbc:postgresql://localhost:${DB_PORT}/${DB_NAME} username: ${DB_USER} password: ${DB_PASSWORD}
Testcontainersの設定と + LiquibaseによるDB初期化
AbstractProjectConfig を実装することで、テスト実行前の設定を行います。
package com.example.kotest_sample import io.kotest.core.config.AbstractProjectConfig import io.kotest.core.spec.style.StringSpec import io.kotest.extensions.spring.SpringExtension import io.kotest.matchers.shouldBe import liquibase.Liquibase import liquibase.database.DatabaseFactory import liquibase.resource.ClassLoaderResourceAccessor import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.core.io.ClassPathResource import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.r2dbc.core.DatabaseClient import org.testcontainers.containers.PostgreSQLContainer import java.sql.DriverManager import java.util.* @TestConfiguration class TestConfiguration : AbstractProjectConfig() { override fun extensions() = listOf(SpringExtension) // プロジェクト全体のテスト実行前に呼び出されるメソッド override suspend fun beforeProject() { // PostgreSQLコンテナを起動 val postgresContainer = PostgreSQLContainer<Nothing>("postgres:latest").apply { start() } // PostgreSQLコンテナから取得した情報をシステムプロパティとして設定 System.setProperty("DB_USER", postgresContainer.username) System.setProperty("DB_PASSWORD", postgresContainer.password) System.setProperty("DB_NAME", postgresContainer.databaseName) System.setProperty("DB_PORT", postgresContainer.firstMappedPort.toString()) // PostgreSQLコンテナに接続するためのJDBC接続を作成 val connection = DriverManager.getConnection( postgresContainer.jdbcUrl, postgresContainer.username, postgresContainer.password ) // Liquibaseを使用して、指定した変更ログファイルでデータベースのマイグレーションを実行 Liquibase( // ${projectRoot}/src/main/resources/liquibase/xml/db.changelog.xmlを指定 ClassPathResource("liquibase/xml/db.changelog.xml").path, ClassLoaderResourceAccessor(), DatabaseFactory.getInstance().findCorrectDatabaseImplementation( liquibase.database.jvm.JdbcConnection(connection) ) ).update("") } }
APIの実装(Kotlin)
Mockを使わずに実際のBeanを使ってテストが可能であることを強調するため、Controller、Service、Repositoryに分けて記載しています。なお、処理自体はデータを右から左に流すだけのものにしています。
package com.example.kotest_sample import kotlinx.coroutines.reactive.awaitSingle import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Column import org.springframework.data.relational.core.mapping.Table import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Controller import org.springframework.stereotype.Service import org.springframework.web.reactive.function.server.* import java.util.* @Configuration class Router { @Bean fun healthCheckRoutes(controller: ProductController) = coRouter { GET("/products/{id}", controller::getProduct) POST("/products", controller::createProduct) } } @Controller class ProductController(private val service: ProductService) { // 新しい製品を作成するためのリクエストボディのデータクラス data class CreateProductRequest(val name: String, val price: Int) suspend fun createProduct(request: ServerRequest): ServerResponse = request.awaitBody<CreateProductRequest>().let { service.createProduct(it.name, it.price) }.let { ServerResponse.ok().bodyValueAndAwait(it) } suspend fun getProduct(request: ServerRequest): ServerResponse = service.getProduct( id = UUID.fromString(request.pathVariable("id")) ).let { ServerResponse.ok().bodyValueAndAwait(it) } } @Service class ProductService(private val repository: ProductRepository) { suspend fun createProduct( name: String, price: Int ): Product = repository .save(Product(name = name, price = price)) .awaitSingle() suspend fun getProduct(id: UUID): Product = repository .findById(id) .awaitSingle() } // productsテーブルのマッピングクラス @Table("products") data class Product( @Id @Column("product_id") val productId: UUID? = null, val name: String, val price: Int ) interface ProductRepository : ReactiveCrudRepository<Product, UUID>
テストコード
package com.example.kotest_sample import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.r2dbc.core.DatabaseClient import java.util.* @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class KotestSampleApplicationTests( private val testRestTemplate: TestRestTemplate, private val databaseClient: DatabaseClient ): StringSpec({ "プロダクトを作成することができる" { val response = testRestTemplate.postForEntity( "/products", HttpEntity( "{\"name\": \"pen\", \"price\": 100}", HttpHeaders().apply { add("Content-Type", "application/json") } ), Product::class.java ) // レスポンスの検証 response.statusCode.value() shouldBe 200 // DBに正しくデータが登録されていることを検証 val count = databaseClient.sql("SELECT count(*) FROM products WHERE name = 'pen' and price = 100") .map { row -> row.get(0, Long::class.java) } .first() .block() count shouldBe 1L } "指定したIDのプロダクトを取得することができる" { // 事前にデータを挿入 val testProductId = UUID.randomUUID() val testProductName = "water" val testProductPrice = 200 databaseClient.sql("INSERT INTO products (product_id, name, price) VALUES ('$testProductId', '$testProductName', $testProductPrice)") .fetch() .rowsUpdated() .block() val response = testRestTemplate.getForEntity( "/products/$testProductId", Product::class.java ) // レスポンスの検証 response.statusCode.value() shouldBe 200 val body = response.body!! body.productId shouldBe testProductId body.name shouldBe testProductName body.price shouldBe testProductPrice } })
おわりに
Kotest + SpringBootTest + Testcontainers の構成により、「実行速度」と「ローカル環境でのテストのしやすさ」が向上しました。 まだ導入して間もないため、テストステップやテストログの視認性などいくつかの課題は残っていますが、 今後は、CI上でもgauge-javaベースの結合テストをKotestに順次移行することで、より高速なパイプラインを目指していきます。