kotlin-testing

kotlin-testing

熱門

Kotlin 測試模式,包含 Kotest、MockK、協程測試、屬性式測試與 Kover 覆蓋率。遵循 TDD 方法論,採用慣用的 Kotlin 實作方式。

23萬星標
3.5萬分支
更新於 2026/7/17
SKILL.md
readonlyread-only
name
kotlin-testing
description

Kotlin 測試模式,包含 Kotest、MockK、協程測試、屬性式測試與 Kover 覆蓋率。遵循 TDD 方法論,採用慣用的 Kotlin 實作方式。

Kotlin 測試模式

全面的 Kotlin 測試模式,協助你使用 Kotest 與 MockK 撰寫可靠、可維護的測試,並遵循 TDD 方法論。

使用時機

  • 撰寫新的 Kotlin 函式或類別
  • 為現有 Kotlin 程式碼增加測試覆蓋率
  • 實作屬性式測試
  • 在 Kotlin 專案中遵循 TDD 工作流程
  • 設定 Kover 進行程式碼覆蓋率檢查

運作方式

  1. 識別目標程式碼 — 找出要測試的函式、類別或模組
  2. 撰寫 Kotest 規格 — 根據測試範圍選擇規格風格(StringSpec、FunSpec、BehaviorSpec)
  3. 模擬相依物件 — 使用 MockK 隔離受測單元
  4. 執行測試(RED) — 確認測試因預期錯誤而失敗
  5. 實作程式碼(GREEN) — 撰寫最簡程式碼讓測試通過
  6. 重構 — 在維持測試通過的前提下改善實作
  7. 檢查覆蓋率 — 執行 ./gradlew koverHtmlReport 並確認覆蓋率達 80% 以上

範例

以下各節包含每種測試模式的詳細可執行範例:

快速參考

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({
    "valid email returns success" {
        validateEmail("user@example.com").shouldBeSuccess("user@example.com")
    }

    "empty email returns failure" {
        validateEmail("").shouldBeFailure()
    }

    "email without @ returns failure" {
        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("Email cannot be blank"))
    if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @"))
    val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
    if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format"))
    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 規格風格

StringSpec(最簡潔)
class CalculatorTest : StringSpec({
    "add two positive numbers" {
        Calculator.add(2, 3) shouldBe 5
    }

    "add negative numbers" {
        Calculator.add(-1, -2) shouldBe -3
    }

    "add zero" {
        Calculator.add(0, 5) shouldBe 5
    }
})
FunSpec(類似 JUnit)
class UserServiceTest : FunSpec({
    val repository = mockk<UserRepository>()
    val service = UserService(repository)

    test("getUser returns user when found") {
        val expected = User(id = "1", name = "Alice")
        coEvery { repository.findById("1") } returns expected

        val result = service.getUser("1")

        result shouldBe expected
    }

    test("getUser throws when not found") {
        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("a valid order request") {
        val request = CreateOrderRequest(
            userId = "user-1",
            items = listOf(OrderItem("product-1", quantity = 2)),
        )

        When("the order is placed") {
            coEvery { paymentService.charge(any()) } returns PaymentResult.Success
            coEvery { repository.save(any()) } answers { firstArg() }

            val result = service.placeOrder(request)

            Then("it should return a confirmed order") {
                result.status shouldBe OrderStatus.CONFIRMED
            }

            Then("it should charge payment") {
                coVerify(exactly = 1) { paymentService.charge(any()) }
            }
        }

        When("payment fails") {
            coEvery { paymentService.charge(any()) } returns PaymentResult.Declined

            Then("it should throw PaymentException") {
                shouldThrow<PaymentException> {
                    service.placeOrder(request)
                }
            }
        }
    }
})
DescribeSpec(RSpec 風格)
class UserValidatorTest : DescribeSpec({
    describe("validateUser") {
        val validator = UserValidator()

        context("with valid input") {
            it("accepts a normal user") {
                val user = CreateUserRequest("Alice", "alice@example.com")
                validator.validate(user).shouldBeValid()
            }
        }

        context("with invalid name") {
            it("rejects blank name") {
                val user = CreateUserRequest("", "alice@example.com")
                validator.validate(user).shouldBeInvalid()
            }

            it("rejects name exceeding max length") {
                val user = CreateUserRequest("A".repeat(256), "alice@example.com")
                validator.validate(user).shouldBeInvalid()
            }
        }
    }
})

Kotest 匹配器

核心匹配器
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()

// 空值
result.shouldNotBeNull()
result.shouldBeNull()

// 型別
result.shouldBeInstanceOf<User>()

// 數值
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0

// 例外
shouldThrow<IllegalArgumentException> {
    validateAge(-1)
}.message shouldBe "Age must be positive"

shouldNotThrow<Exception> {
    validateAge(25)
}
自訂匹配器
fun beActiveUser() = object : Matcher<User> {
    override fun test(value: User) = MatcherResult(
        value.isActive && value.lastLogin != null,
        { "User ${value.id} should be active with a last login" },
        { "User ${value.id} should not be active" },
    )
}

// 使用方式
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 delegates to repository") {
        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 returns null for unknown id") {
        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 suspending function") {
        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 with delay") {
        coEvery { repository.findById("1") } coAnswers {
            delay(100) // 模擬非同步工作
            User(id = "1", name = "Alice")
        }

        val result = service.getUser("1")
        result.name shouldBe "Alice"
    }
})
引數捕捉
test("save captures the user argument") {
    val slot = slot<User>()
    coEvery { repository.save(capture(slot)) } returns Unit

    service.createUser(CreateUserRequest("Alice", "alice@example.com"))

    slot.captured.name shouldBe "Alice"
    slot.captured.email shouldBe "alice@example.com"
    slot.captured.id.shouldNotBeNull()
}
Spy 與部分模擬
test("spy on real object") {
    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("concurrent fetches complete together") {
        runTest {
            val service = DataService(testScope = this)

            val result = service.fetchAllData()

            result.users.shouldNotBeEmpty()
            result.products.shouldNotBeEmpty()
        }
    }

    test("timeout after delay") {
        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 emits updates") {
        runTest {
            val service = UserFlowService()

            val emissions = service.observeUsers()
                .take(3)
                .toList()

            emissions shouldHaveSize 3
            emissions.last().shouldNotBeEmpty()
        }
    }

    test("searchUsers debounces input") {
        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("uses test dispatcher for controlled execution") {
        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 data class 加上 @Serializable 註解(來自 kotlinx.serialization)。

class PropertyTest : FunSpec({
    test("string reverse is involutory") {
        forAll<String> { s ->
            s.reversed().reversed() == s
        }
    }

    test("list sort is idempotent") {
        forAll(Arb.list(Arb.int())) { list ->
            list.sorted() == list.sorted().sorted()
        }
    }

    test("serialization roundtrip preserves data") {
        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
        }
    }
})
自訂產生器
val userArb: Arb<User> = Arb.bind(
    Arb.string(minSize = 1, maxSize = 50),
    Arb.email(),
    Arb.enum<Role>(),
) { name, email, role ->
    User(
        id = UserId(UUID.randomUUID().toString()),
        name = name,
        email = Email(email),
        role = role,
    )
}

val moneyArb: Arb<Money> = Arb.bind(
    Arb.long(1L..1_000_000L),
    Arb.enum<Currency>(),
) { amount, currency ->
    Money(amount, currency)
}

資料驅動測試

Kotest 的 withData
class ParserTest : FunSpec({
    context("parsing valid dates") {
        withData(
            "2026-01-15" to LocalDate(2026, 1, 15),
            "2026-12-31" to LocalDate(2026, 12, 31),
            "2000-01-01" to LocalDate(2000, 1, 1),
        ) { (input, expected) ->
            parseDate(input) shouldBe expected
        }
    }

    context("rejecting invalid dates") {
        withData(
            nameFn = { "rejects '$it'" },
            "not-a-date",
            "2026-13-01",
            "2026-00-15",
            "",
        ) { input ->
            shouldThrow<DateParseException> {
                parseDate(input)
            }
        }
    }
})

測試生命週期與固定設施

BeforeTest / AfterTest
class DatabaseTest : FunSpec({
    lateinit var db: Database

    beforeSpec {
        db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
        transaction(db) {
            SchemaUtils.create(UsersTable)
        }
    }

    afterSpec {
        transaction(db) {
            SchemaUtils.drop(UsersTable)
        }
    }

    beforeTest {
        transaction(db) {
            UsersTable.deleteAll()
        }
    }

    test("insert and retrieve user") {
        transaction(db) {
            UsersTable.insert {
                it[name] = "Alice"
                it[email] = "alice@example.com"
            }
        }

        val users = transaction(db) {
            UsersTable.selectAll().map { it[UsersTable.name] }
        }

        users shouldContain "Alice"
    }
})
Kotest 擴充
// 可重複使用的測試擴充
class DatabaseExtension : BeforeSpecListener, AfterSpecListener {
    lateinit var db: Database

    override suspend fun beforeSpec(spec: Spec) {
        db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
    }

    override suspend fun afterSpec(spec: Spec) {
        // 清理
    }
}

class UserRepositoryTest : FunSpec({
    val dbExt = DatabaseExtension()
    register(dbExt)

    test("save and find user") {
        val repo = UserRepository(dbExt.db)
        // ...
    }
})

Kover 覆蓋率

Gradle 設定
// build.gradle.kts
plugins {
    id("org.jetbrains.kotlinx.kover") version "0.9.7"
}

kover {
    reports {
        total {
            html { onCheck = true }
            xml { onCheck = true }
        }
        filters {
            excludes {
                classes("*.generated.*", "*.config.*")
            }
        }
        verify {
            rule {
                minBound(80) // 覆蓋率低於 80% 時建置失敗
            }
        }
    }
}
覆蓋率指令
# 執行測試並產生覆蓋率報告
./gradlew koverHtmlReport

# 驗證覆蓋率門檻
./gradlew koverVerify

# 產生 XML 報告供 CI 使用
./gradlew koverXmlReport

# 檢視 HTML 報告(依作業系統使用對應指令)
# macOS:   open build/reports/kover/html/index.html
# Linux:   xdg-open build/reports/kover/html/index.html
# Windows: start build/reports/kover/html/index.html
覆蓋率目標
程式碼類型 目標
關鍵商業邏輯 100%
公開 API 90% 以上
一般程式碼 80% 以上
產生/設定程式碼 排除

Ktor testApplication 測試

class ApiRoutesTest : FunSpec({
    test("GET /users returns list") {
        testApplication {
            application {
                configureRouting()
                configureSerialization()
            }

            val response = client.get("/users")

            response.status shouldBe HttpStatusCode.OK
            val users = response.body<List<UserResponse>>()
            users.shouldNotBeEmpty()
        }
    }

    test("POST /users creates user") {
        testApplication {
            application {
                configureRouting()
                configureSerialization()
            }

            val response = client.post("/users") {
                contentType(ContentType.Application.Json)
                setBody(CreateUserRequest("Alice", "alice@example.com"))
            }

            response.status shouldBe HttpStatusCode.Created
        }
    }
})

測試指令

# 執行所有測試
./gradlew test

# 執行特定測試類別
./gradlew test --tests "com.example.UserServiceTest"

# 執行特定測試
./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"

# 執行並顯示詳細輸出
./gradlew test --info

# 執行測試並產生覆蓋率報告
./gradlew koverHtmlReport

# 執行 detekt(靜態分析)
./gradlew detekt

# 執行 ktlint(格式檢查)
./gradlew ktlintCheck

# 持續測試模式
./gradlew test --continuous

最佳實務

應做:

  • 先撰寫測試(TDD)
  • 在整個專案中一致使用 Kotest 的規格風格
  • 對暫停函式使用 MockK 的 coEvery/coVerify
  • 使用 runTest 進行協程測試
  • 測試行為,而非實作
  • 對純函式使用屬性式測試
  • 使用 data class 測試固定設施以增加可讀性

不應做:

  • 混用測試框架(選定 Kotest 後就固定使用)
  • 模擬 data class(應使用真實實例)
  • 在協程測試中使用 Thread.sleep()(應使用 advanceTimeBy
  • 跳過 TDD 的 RED 階段
  • 直接測試私有函式
  • 忽略不穩定的測試

與 CI/CD 整合

# GitHub Actions 範例
test:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-java@v4
      with:
        distribution: 'temurin'
        java-version: '21'

    - name: 執行測試並產生覆蓋率報告
      run: ./gradlew test koverXmlReport

    - name: 驗證覆蓋率
      run: ./gradlew koverVerify

    - name: 上傳覆蓋率報告
      uses: codecov/codecov-action@v5
      with:
        files: build/reports/kover/report.xml
        token: ${{ secrets.CODECOV_TOKEN }}

切記:測試就是文件。它們展示了你的 Kotlin 程式碼應該如何使用。使用 Kotest 的表達性匹配器讓測試易於閱讀,並使用 MockK 進行乾淨的相依物件模擬。