Kotest

Kotest: Kotlin テストフレームワーク完全ガイド

目次

  1. はじめに
  2. Kotestの基本概念
  3. アーキテクチャ概要
  4. 主要な機能
  5. テストスタイル
  6. 設定と構成
  7. 高度な機能
  8. 実装例
  9. ベストプラクティス

はじめに

Kotestは、Kotlin専用に設計されたモダンで表現力豊かなテストフレームワークです。JUnitやTestNGなどの従来のフレームワークとは異なり、Kotlinの言語機能を最大限に活用し、テストコードの可読性と記述性を向上させます。

Kotestは以下の特徴を持ちます:

  • 複数のテストスタイル:FunSpec、StringSpec、DescribeSpec、BehaviorSpec、FreeSpec、WordSpec、ShouldSpecなど7種類以上のテストスタイルをサポート
  • 豊富なアサーション:数百個の組み込みマッチャーとカスタムマッチャーの作成機能
  • プロパティベーステスト:自動生成されたテストデータを使用したプロパティベーステストのサポート
  • スケーラビリティ:大規模テストスイートの並列実行と効率的なリソース管理
  • DI対応:Spring、Guice、Quarkusなどの依存性注入フレームワークとの統合
  • 拡張可能性:テストライフサイクルのカスタマイズと拡張ポイントの豊富さ

Kotestの基本概念

テストコンテナとテストケース

Kotestの基本構造は、テストコンテナ(テストスイート)とテストケースから成り立ちます。

テストコンテナ(TestCase)
  ├── テストケース1
  ├── ネストされたテストコンテナ
  │   ├── テストケース2
  │   └── テストケース3
  └── テストケース4

テストライフサイクル

Kotestのテストライフサイクルは以下の段階で構成されます:

  1. 初期化フェーズ(Initialization Phase)

    • テストクラスのインスタンス化
    • テストコンテナの構築
  2. 準備フェーズ(Setup Phase)

    • beforeSpec() / afterSpec(): スペック全体の前後処理
    • beforeEach() / afterEach(): 各テストの前後処理
    • beforeContainer() / afterContainer(): ネストされたコンテナの前後処理
  3. 実行フェーズ(Execution Phase)

    • テストケースの並列または順序実行
  4. 破棄フェーズ(Teardown Phase)

    • リソースのクリーンアップ

隔離戦略

Kotestは複数の隔離戦略をサポートします:

  • InstancePerLeaf(デフォルト):各テストケースに対してテストクラスの新しいインスタンスを作成
  • InstancePerTest:各テストケースに対してテストクラスの新しいインスタンスを作成
  • SingleInstance:すべてのテストに対して同じインスタンスを使用
  • InstancePerRun:テスト実行全体で同じインスタンスを使用

アーキテクチャ概要

コアコンポーネント

┌─────────────────────────────────────────────────────┐
│                  Kotest Core                        │
├─────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────┐    │
│  │  Test Discovery & Execution Engine        │    │
│  │  - ClassScanner                           │    │
│  │  - SpecInstantiator                       │    │
│  │  - TestCaseExecutor                       │    │
│  │  - ParallelExecutor                       │    │
│  └────────────────────────────────────────────┘    │
│                                                     │
│  ┌────────────────────────────────────────────┐    │
│  │  Test Style System                        │    │
│  │  - AbstractSpec                           │    │
│  │  - RootTestListener                       │    │
│  │  - TestCaseContext                        │    │
│  └────────────────────────────────────────────┘    │
│                                                     │
│  ┌────────────────────────────────────────────┐    │
│  │  Assertion & Matcher Framework            │    │
│  │  - Matcher<T>                             │    │
│  │  - Result                                 │    │
│  │  - Internal Matchers Library              │    │
│  └────────────────────────────────────────────┘    │
│                                                     │
│  ┌────────────────────────────────────────────┐    │
│  │  Configuration System                     │    │
│  │  - ProjectConfig                          │    │
│  │  - ConfigLoader                           │    │
│  └────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────┘
         │                    │                  │
         ▼                    ▼                  ▼
    ┌─────────┐          ┌─────────┐      ┌──────────┐
    │  Arrow  │          │ Property│      │ Mocking  │
    │ Testing │          │ Testing │      │ Library  │
    └─────────┘          └─────────┘      └──────────┘

主要なクラス階層

AbstractSpec
  ├── FunSpec
  ├── StringSpec
  ├── DescribeSpec
  ├── BehaviorSpec
  ├── FreeSpec
  ├── WordSpec
  ├── ShouldSpec
  └── AnnotationSpec

実行フロー

1. テスト発見
   ↓
2. スペック読み込み
   ↓
3. テストコンテナ構築
   ↓
4. 並列実行スケジューリング
   ↓
5. テスト実行(ライフサイクルコールバック付き)
   ↓
6. 結果集約
   ↓
7. リポーター通知

主要な機能

1. テストスタイル

FunSpec:関数型スタイル

class UserServiceFunSpecTest : FunSpec({
  test("getUserById returns user") {
    val service = UserService()
    val user = service.getUserById(1)
    user.name shouldBe "John"
  }
  
  test("getUserById throws when invalid") {
    val service = UserService()
    shouldThrow<NotFoundException> {
      service.getUserById(-1)
    }
  }
})

DescribeSpec:RSpec風スタイル

class UserServiceDescribeSpecTest : DescribeSpec({
  describe("UserService") {
    describe("getUserById") {
      it("returns user when exists") {
        val service = UserService()
        val user = service.getUserById(1)
        user.name shouldBe "John"
      }
      
      it("throws when invalid") {
        val service = UserService()
        shouldThrow<NotFoundException> {
          service.getUserById(-1)
        }
      }
    }
    
    describe("createUser") {
      it("creates and returns user") {
        val service = UserService()
        val user = service.createUser("Jane", "jane@example.com")
        user.name shouldBe "Jane"
      }
    }
  }
})

BehaviorSpec:BDD風スタイル

class UserServiceBehaviorSpecTest : BehaviorSpec({
  given("a UserService") {
    val service = UserService()
    
    when("getting an existing user") {
      val user = service.getUserById(1)
      
      then("it returns the correct user") {
        user.name shouldBe "John"
      }
      
      then("it has an email") {
        user.email shouldNotBe null
      }
    }
    
    when("getting a non-existent user") {
      then("it throws NotFoundException") {
        shouldThrow<NotFoundException> {
          service.getUserById(-1)
        }
      }
    }
  }
})

StringSpec:シンプルなスタイル

class UserServiceStringSpecTest : StringSpec({
  "getUserById returns user" {
    val service = UserService()
    val user = service.getUserById(1)
    user.name shouldBe "John"
  }
  
  "getUserById throws when invalid" {
    val service = UserService()
    shouldThrow<NotFoundException> {
      service.getUserById(-1)
    }
  }
})

WordSpec:単語連鎖スタイル

class UserServiceWordSpecTest : WordSpec({
  "UserService" should {
    "getUserById returns user" {
      val service = UserService()
      val user = service.getUserById(1)
      user.name shouldBe "John"
    }
    
    "createUser creates user" {
      val service = UserService()
      val user = service.createUser("Jane", "jane@example.com")
      user.name shouldBe "Jane"
    }
  }
})

2. アサーションと マッチャー

Kotestは数百個の組み込みマッチャーを提供します:

// 基本的なマッチャー
value shouldBe expected
value shouldNotBe notExpected
value shouldEqual expected

// 型チェック
value shouldBeInstanceOf<String>()
value.shouldBeInstanceOf<String>()

// Null チェック
value.shouldBeNull()
value.shouldNotBeNull()

// 例外テスト
shouldThrow<IllegalArgumentException> {
  throwingFunction()
}

shouldThrowAny {
  throwingFunction()
}

shouldThrowMessage("error message") {
  throwingFunction()
}

// コレクションマッチャー
list.shouldBeEmpty()
list.shouldHaveSize(3)
list.shouldContain("item")
list.shouldContainAll("a", "b", "c")
list.shouldNotContainDuplicates()

// 文字列マッチャー
"hello".shouldBeEmpty()
"hello".shouldStartWith("he")
"hello".shouldEndWith("lo")
"hello".shouldContain("ll")
"hello".shouldMatch(Regex("h.*o"))

// 数値マッチャー
5 shouldBe between(3, 7)
5 shouldBeGreaterThan 3
5 shouldBeLessThan 10
5.shouldBeEven()
5.shouldBeOdd()

// ブール値マッチャー
true.shouldBeTrue()
false.shouldBeFalse()

// 日付マッチャー
date.shouldHaveDayOfMonth(15)
date.shouldHaveMonth(Month.DECEMBER)
date1.shouldBeBefore(date2)
date1.shouldBeAfter(date2)

カスタムマッチャーの作成

fun String.shouldBeValidEmail() {
  this should match("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}$".toRegex(RegexOption.IGNORE_CASE))
}

fun <T> T.shouldBeOfType(expected: Class<T>) {
  this.shouldBeInstanceOf(expected)
}

// 複合マッチャー
fun String.shouldBeValidPhoneNumber() {
  this should {
    satisfy {
      it.length in 10..15 && it.all { c -> c.isDigit() || c == '-' }
    }
  }
}

3. プロパティベーステスト

Kotestはプロパティベーステストのための包括的なサポートを提供します:

class PropertyBasedTestExample : FunSpec({
  test("addition is commutative") {
    forAll(Arb.int(), Arb.int()) { a, b ->
      a + b shouldBe b + a
    }
  }
  
  test("list concatenation is associative") {
    forAll(
      Arb.list(Arb.string()),
      Arb.list(Arb.string()),
      Arb.list(Arb.string())
    ) { a, b, c ->
      (a + b) + c shouldBe a + (b + c)
    }
  }
  
  test("string length") {
    forAll(Arb.string(10..100)) { s ->
      s.length shouldBeInRange 10..100
    }
  }
})

Arb(任意値生成)

// 基本型
Arb.int()
Arb.long()
Arb.float()
Arb.double()
Arb.boolean()
Arb.string()
Arb.char()

// 範囲指定
Arb.int(1..100)
Arb.long(1000L..5000L)
Arb.string(5..20)

// コレクション
Arb.list(Arb.int())
Arb.set(Arb.string())
Arb.map(Arb.string(), Arb.int())

// カスタムジェネレーター
val userArb = Arb.bind(
  Arb.string(),
  Arb.email()
) { name, email ->
  User(name, email)
}

4. データ駆動テスト

class DataDrivenTestExample : FunSpec({
  test("addition") {
    forAll(
      row(1, 2, 3),
      row(5, 5, 10),
      row(-1, 1, 0),
      row(0, 0, 0)
    ) { a, b, expected ->
      a + b shouldBe expected
    }
  }
})

5. タイムアウトと終了

class TimeoutExample : FunSpec({
  test("should complete within timeout").config(timeout = 100.milliseconds) {
    delay(50)
    true.shouldBeTrue()
  }
  
  test("should fail on timeout").config(timeout = 50.milliseconds) {
    delay(100) // Will timeout
  }
  
  test("test will be invoked 3 times").config(invocations = 3) {
    // This test runs 3 times
  }
})

設定と構成

kotest.properties ファイル

プロジェクトルートに kotest.properties ファイルを作成:

# 並列実行の設定
kotest.framework.execution.parallelism=8

# テスト実行モード
kotest.framework.execution.mode=parallel

# タイムアウトの設定
kotest.framework.timeouts.default=300s
kotest.framework.timeouts.execution=600s

# 隔離戦略
kotest.framework.isolation.instance=InstancePerLeaf

# テスト順序
kotest.framework.testcase.ordering=Random

# スタックトレース表示
kotest.framework.stacktrace.format=short

# ディスプレイ・フォーマッター
kotest.framework.display.filter=all
kotest.framework.display.order=property

# 失敗時の動作
kotest.framework.fail.on.empty.test.suite=true

# レポーター設定
kotest.framework.reporters=io.kotest.engine.reporters.TeamCityTestEventListener

ProjectConfig クラス

ProjectConfig を拡張してプログラマティックな設定を行う:

object TestProjectConfig : ProjectConfig() {
  override val parallelism: Int = 8
  override val timeout: Duration = 300.seconds
  
  override val invocations: Int? = null
  
  override val failOnEmptyTestSuite: Boolean = true
  
  override val logLevel: LogLevel = LogLevel.Info
  
  override val testCaseOrder: TestCaseOrder = TestCaseOrder.Random
  
  override val isolationMode: IsolationMode = IsolationMode.InstancePerLeaf
  
  override val concurrentSpecs: Int? = null
  
  override val concurrentTests: Int? = null
  
  override val testNameTruncateLength: Int? = null
  
  override val displayFullTestPath: Boolean = false
  
  // ライフサイクルコールバック
  override suspend fun beforeAll() {
    println("Running tests...")
  }
  
  override suspend fun afterAll() {
    println("Tests completed")
  }
  
  // リスナー登録
  override fun listeners(): List<TestListener> = listOf(
    MyCustomTestListener()
  )
  
  // 拡張機能登録
  override fun extensions(): List<Extension> = listOf(
    MyCustomExtension()
  )
}

gradle.properties での設定

# Kotest設定
kotest.parallelism=8
kotest.timeout=300000
kotest.isolation=InstancePerLeaf
kotest.test.order=Random
kotest.fail.empty=true

build.gradle.kts での Kotest 設定

dependencies {
  testImplementation("io.kotest:kotest-runner-junit5:5.6.2")
  testImplementation("io.kotest:kotest-assertions-core:5.6.2")
  testImplementation("io.kotest:kotest-property:5.6.2")
  testImplementation("io.kotest:kotest-extensions-spring:5.6.2")
  testImplementation("io.kotest:kotest-extensions-mockserver:5.6.2")
}

tasks.test {
  useJUnitPlatform()
  
  systemProperties = mapOf(
    "kotest.framework.execution.parallelism" to "8",
    "kotest.framework.execution.timeout" to "300000",
    "kotest.framework.isolation.instance" to "InstancePerLeaf"
  )
}

高度な機能

1. テストリスナー

class CustomTestListener : TestListener {
  override suspend fun beforeTest(testCase: TestCase) {
    println("Starting test: ${testCase.name}")
  }
  
  override suspend fun afterTest(testCase: TestCase, result: TestResult) {
    println("Completed test: ${testCase.name} - ${result.status}")
    when (result.status) {
      TestStatus.Success -> println("✓ Passed")
      TestStatus.Failure -> println("✗ Failed: ${result.errorOrNull}")
      TestStatus.Ignored -> println("⊘ Ignored")
      TestStatus.Skipped -> println("⊘ Skipped")
      TestStatus.Error -> println("✗ Error: ${result.errorOrNull}")
    }
  }
  
  override suspend fun specFinished(klass: KClass<out Spec>, results: Map<TestCase, TestResult>) {
    val passed = results.values.count { it.isSuccess }
    val failed = results.values.count { it.isFailure }
    println("Spec ${klass.simpleName}: $passed passed, $failed failed")
  }
}

2. テスト拡張(Extension)

class DatabaseExtension : TestExtension {
  private lateinit var database: TestDatabase
  
  override suspend fun beforeSpec(spec: Spec) {
    database = TestDatabase()
    database.start()
  }
  
  override suspend fun afterSpec(spec: Spec) {
    database.stop()
  }
  
  override suspend fun beforeEach(testCase: TestCase) {
    database.clearData()
  }
}

class CoroutineExtension : TestExtension {
  override suspend fun beforeTest(testCase: TestCase) {
    // Coroutineセットアップ
  }
}

3. Spring 統合

@SpringBootTest
class UserServiceSpringTest : FunSpec() {
  override fun extensions() = listOf(SpringExtension)
  
  @Autowired
  private lateinit var userService: UserService
  
  @Autowired
  private lateinit var userRepository: UserRepository
  
  init {
    test("should create user") {
      val user = userService.createUser("John", "john@example.com")
      user.id shouldNotBe null
    }
    
    test("should find user") {
      val saved = userRepository.save(User(name = "Jane", email = "jane@example.com"))
      val found = userService.getUserById(saved.id!!)
      found.name shouldBe "Jane"
    }
  }
}

4. Mockk 統合

class UserServiceMockTest : FunSpec({
  test("should call repository") {
    val userRepository = mockk<UserRepository>()
    val userService = UserService(userRepository)
    
    every { userRepository.findById(1) } returns User(1, "John", "john@example.com")
    
    val user = userService.getUserById(1)
    
    user.name shouldBe "John"
    verify { userRepository.findById(1) }
  }
})

5. ネストされたテスト構造

class NestedTestStructureExample : DescribeSpec({
  describe("User API") {
    describe("GET /users/{id}") {
      describe("when user exists") {
        describe("and user has profile") {
          it("returns user with profile") {
            // Test implementation
          }
          
          it("includes email") {
            // Test implementation
          }
        }
      }
      
      describe("when user does not exist") {
        it("returns 404") {
          // Test implementation
        }
      }
    }
    
    describe("POST /users") {
      describe("with valid input") {
        it("creates user") {
          // Test implementation
        }
      }
      
      describe("with invalid input") {
        it("returns validation error") {
          // Test implementation
        }
      }
    }
  }
})

6. テストタグとフィルタリング

class TaggingExample : FunSpec({
  test("fast test").config(tags = setOf(Tag("fast"), Tag("unit"))) {
    true.shouldBeTrue()
  }
  
  test("slow test").config(tags = setOf(Tag("slow"), Tag("integration"))) {
    Thread.sleep(1000)
    true.shouldBeTrue()
  }
  
  test("database test").config(tags = setOf(Tag("database"), Tag("integration"))) {
    // Requires database
  }
})

object TestTags {
  val FAST = Tag("fast")
  val SLOW = Tag("slow")
  val UNIT = Tag("unit")
  val INTEGRATION = Tag("integration")
  val DATABASE = Tag("database")
}

// 使用例
class FilteredTestExample : FunSpec({
  test("fast test").config(tags = setOf(TestTags.FAST, TestTags.UNIT)) {
    true.shouldBeTrue()
  }
})

7. 条件付き実行

class ConditionalExecutionExample : FunSpec({
  test("should run on CI").config(
    enabled = System.getenv("CI") != null
  ) {
    // Only runs on CI
  }
  
  test("should skip on Windows").config(
    enabled = !System.getProperty("os.name").contains("Windows")
  ) {
    // Skipped on Windows
  }
  
  test("should run if condition met").config(
    enabledIf = {
      val shouldRun = testConfig.shouldRun()
      shouldRun
    }
  ) {
    true.shouldBeTrue()
  }
})

実装例

例1: 計算機サービスのテスト

data class CalculationResult(
  val operation: String,
  val operand1: Double,
  val operand2: Double,
  val result: Double
)

class Calculator {
  fun add(a: Double, b: Double): Double = a + b
  fun subtract(a: Double, b: Double): Double = a - b
  fun multiply(a: Double, b: Double): Double = a * b
  fun divide(a: Double, b: Double): Double {
    if (b == 0.0) throw IllegalArgumentException("Division by zero")
    return a / b
  }
}

class CalculatorTest : DescribeSpec({
  val calculator = Calculator()
  
  describe("Calculator") {
    describe("add operation") {
      it("should add two positive numbers") {
        val result = calculator.add(5.0, 3.0)
        result shouldBe 8.0
      }
      
      it("should handle negative numbers") {
        val result = calculator.add(-5.0, 3.0)
        result shouldBe -2.0
      }
      
      it("should handle decimals") {
        val result = calculator.add(1.5, 2.5)
        result shouldBe 4.0
      }
    }
    
    describe("divide operation") {
      it("should divide two numbers") {
        val result = calculator.divide(10.0, 2.0)
        result shouldBe 5.0
      }
      
      it("should throw on division by zero") {
        shouldThrow<IllegalArgumentException> {
          calculator.divide(10.0, 0.0)
        }
      }
    }
  }
})

// プロパティベーステスト
class CalculatorPropertyTest : FunSpec({
  val calculator = Calculator()
  
  test("addition is commutative") {
    forAll(Arb.double(), Arb.double()) { a, b ->
      calculator.add(a, b) shouldBe calculator.add(b, a)
    }
  }
  
  test("multiplication distributes over addition") {
    forAll(Arb.double(), Arb.double(), Arb.double()) { a, b, c ->
      calculator.multiply(a, calculator.add(b, c)) shouldBe
        calculator.add(
          calculator.multiply(a, b),
          calculator.multiply(a, c)
        )
    }
  }
})

例2: ユーザー管理サービスのテスト

data class User(
  val id: Long? = null,
  val name: String,
  val email: String,
  val age: Int? = null,
  val active: Boolean = true
)

interface UserRepository {
  suspend fun save(user: User): User
  suspend fun findById(id: Long): User?
  suspend fun findAll(): List<User>
  suspend fun delete(id: Long): Boolean
}

class UserService(private val repository: UserRepository) {
  suspend fun createUser(name: String, email: String, age: Int? = null): User {
    if (name.isBlank()) throw IllegalArgumentException("Name cannot be blank")
    if (!email.contains("@")) throw IllegalArgumentException("Invalid email")
    
    val user = User(name = name, email = email, age = age)
    return repository.save(user)
  }
  
  suspend fun getUserById(id: Long): User {
    return repository.findById(id) ?: throw NotFoundException("User not found")
  }
  
  suspend fun updateUser(id: Long, name: String?, email: String?): User {
    val existing = getUserById(id)
    val updated = existing.copy(
      name = name ?: existing.name,
      email = email ?: existing.email
    )
    return repository.save(updated)
  }
}

class UserServiceTest : BehaviorSpec({
  val userRepository = mockk<UserRepository>()
  val userService = UserService(userRepository)
  
  given("a UserService with mocked repository") {
    when("creating a valid user") {
      val newUser = User(name = "John Doe", email = "john@example.com", age = 30)
      val savedUser = newUser.copy(id = 1)
      
      coEvery { userRepository.save(any()) } returns savedUser
      
      val result = userService.createUser("John Doe", "john@example.com", 30)
      
      then("it should return the saved user") {
        result.id shouldBe 1
        result.name shouldBe "John Doe"
        result.email shouldBe "john@example.com"
      }
      
      then("it should save to repository") {
        coVerify { userRepository.save(any()) }
      }
    }
    
    when("creating user with blank name") {
      then("it should throw IllegalArgumentException") {
        shouldThrow<IllegalArgumentException> {
          userService.createUser("", "john@example.com")
        }
      }
    }
    
    when("creating user with invalid email") {
      then("it should throw IllegalArgumentException") {
        shouldThrow<IllegalArgumentException> {
          userService.createUser("John", "invalid-email")
        }
      }
    }
    
    when("getting non-existent user") {
      coEvery { userRepository.findById(999) } returns null
      
      then("it should throw NotFoundException") {
        shouldThrow<NotFoundException> {
          userService.getUserById(999)
        }
      }
    }
  }
})

// データ駆動テスト
class UserServiceDataDrivenTest : FunSpec({
  val userRepository = mockk<UserRepository>()
  val userService = UserService(userRepository)
  
  test("email validation") {
    forAll(
      row("valid@example.com", true),
      row("user.name@example.co.uk", true),
      row("invalid.email", false),
      row("@example.com", false),
      row("user@", false)
    ) { email, shouldBeValid ->
      val isValid = email.contains("@")
      isValid shouldBe shouldBeValid
    }
  }
})

例3: API統合テスト

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiIntegrationTest : FunSpec() {
  override fun extensions() = listOf(SpringExtension)
  
  @Autowired
  private lateinit var testRestTemplate: TestRestTemplate
  
  @Autowired
  private lateinit var userRepository: UserRepository
  
  init {
    describe("User API") {
      describe("GET /api/users/{id}") {
        it("should return user") {
          val user = userRepository.save(User(name = "John", email = "john@example.com"))
          
          val response = testRestTemplate.getForEntity(
            "/api/users/${user.id}",
            User::class.java
          )
          
          response.statusCode shouldBe HttpStatus.OK
          response.body?.name shouldBe "John"
        }
        
        it("should return 404 for non-existent user") {
          val response = testRestTemplate.getForEntity(
            "/api/users/99999",
            String::class.java
          )
          
          response.statusCode shouldBe HttpStatus.NOT_FOUND
        }
      }
      
      describe("POST /api/users") {
        it("should create user") {
          val newUser = User(name = "Jane", email = "jane@example.com")
          
          val response = testRestTemplate.postForEntity(
            "/api/users",
            newUser,
            User::class.java
          )
          
          response.statusCode shouldBe HttpStatus.CREATED
          response.body?.id shouldNotBe null
          response.body?.name shouldBe "Jane"
        }
      }
      
      describe("PUT /api/users/{id}") {
        it("should update user") {
          val existing = userRepository.save(User(name = "John", email = "john@example.com"))
          val updated = existing.copy(name = "Updated John")
          
          testRestTemplate.put("/api/users/${existing.id}", updated)
          
          val retrieved = userRepository.findById(existing.id!!).orElseThrow()
          retrieved.name shouldBe "Updated John"
        }
      }
    }
  }
}

ベストプラクティス

1. 適切なテストスタイルの選択

// ✓ Good: テスト構造が明確
class UserServiceTest : DescribeSpec({
  describe("UserService") {
    describe("getUserById") {
      it("returns user when exists") { }
      it("throws when invalid") { }
    }
  }
})

// ✗ Bad: テスト構造が不明確
class UserServiceTest : StringSpec({
  "user service get user by id returns user when exists" { }
  "user service get user by id throws when invalid" { }
})

2. 一貫性のあるテスト命名

// ✓ Good: 明確でテスト可能な内容を表現
class CalculatorTest : FunSpec({
  test("add returns sum of two numbers") { }
  test("add with negative numbers") { }
  test("divide throws when divisor is zero") { }
})

// ✗ Bad: 曖昧な名前
class CalculatorTest : FunSpec({
  test("test1") { }
  test("test2") { }
})

3. 適切なアサーション構文

// ✓ Good: 読みやすいアサーション
user.name shouldBe "John"
user.age shouldBeGreaterThan 18
list.shouldBeEmpty()
list.shouldContain("item")

// ✗ Bad: 読みにくいアサーション
assert(user.name == "John")
assertTrue(user.age > 18)
assertTrue(list.isEmpty())
assertTrue(list.contains("item"))

4. セットアップの共有

// ✓ Good: セットアップを初期化ブロック内で
class UserServiceTest : FunSpec({
  val userRepository = mockk<UserRepository>()
  val userService = UserService(userRepository)
  
  test("should create user") {
    coEvery { userRepository.save(any()) } returns User(id = 1, name = "John")
    val result = userService.createUser("John", "john@example.com")
    result.id shouldBe 1
  }
})

// ✗ Bad: 各テストで独立してセットアップ
class UserServiceTest : FunSpec({
  test("should create user") {
    val userRepository = mockk<UserRepository>()
    val userService = UserService(userRepository)
    // ...
  }
  
  test("should update user") {
    val userRepository = mockk<UserRepository>()
    val userService = UserService(userRepository)
    // ...
  }
})

5. プロパティベーステストの活用

// ✓ Good: プロパティベーステストで多くのケースをカバー
test("addition is commutative") {
  forAll(Arb.int(), Arb.int()) { a, b ->
    a + b shouldBe b + a
  }
}

// ✗ Bad: 手動でいくつかのケースのみテスト
test("addition") {
  (5 + 3) shouldBe (3 + 5)
  (10 + 20) shouldBe (20 + 10)
  (-5 + 3) shouldBe (3 + (-5))
}

6. 例外テストの適切な使用

// ✓ Good: 例外の型とメッセージを検証
shouldThrow<IllegalArgumentException> {
  calculator.divide(10, 0)
}.message shouldContain "Division by zero"

// ✗ Bad: 例外だけをチェック
shouldThrow<Exception> {
  calculator.divide(10, 0)
}

7. モッキングの適切な使用

// ✓ Good: 依存関係をモック、テスト対象は実装
class UserServiceTest : FunSpec({
  val userRepository = mockk<UserRepository>()
  val userService = UserService(userRepository) // 本実装
  
  test("creates user") {
    coEvery { userRepository.save(any()) } returns User(id = 1, name = "John")
    val result = userService.createUser("John", "john@example.com")
    result.id shouldBe 1
  }
})

// ✗ Bad: テスト対象自体をモック
class UserServiceTest : FunSpec({
  val userService = mockk<UserService>()
  
  test("creates user") {
    coEvery { userService.createUser("John", "john@example.com") } 
      returns User(id = 1, name = "John")
    val result = userService.createUser("John", "john@example.com")
    result.id shouldBe 1
  }
})

8. パフォーマンステスト

class PerformanceTest : FunSpec({
  test("large list processing").config(
    timeout = 5.seconds,
    tags = setOf(Tag("performance"))
  ) {
    val largeList = (1..1000000).toList()
    val start = System.currentTimeMillis()
    
    val filtered = largeList.filter { it % 2 == 0 }
    
    val duration = System.currentTimeMillis() - start
    duration shouldBeLessThan 1000 // 1秒以内
    filtered.shouldNotBeEmpty()
  }
})

9. テスト分離と独立性

// ✓ Good: 各テストが独立している
class UserServiceTest : FunSpec({
  test("test1 should work") {
    val result = someOperation()
    result shouldBe expected1
  }
  
  test("test2 should work") {
    val result = anotherOperation()
    result shouldBe expected2
  }
})

// ✗ Bad: テスト間に依存関係がある
class UserServiceTest : FunSpec({
  var globalUser: User? = null
  
  test("test1 creates user") {
    globalUser = userService.createUser("John", "john@example.com")
  }
  
  test("test2 uses user from test1") {
    globalUser?.name shouldBe "John" // test1が実行されなければ失敗
  }
})

10. テスト可能性の設計

// ✓ Good: 依存関係をコンストラクタで注入
class UserService(private val repository: UserRepository) {
  suspend fun createUser(name: String, email: String): User {
    return repository.save(User(name = name, email = email))
  }
}

// ✗ Bad: 依存関係が硬くコードされている
class UserService {
  private val repository = UserRepository() // テスト困難
  
  suspend fun createUser(name: String, email: String): User {
    return repository.save(User(name = name, email = email))
  }
}

パフォーマンス最適化

並列実行の設定

# kotest.properties
kotest.framework.execution.parallelism=8
kotest.framework.execution.mode=concurrent
kotest.framework.concurrentSpecs=4
kotest.framework.concurrentTests=2

テスト実行時間の短縮

class OptimizedTest : FunSpec({
  // 並列実行可能なテストをグループ化
  test("independent test 1").config(
    tags = setOf(Tag("parallel"))
  ) { }
  
  test("independent test 2").config(
    tags = setOf(Tag("parallel"))
  ) { }
  
  // 依存性のあるテスト
  test("dependent test").config(
    tags = setOf(Tag("sequential")),
    enabled = !isRunningInParallel()
  ) { }
})

トラブルシューティング

よくある問題と解決策

問題1: テストがハングする

// ✗ 問題:タイムアウトが設定されていない
test("may hang") {
  while(true) { } // 無限ループ
}

// ✓ 解決策:タイムアウトを設定
test("may hang").config(timeout = 10.seconds) {
  while(true) { }
}

問題2: 並列実行でテストが失敗する

// ✗ 問題:テスト間に依存関係がある
var sharedState = mutableListOf<String>()

test("test 1") {
  sharedState.add("item1")
}

test("test 2") {
  sharedState.shouldContain("item1") // test1に依存
}

// ✓ 解決策:各テストで独立したデータを使用
test("test 1") {
  val state = mutableListOf<String>()
  state.add("item1")
  state.shouldContain("item1")
}

test("test 2") {
  val state = mutableListOf<String>()
  state.add("item2")
  state.shouldContain("item2")
}

問題3: Spring統合でBeanが見つからない

// ✓ 解決策:SpringExtensionを使用
@SpringBootTest
class ServiceTest : FunSpec() {
  override fun extensions() = listOf(SpringExtension)
  
  @Autowired
  private lateinit var service: MyService
}

まとめ

Kotestは、Kotlinの言語機能を最大限に活用した強力で柔軟なテストフレームワークです。以下の点が重要です:

  1. 複数のテストスタイル:プロジェクトの文化や要件に合わせて選択可能
  2. 豊富なアサーション:読みやすく、保守しやすいテストコード
  3. プロパティベーステスト:バグ発見率の向上
  4. 高度な設定機能:テスト実行の完全な制御
  5. 拡張可能性:カスタム拡張とリスナーによる柔軟な統合

これらの機能を適切に活用することで、品質の高いテストスイートを構築できます。