コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

Kotest + SpringBootTest + TestcontainersでAPIの結合テストを行う

はじめに

コドモンプロダクト開発部の安居です。私たちのチームでは、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 アプリケーションと統合することで、テストや開発フローに自然に組み込むことができます。 インメモリでモックサーバーを立てることができ、テスト実行時にサーバーが即座に立ち上がり、終了後に自動で破棄されるため、軽快に動作します。

公式ドキュメント

wiremock.org

サンプルコードの紹介

ここでは題材として『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に順次移行することで、より高速なパイプラインを目指していきます。