Kotlin テストパターン
Kotest と MockK を使用し、TDD手法に従って信頼性が高く保守しやすいテストを記述するための包括的なKotlinテストパターン集です。
使用タイミング
- 新しいKotlin関数やクラスを作成するとき
- 既存のKotlinコードにテストカバレッジを追加するとき
- プロパティベースのテストを実装するとき
- KotlinプロジェクトでTDDワークフローに従うとき
- Koverを設定してコードカバレッジを確認するとき
仕組み
- テスト対象を特定する — テストする関数、クラス、モジュールを見つける
- Kotestスペックを書く — テスト範囲に合ったスペックスタイル(StringSpec、FunSpec、BehaviorSpec)を選択する
- 依存関係をモックする — MockKを使用してテスト対象を分離する
- テストを実行する(RED) — 期待通りのエラーでテストが失敗することを確認する
- コードを実装する(GREEN) — テストに合格する最小限のコードを書く
- リファクタリングする — テストが緑のまま実装を改善する
- カバレッジを確認する —
./gradlew koverHtmlReportを実行し、80%以上のカバレッジを確認する
例
以下のセクションには、各テストパターンの詳細で実行可能な例が含まれています。
クイックリファレンス
- Kotestスペック — Kotest Spec Styles のStringSpec、FunSpec、BehaviorSpec、DescribeSpecの例
- モッキング — MockK のMockKセットアップ、コルーチンモッキング、引数キャプチャ
- TDDチュートリアル — Kotlin向けTDDワークフロー のEmailValidatorを使ったRED/GREEN/REFACTORの完全なサイクル
- カバレッジ — Kover Coverage のKover設定とコマンド
- Ktorテスト — Ktor testApplication Testing のtestApplicationセットアップ
Kotlin向けTDDワークフロー
RED-GREEN-REFACTORサイクル
RED -> 最初に失敗するテストを書く
GREEN -> テストに合格する最小限のコードを書く
REFACTOR -> テストが緑のままコードを改善する
REPEAT -> 次の要件に進む
KotlinでのステップバイステップTDD
// ステップ 1: インターフェース/シグネチャを定義する
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// ステップ 2: 失敗するテストを書く(RED)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({
"有効なメールアドレスは成功を返す" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"空のメールアドレスは失敗を返す" {
validateEmail("").shouldBeFailure()
}
"@を含まないメールアドレスは失敗を返す" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// ステップ 3: テストを実行する - 失敗を確認する
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// ステップ 4: 最小限のコードを実装する(GREEN)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("メールアドレスは空にできません"))
if ('@' !in email) return Result.failure(IllegalArgumentException("メールアドレスには@が必要です"))
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("メールアドレスの形式が無効です"))
return Result.success(email)
}
// ステップ 5: テストを実行する - 成功を確認する
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// ステップ 6: 必要に応じてリファクタリングし、テストがまだ成功することを確認する
Kotest Spec Styles
StringSpec(最もシンプル)
class CalculatorTest : StringSpec({
"2つの正の数を加算する" {
Calculator.add(2, 3) shouldBe 5
}
"負の数を加算する" {
Calculator.add(-1, -2) shouldBe -3
}
"ゼロを加算する" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec(JUnit風)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUserはユーザーが見つかった場合にユーザーを返す") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUserは見つからない場合に例外をスローする") {
coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> {
service.getUser("999")
}
}
})
BehaviorSpec(BDDスタイル)
class OrderServiceTest : BehaviorSpec({
val repository = mockk<OrderRepository>()
val paymentService = mockk<PaymentService>()
val service = OrderService(repository, paymentService)
Given("有効な注文リクエスト") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("注文が行われる") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("確定された注文を返す") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("支払いが請求される") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("支払いが失敗する") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("PaymentExceptionをスローする") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec(RSpecスタイル)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("有効な入力の場合") {
it("通常のユーザーを受け入れる") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("無効な名前の場合") {
it("空白の名前を拒否する") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("最大長を超える名前を拒否する") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Kotest Matchers
コアMatcher
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// 等価性
result shouldBe expected
result shouldNotBe unexpected
// 文字列
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// コレクション
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Null
result.shouldNotBeNull()
result.shouldBeNull()
// 型
result.shouldBeInstanceOf<User>()
// 数値
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// 例外
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "年齢は正の値である必要があります"
shouldNotThrow<Exception> {
validateAge(25)
}
カスタムMatcher
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "ユーザー ${value.id} はアクティブで最終ログイン日時が必要です" },
{ "ユーザー ${value.id} はアクティブであってはいけません" },
)
}
// 使用法
user should beActiveUser()
MockK
基本モッキング
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: デフォルト値を返す
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUserはリポジトリに委譲する") {
val expected = User(id = "1", name = "Alice")
every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected
verify(exactly = 1) { repository.findById("1") }
}
test("findUserは不明なIDに対してnullを返す") {
every { repository.findById(any()) } returns null
val result = service.findUser("unknown")
result.shouldBeNull()
}
})
コルーチンモッキング
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUserのサスペンド関数") {
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice"
coVerify { repository.findById("1") }
}
test("getUserに遅延がある場合") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // 非同期処理をシミュレート
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
引数キャプチャ
test("saveはユーザー引数をキャプチャする") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"
slot.captured.id.shouldNotBeNull()
}
Spyと部分モッキング
test("実際のオブジェクトをスパイする") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // オーバーライドされた
// 他のメソッドは実際の実装を使用
}
コルーチンテスト
サスペンド関数のrunTest
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("同時フェッチが一緒に完了する") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("遅延後にタイムアウトする") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // 100ms以上かかる
}
}
}
}
})
Flowのテスト
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({
test("observeUsersは更新を発行する") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsersは入力をデバウンスする") {
runTest {
val service = SearchService()
val queries = MutableSharedFlow<String>()
val results = mutableListOf<List<User>>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // これだけが検索をトリガーする
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("制御された実行のためにテストディスパッチャを使用する") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completed = false
launch {
delay(1000)
completed = true
}
completed shouldBe false
advanceTimeBy(1000)
completed shouldBe true
}
}
})
プロパティベーステスト
Kotestプロパティテスト
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// 注: 以下のシリアル化ラウンドトリップテストでは、Userデータクラスに
// @Serializable(kotlinx.serializationから)のアノテーションが必要です。
class PropertyTest : FunSpec({
test("文字列の反転は対合である") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("リストのソートは冪等である") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("シリアル化ラウンドトリップでデータが保持される") {
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
User(name = name, email = "$email@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
カスタムジェネレータ
class CustomGeneratorTest : FunSpec({
test("順序付きのユーザーデータを生成する") {
val userArb = Arb.bind(
Arb.string(1..50),
Arb.email(),
Arb.int(18..120),
) { name, email, age ->
User(name = name, email = email, age = age)
}
checkAll(userArb) { user ->
user.age shouldBeGreaterThanOrEqual 18
user.email shouldContain "@"
}
}
test("ネストされたデータ構造を生成する") {
data class Order(val id: String, val items: List<OrderItem>)
val orderArb = Arb.bind(
Arb.string(10..20),
Arb.list(Arb.bind(Arb.string(), Arb.int(1..100)) { name, qty ->
OrderItem(name, qty)
}, 1..10),
) { id, items -> Order(id, items) }
checkAll(orderArb) { order ->
order.id.length shouldBeGreaterThanOrEqual 10
order.items.shouldNotBeEmpty()
}
}
})
fun Arb.Companion.email(): Arb<String> = arbitrary {
"${randomString()}@${randomString()}.com"
}
fun randomString(length: Int = 10) = (1..length)
.map { ('a'..'z').random() }
.joinToString("")
Koverカバレッジ
Gradle設定(build.gradle.kts)
plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.0"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.0"
id("org.jetbrains.kotlinx.kover") version "0.8.0"
}
// Koverの設定
kover {
// カバレッジルール:行カバレッジ80%
coverageRules {
rule {
min = 0.8
}
}
// ジェネレーションから特定のクラスを除外
excludes {
classes(
"com.example.generated.*",
"com.example.MainKt",
)
}
}
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}
コマンド
# テストを実行し、HTMLレポートを生成する
./gradlew koverHtmlReport
# 検証ビルド中にXMLレポートを生成する
./gradlew koverXmlReport
# テストカバレッジルールを検証する
./gradlew koverVerify
# Koverレポートの出力場所:
# build/reports/kover/html/index.html
# build/reports/kover/xml/report.xml
重要な注意事項:
- カバレッジルールは
koverVerifyタスクで検証されます。検証に失敗した場合、ビルドは失敗します。 - 除外は
kover.excludesブロックにクラスまたはパターンを追加することで設定します。 - 変更を加えた後は、常に
./gradlew koverHtmlReportを実行して更新されたレポートを確認してください。
Ktor testApplicationテスト
基本的なKtorテスト
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
class UserRoutesTest : FunSpec({
test("GET /users が200を返す") {
testApplication {
application {
configureRoutes()
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
}
}
test("POST /users が201を返す") {
testApplication {
application {
configureRoutes()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody("""{"name":"Alice","email":"[email protected]"}""")
}
response.status shouldBe HttpStatusCode.Created
}
}
test("GET /users/:id が存在しないIDに対して404を返す") {
testApplication {
application {
configureRoutes()
}
val response = client.get("/users/999")
response.status shouldBe HttpStatusCode.NotFound
}
}
})
モックリポジトリを使ったKtorテスト
class UserRoutesMockTest : FunSpec({
test("モックリポジトリを使ってユーザーを取得する") {
val repository = mockk<UserRepository>()
every { repository.findAll() } returns listOf(
User(id = "1", name = "Alice"),
User(id = "2", name = "Bob"),
)
testApplication {
application {
configureRoutes(repository)
}
val response = client.get("/users")
val body = response.bodyAsText()
response.status shouldBe HttpStatusCode.OK
body shouldContain "Alice"
body shouldContain "Bob"
}
}
})
認証が必要なエンドポイントのテスト
class AuthenticatedRoutesTest : FunSpec({
val jwtSecret = "test-secret"
test("認証なしのリクエストは401を返す") {
testApplication {
application {
configureAuthRoutes(jwtSecret)
}
val response = client.get("/protected")
response.status shouldBe HttpStatusCode.Unauthorized
}
}
test("有効なJWTトークンで認証する") {
testApplication {
application {
configureAuthRoutes(jwtSecret)
}
val token = generateTestToken("user-1", jwtSecret)
val response = client.get("/protected") {
bearerAuth(token)
}
response.status shouldBe HttpStatusCode.OK
}
}
})
ベストプラクティスと規則
命名規則
テスト名は、テスト対象となる動作を記述する必要があります。
✅ "有効なメールアドレスは検証に合格する"
✅ "空の文字列はIllegalArgumentExceptionをスローする"
❌ "test1"
❌ "メールのテスト"
テスト構成
Kotlinプロジェクトの標準的なテスト構成:
src/
main/
kotlin/
com/example/
EmailValidator.kt
test/
kotlin/
com/example/
EmailValidatorTest.kt
コード品質の列
| 品質 | 説明 |
|---|---|
| 読みやすさ | スペックスタイルと記述的なテスト名を使用する。テストは仕様書として読めるべき |
| 分離 | MockKを使用して依存関係をモックし、テスト対象を分離する |
| 速度 | テストが高速(ミリ秒単位)であることを確認する。低速なテストは統合テストに移動する |
| 決定性 | テスト毎にテストデータをリセットする。テスト間の共有状態を避ける |
| カバレッジ | コードの80%以上をカバーすることを目指し、重要な分岐をカバーする |
よくある落とし穴
- テストを実行し忘れる — 変更後は常に
./gradlew testを実行する - 偽陽性テスト — アサーションが期待通りに動作することを確認する(例:
shouldBeが実際に呼び出されているか) - 脆弱なテスト — 実装の詳細ではなく、動作に対してテストする
- テストが遅い — ネットワーク/ファイルI/Oを除く高速なテストに留める
- MockKのrelaxedを見逃す — 未定義の動作に対して予期しないデフォルトが返される可能性があるため、relaxed = trueを使用する際は注意する