SpringBootTestでIntegrationテストを自動化する

## これは何？

バックエンドサービスが提供するAPIが想定通りのレスポンスを返すか評価するためのIntegrationテストを書きGradleから実行できるようにし、GitHub Actionsで実行できるようにするまでの流れをまとめた

### リポジトリ

https://github.com/kazokmr/book-manager/tree/main/src/intTest/kotlin/com/book/manager

尚、今回のリポジトリのコードは下記の書籍のサンプルコードをベースとさせて頂いています。

https://gihyo.jp/book/2021/978-4-297-11859-4

## 前提

### この記事におけるIntegrationテストの定義

DBを含むバックエンドサービス全体を起動し、クライアントからのリクエストに対するレスポンスを評価するテストを行う。バックエンドサービスはREST APIとなっているので APIテストとも言える。

Unitテストだと Controller, Service, Repository, Domainといったクラス単体の処理を評価するので、テスト対象が利用するオブジェクトをMock化することがある。一方で IntegrationtionテストはMockは使わずに実際のクラスからインスタンス化したオブジェクトを利用してテストを行うので提供するAPIが想定通りの機能を提供しているか確認できる。

今回書いたIntegrationテストは、DBとアプリケーションサーバーをDockerコンテナで起動した状態でテストを行うので、本番環境を想定したセキュリティやパフォーマンスといった非機能レベルの検証は行えないが Spring Security や Spring Session Data を利用したログインユーザーの認証・認可やCSRF対策、セッション管理といった機能面については本番環境同等の評価が行えると考えている。

### 環境情報

主要な実行環境とライブラリを抜粋。この他の依存関係も全て 2023/03/18 時点の最新バージョンを利用した。

* Kotlin 1.8.10
* JDK 19
* Gradle 8.0.2
* PostgreSQL 15.2
* Redis 7.0.2
* Spring Boot 3.0.4
* Junit 5.9.2
* Testcontainers 1.27.6

## GradleにIntegrationTest用のソースセットとタスクを追加する

まず Integrationテスト専用のコードと実行を管理する。理由は主に３つ

1. "test"ディレクトリ内のテストはUnitテスト用で、"main"ディレクトリのクラスに対応するが、IntegrationテストはAPIに対応するのでディレクトリ構造が違うため
1. Unitテストと分けて実行できるようにしたかったため
1. Integationテストだけで利用する依存関係を定義したかったため

今回は Gradle公式の設定を参考にし、Integrationテストコードを配置する `intTest` ディレクトリを作成し、`integrationTest`タスクを実行するこでテストコードを実行できるようにした。 
更に既存の `check` タスクを実行すると `test` タスクの後に `integrationTest` タスクが実行されるようにもした。 

https://docs.gradle.org/current/userguide/java_testing.html#sec:configuring_java_integration_tests

:::note warn
`intTestImplementation` と `intTestRuntimeOnly` の設定は `dependencies` ブロックよりも前にすること。
`intTestImplementation` で追加する依存関係が認識されないため。
:::

``` build.gradle.kts
......

// Integrationテスト用の Source Setを作成する。testで利用しているコードを利用するのでClasspathに追加する
sourceSets {
    create("intTest") {
        java {
            setSrcDirs(listOf("src/intTest"))
        }
        compileClasspath += main.get().output + test.get().output
        runtimeClasspath += main.get().output + test.get().output
    }
}

idea {
    module {
        testSources.from(sourceSets["intTest"].kotlin.srcDirs)
        testResources.from(sourceSets["intTest"].resources.srcDirs)
    }
}

// `intTestImplementation` と `intTestRuntimeOnly` を定義する。こちらも testをベースに設定した
val intTestImplementation: Configuration by configurations.getting {
    extendsFrom(configurations.testImplementation.get())
}

configurations["intTestRuntimeOnly"].extendsFrom(configurations.testRuntimeOnly.get())

// WebTestClientを利用するので Integrationテスト用の依存関係にwebfluxを追加する
dependencies {
    ......
    intTestImplementation("org.springframework.boot:spring-boot-starter-webflux")
}

// 専用タスク `integrationTest` を作成。
val integrationTest = task<Test>("integrationTest") {
    description = "Runs integration tests."
    group = "verification"
    testClassesDirs = sourceSets["intTest"].output.classesDirs
    classpath = sourceSets["intTest"].runtimeClasspath
    shouldRunAfter("test")
}

// `check` タスク実行時に integrationTestタスクを実行するように追加（Unitテストの後に実行する)
tasks.check {
    dependsOn(integrationTest)
}

......
```

## テストコードを書いていく

### Testcontainers で PostgreSQLとRedisのコンテナを用意する

Unitテストで利用している下記の抽象クラスを Integrationテストでも継承することでテスト実行時にコンテナで起動したDBにアクセスして利用することができる

``` kotlin
package com.book.manager.infrastructure.database.testcontainers

import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.utility.DockerImageName

@Testcontainers
abstract class TestContainerDataRegistry {

    companion object {
        @Container
        @JvmStatic
        val database = PostgreSQLContainer<Nothing>(DockerImageName.parse("postgres:latest")).apply {
            withDatabaseName("test")
            withUsername("user")
            withPassword("pass")
            withEnv("POSTGRES_INITDB_ARGS", "--encoding=UTF-8 --no-locale")
            withEnv("TZ", "Asia/Tokyo")
            withInitScript("initdb/schema.sql")
        }

        @Container
        @JvmStatic
        val redis: GenericContainer<*> = GenericContainer<Nothing>(DockerImageName.parse("redis:latest")).apply {
            withExposedPorts(6379)
        }

        @DynamicPropertySource
        @JvmStatic
        fun setUp(registry: DynamicPropertyRegistry) {
            registry.add("spring.datasource.url", database::getJdbcUrl)
            registry.add("spring.datasource.username", database::getUsername)
            registry.add("spring.datasource.password", database::getPassword)
            registry.add("spring.data.redis.host", redis::getHost)
            registry.add("spring.data.redis.port", redis::getFirstMappedPort)
        }
    }
}

```

::: note info
Spring Boot 3.1 から Testcontainers との連携が強化され、上記 `setup()` で行っているapplication.properties への値の登録を不要にすることができます。
ただし redis コンテナに GenericContainer を使っている関係か、redisのポート番号のみ 引き続き `getFirstMappedPort()` で プロパティ値への登録が必要でした。(redis の host は GenericContainerにアクセッサがあるため省略可能でした）
:::


### WebTestClientを利用する

従来だと REST APIをテストする際のクライアントとして、`TestRestTemplate` が利用されており今回も最初はこれでテストコードを書いていたのだが、TestRestTemplateは開発が停滞しており、今後は WebFluxで利用されている `WebTestClient` をWebMVCでも利用することが増えそうに読めたので WebTestClientを使うことにした。

https://docs.spring.io/spring-boot/docs/3.0.3/reference/htmlsingle/#features.testing.utilities.test-rest-template

#### テスト時にSessionIdとCSRFトークンをセットできるようにする

バックエンドサービスはSpring Security と Spring Sessionを利用して、CSRF対策および認証済みユーザーのセッション管理を行なっている。このためIntegrationテストにおいてもWebTestClientからリクエストを送信するときにサービスから発行されている CSRFトークンとSessionID をリクエストヘッダに含めなければならない。これを実現するために ExchangeFilterFunctionを利用している。

ExchangeFilterFunctionは、WebTestClientのリクエスト送信からレスポンス受信までの処理をインターセプトすることができる。これを利用して直前のレスポンスのヘッダに含まれるSessionIDとCSRFトークンを格納し、次回のリクエストの際にヘッダにセットする処理を入れている。


``` kotlin
package com.book.manager.config

import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
import org.springframework.http.HttpHeaders
import org.springframework.web.reactive.function.client.ClientRequest
import org.springframework.web.reactive.function.client.ClientResponse
import org.springframework.web.reactive.function.client.ExchangeFilterFunction
import org.springframework.web.reactive.function.client.ExchangeFunction
import reactor.core.publisher.Mono

private val logger: Logger = LogManager.getLogger(CustomExchangeFilterFunction::class)

class CustomExchangeFilterFunction : ExchangeFilterFunction {

    private var sessionId = ""
    private var csrfToken = ""
    private var csrfHeader = ""

    override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> {

        val filteredRequest = ClientRequest.from(request)
            .cookies { cookies ->
                if (sessionId != "") {
                    logger.info("Set Cookie SESSION: $sessionId")
                    cookies.set("SESSION", sessionId)
                }
            }
            .headers { headers ->
                if (csrfToken != "") {
                    logger.info("Set Request Header $csrfHeader: $csrfToken")
                    headers.add(csrfHeader, csrfToken)
                }
            }
            .build()

        logger.info("Request: uri=${filteredRequest.url()}, headers=${filteredRequest.headers()}, body=${filteredRequest.body()}")

        return next.exchange(filteredRequest).doOnSuccess {

            logger.info("Response: status=${it.statusCode()}")

            val sessionIdFromCookie = it.cookies()["SESSION"]
            if (sessionIdFromCookie != null) {
                logger.info("Extracted cookie SESSION: ${sessionIdFromCookie[0]}")
                sessionId = sessionIdFromCookie[0].value
            }

            val headers: HttpHeaders = it.headers().asHttpHeaders()
            val csrfHeaders = headers["_csrf_header"]
            if (csrfHeaders != null) {
                logger.info("Extracted CSRF-HEADER from response: ${csrfHeaders[0]}")
                csrfHeader = csrfHeaders[0].toString()
            }
            val csrfTokens = headers["_csrf"]
            if (csrfTokens != null) {
                logger.info("Extracted CSRF-TOKEN from response: ${csrfTokens[0]}")
                csrfToken = csrfTokens[0].toString()
            }
        }
    }
}
```

あとは、WebTestClientを生成時に `filter()` を追加し、テスト実施前に CSRFトークンを取得するリクエストを送信してトークンの保管や、ログイン認証を行なってSessionIdを取得しておけば良い。

``` kotlin

@BeforeEach
internal fun setUp() {
    webClient = WebTestClient
        .bindToServer()
        .baseUrl("http://localhost:$port")
        .filter(exchangeFilter)
        .build()
    webClient.get().uri("/csrf_token").exchange()
}
```

### 実際のテストコード

ログイン認証や書籍の登録リクエストを実施し、認証エラーや認可エラーを加味したテストを行なっている。

またResponseに含まれるJsonをJavaオブジェクトにデシリアライズして評価するために、専用のObjectMapper Beanを用意し WebTestClient起動時にロードしている。

``` kotlin
package com.book.manager

import com.book.manager.config.CustomExchangeFilterFunction
import com.book.manager.config.CustomJsonConverter
import com.book.manager.config.CustomTestMapper
import com.book.manager.config.IntegrationTestConfiguration
import com.book.manager.domain.model.Book
import com.book.manager.domain.model.BookWithRental
import com.book.manager.domain.model.Rental
import com.book.manager.infrastructure.database.testcontainers.TestContainerDataRegistry
import com.book.manager.presentation.form.AdminBookResponse
import com.book.manager.presentation.form.BookInfo
import com.book.manager.presentation.form.GetBookDetailResponse
import com.book.manager.presentation.form.GetBookListResponse
import org.assertj.core.api.Assertions.*
import org.assertj.core.api.SoftAssertions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.codec.json.Jackson2JsonDecoder
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.expectBody
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.reactive.function.BodyInserters
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.stream.Stream

@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(IntegrationTestConfiguration::class)
internal class BookManagerIntegrationTests : TestContainerDataRegistry() {

    @Autowired
    private lateinit var exchangeFilter: CustomExchangeFilterFunction

    @Autowired
    private lateinit var jsonConverter: CustomJsonConverter

    @Autowired
    private lateinit var testMapper: CustomTestMapper

    @LocalServerPort
    private var port: Int = 0

    private lateinit var webClient: WebTestClient

    @BeforeEach
    internal fun setUp() {
        testMapper.initDefaultAccounts()
        webClient = WebTestClient
            .bindToServer()
            .baseUrl("http://localhost:$port")
            .filter(exchangeFilter)
            .codecs {
                it.defaultCodecs().jackson2JsonDecoder(Jackson2JsonDecoder(jsonConverter.objectMapper()))
            }
            .build()
        webClient.get().uri("/csrf_token").exchange()
    }

    @AfterEach
    internal fun tearDown() {
        testMapper.clearAllData()
    }

    companion object {
        @JvmStatic
        fun users(): Stream<Arguments> = Stream.of(
            Arguments.of("admin@example.com", "admin", HttpStatus.OK),
            Arguments.of("user@example.com", "user", HttpStatus.OK),
            Arguments.of("test@example.com", "test", HttpStatus.OK),
            Arguments.of("none@example.com", "none", HttpStatus.UNAUTHORIZED)
        )

        @JvmStatic
        fun dataOfRegister(): Stream<Arguments> {
            val book = Book(789, "統合テスト", "テスト二郎", LocalDate.of(2010, 12, 3))
            val expectedRegisteredBook = AdminBookResponse(book.id, book.title, book.author, book.releaseDate)
            val expectedBookDetail = GetBookDetailResponse(BookWithRental(book, null))
            return Stream.of(
                Arguments.of(
                    "admin@example.com",
                    "admin",
                    book,
                    HttpStatus.CREATED,
                    expectedRegisteredBook,
                    HttpStatus.OK,
                    expectedBookDetail
                ),
                Arguments.of(
                    "user@example.com",
                    "user",
                    book,
                    HttpStatus.FORBIDDEN,
                    null,
                    HttpStatus.BAD_REQUEST,
                    null
                )
            )
        }
    }

    @ParameterizedTest(name = "ログインテスト: User => {0}, Status => {2}")
    @MethodSource("users")
    @DisplayName("ログイン認証テスト")
    fun `login when user is exist then login`(user: String, pass: String, expectedStatus: HttpStatus) {

        // Given
        // When
        val response = webClient.login(user, pass).expectBody<String>().returnResult()

        // Then
        assertThat(response.status).isEqualTo(expectedStatus)
    }

    @Test
    @DisplayName("書籍リストを取得する")
    fun `bookList when list is exist then return them`() {

        // Given
        val bookInfo1 = BookInfo(100, "Kotlin入門", "ことりん太郎", false)
        val bookInfo2 = BookInfo(200, "Java入門", "じゃば太郎", true)
        val bookInfo3 = BookInfo(300, "Spring入門", "すぷりんぐ太郎", true)
        val bookInfo4 = BookInfo(400, "Kotlin実践", "ことりん太郎", false)
        val bookInfoNone = BookInfo(9999, "未登録書籍", "アノニマス", false)

        val testBookWithRentalList =
            listOf(
                BookWithRental(
                    Book(bookInfo1.id, bookInfo1.title, bookInfo1.author, LocalDate.now()),
                    null
                ),
                BookWithRental(
                    Book(bookInfo2.id, bookInfo2.title, bookInfo2.author, LocalDate.now()),
                    Rental(bookInfo2.id, 1000, LocalDateTime.now(), LocalDateTime.now().plusDays(14))
                ),
                BookWithRental(
                    Book(bookInfo3.id, bookInfo3.title, bookInfo3.author, LocalDate.now()),
                    Rental(bookInfo3.id, 1000, LocalDateTime.now(), LocalDateTime.now().plusDays(14))
                ),
                BookWithRental(
                    Book(bookInfo4.id, bookInfo4.title, bookInfo4.author, LocalDate.now()),
                    null
                )
            )
        testMapper.createBookWithRental(testBookWithRentalList)

        val user = "admin@example.com"
        val pass = "admin"
        webClient.login(user, pass)

        // When
        val response = webClient
            .get()
            .uri("/book/list")
            .exchange()
            .expectBody<GetBookListResponse>()
            .returnResult()

        // Then
        val expected = GetBookListResponse(listOf(bookInfo1, bookInfo2, bookInfo3, bookInfo4))
        SoftAssertions().apply {
            assertThat(response.status).isEqualTo(HttpStatus.OK)
            assertThat(response.responseBody?.bookList).containsExactlyInAnyOrderElementsOf(expected.bookList)
            assertThat(response.responseBody?.bookList).`as`("登録していない書籍は含まれていないこと").doesNotContain(bookInfoNone)
        }.assertAll()
    }

    @ParameterizedTest(name = "書籍を登録する: user => {0}, registeredStatus => {3}, getStatus => {5}")
    @MethodSource("dataOfRegister")
    @DisplayName("書籍を登録する")
    fun `when book is register then get this`(
        user: String,
        pass: String,
        book: Book,
        postStatus: HttpStatus,
        expectedRegisteredBook: AdminBookResponse?,
        getStatus: HttpStatus,
        expectedBookDetail: GetBookDetailResponse?
    ) {
        // Given
        webClient.login(user, pass)

        // When
        val objectMapper = jsonConverter.objectMapper()
        val jsonObject = objectMapper.createObjectNode().apply {
            put("id", book.id)
            put("title", book.title)
            put("author", book.author)
            put("release_date", book.releaseDate.format(DateTimeFormatter.ISO_DATE))
        }.let { objectMapper.writeValueAsString(it) }

        val postResponse = webClient
            .post()
            .uri("/admin/book/register")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(jsonObject)
            .exchange()
            .expectBody<AdminBookResponse>()
            .returnResult()

        // Then
        val getResponse = webClient
            .get()
            .uri("/book/detail/${book.id}")
            .exchange()
            .expectBody<String>()
            .returnResult()

        // 登録できないケースではGetBookDetailResponseのプロパティがNon-Nullのため `expectBody<GetBookDetailResponse>()` で変換すると例外が発生する、
        // このため専用の変換処理を使ってNullを返すようにしている
        val result = jsonConverter.toObject(getResponse.responseBody, GetBookDetailResponse::class.java)

        SoftAssertions().apply {
            assertThat(postResponse.status).isEqualTo(postStatus)
            assertThat(postResponse.responseBody).isEqualTo(expectedRegisteredBook)
            assertThat(getResponse.status).isEqualTo(getStatus)
            assertThat(result).isEqualTo(expectedBookDetail)
        }.assertAll()
    }

    fun WebTestClient.login(user: String, pass: String): WebTestClient.ResponseSpec {

        val loginForm = LinkedMultiValueMap<String, String>().apply {
            add("email", user)
            add("pass", pass)
        }

        return webClient
            .post()
            .uri("/login")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .accept(MediaType.TEXT_HTML)
            .body(BodyInserters.fromFormData(loginForm))
            .exchange()
    }
}

```

## テストを実行する

あとは、 `./gradlew integrationTest` でタスクを実行すればIntegrationテストが行われる。また `./gradlew check` とすれば、Unitテスト実行後にIntegrationテストを行うようになる。

尚、Testcontainers が Dockerを利用するので、テストを実行する環境で Docker Engine (MacやWindowsならDocker Desktop)が起動していることを確認すること

### GitHub Actions で実行する

GitHub Actionなら特別な設定をすることなく、Dockerコンテナが起動できるので、`./gradlew check` を実行すれば、難なくIntegrationテストが実行できる。

``` workflow.yml
# This is a basic workflow to help you get started with Actions

name: CI Workflow

# Controls when the action will run. 
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - name: Checkout repository
        uses: actions/checkout@v3

      # Set up JDK 19
      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '19'
          check-latest: true
          cache: 'gradle'

      # Setup Gradle
      - name: Setup Gradle
        uses: gradle/gradle-build-action@v2

      # Build with Gradle
      - name: Build with Gradle
        run: ./gradlew clean check jacocoTestReport

      # Test Reportをアーカイブする
      - name: Archive Test Reports
        uses: actions/upload-artifact@v2
        if: always()
        with:
          name: test reports
          path: build/reports

      # 不要なキャッシュの削除
      - name: Cleanup Gradle Cache
        run: |
          rm -f ~/.gradle/caches/modules-2/modules-2.lock
          rm -f ~/.gradle/caches/modules-2/gc.properties

```

## 参考

https://docs.spring.io/spring-boot/docs/3.0.3/reference/htmlsingle/#features.testing.spring-boot-applications

https://www.baeldung.com/spring-boot-testing