これは何?
バックエンドサービスが提供するAPIが想定通りのレスポンスを返すか評価するためのIntegrationテストを書きGradleから実行できるようにし、GitHub Actionsで実行できるようにするまでの流れをまとめた
リポジトリ
尚、今回のリポジトリのコードは下記の書籍のサンプルコードをベースとさせて頂いています。
前提
この記事における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テスト専用のコードと実行を管理する。理由は主に3つ
- "test"ディレクトリ内のテストはUnitテスト用で、"main"ディレクトリのクラスに対応するが、IntegrationテストはAPIに対応するのでディレクトリ構造が違うため
- Unitテストと分けて実行できるようにしたかったため
- Integationテストだけで利用する依存関係を定義したかったため
今回は Gradle公式の設定を参考にし、Integrationテストコードを配置する intTest
ディレクトリを作成し、integrationTest
タスクを実行するこでテストコードを実行できるようにした。
更に既存の check
タスクを実行すると test
タスクの後に integrationTest
タスクが実行されるようにもした。
intTestImplementation
と intTestRuntimeOnly
の設定は dependencies
ブロックよりも前にすること。
intTestImplementation
で追加する依存関係が認識されないため。
......
// 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にアクセスして利用することができる
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)
}
}
}
Spring Boot 3.1 から Testcontainers との連携が強化され、上記 setup()
で行っているapplication.properties への値の登録を不要にすることができます。
ただし redis コンテナに GenericContainer を使っている関係か、redisのポート番号のみ 引き続き getFirstMappedPort()
で プロパティ値への登録が必要でした。(redis の host は GenericContainerにアクセッサがあるため省略可能でした)
WebTestClientを利用する
従来だと REST APIをテストする際のクライアントとして、TestRestTemplate
が利用されており今回も最初はこれでテストコードを書いていたのだが、TestRestTemplateは開発が停滞しており、今後は WebFluxで利用されている WebTestClient
をWebMVCでも利用することが増えそうに読めたので WebTestClientを使うことにした。
テスト時にSessionIdとCSRFトークンをセットできるようにする
バックエンドサービスはSpring Security と Spring Sessionを利用して、CSRF対策および認証済みユーザーのセッション管理を行なっている。このためIntegrationテストにおいてもWebTestClientからリクエストを送信するときにサービスから発行されている CSRFトークンとSessionID をリクエストヘッダに含めなければならない。これを実現するために ExchangeFilterFunctionを利用している。
ExchangeFilterFunctionは、WebTestClientのリクエスト送信からレスポンス受信までの処理をインターセプトすることができる。これを利用して直前のレスポンスのヘッダに含まれるSessionIDとCSRFトークンを格納し、次回のリクエストの際にヘッダにセットする処理を入れている。
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を取得しておけば良い。
@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起動時にロードしている。
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テストが実行できる。
# 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
参考