🚀 快速安装

复制以下命令并运行,立即安装此 Skill:

npx skills add https://skills.sh/affaan-m/everything-claude-code/kotlin-testing

💡 提示:需要 Node.js 和 NPM

Kotlin 测试模式

使用 Kotest 和 MockK 编写可靠、可维护测试的综合 Kotlin 测试模式,遵循 TDD 方法。

何时使用

  • 编写新的 Kotlin 函数或类时
  • 为现有 Kotlin 代码添加测试覆盖率
  • 实现基于属性的测试
  • 在 Kotlin 项目中遵循 TDD 工作流
  • 配置 Kover 进行代码覆盖率检查

工作原理

  1. 识别目标代码 — 找到要测试的函数、类或模块
  2. 编写 Kotest 规范 — 选择与测试范围匹配的规范风格(StringSpec、FunSpec、BehaviorSpec)
  3. 模拟依赖 — 使用 MockK 隔离被测单元
  4. 运行测试(RED) — 验证测试是否按预期失败
  5. 实现代码(GREEN) — 编写最少的代码以使测试通过
  6. 重构 — 改进实现,同时保持测试通过
  7. 检查覆盖率 — 运行 ./gradlew koverHtmlReport 并验证 80% 以上的覆盖率

示例

以下部分包含每种测试模式的详细、可运行示例:

快速参考

  • Kotest 规范 — 在 Kotest 规范风格 中查看 StringSpec、FunSpec、BehaviorSpec、DescribeSpec 示例
  • 模拟 — 在 MockK 中查看 MockK 设置、协程模拟、参数捕获
  • TDD 演练 — 在 Kotlin TDD 工作流 中查看带有 EmailValidator 的完整 RED/GREEN/REFACTOR 周期
  • 覆盖率 — 在 Kover 覆盖率 中查看 Kover 配置和命令
  • Ktor 测试 — 在 Ktor testApplication 测试 中查看 testApplication 设置

Kotlin TDD 工作流

红-绿-重构周期

红     -> 首先编写一个失败的测试
绿   -> 编写最少的代码使其通过
重构 -> 保持测试通过的同时改进代码
重复  -> 继续下一个需求

Kotlin 中的逐步 TDD

// 步骤 1:定义接口/签名
// EmailValidator.kt
package com.example.validator

fun validateEmail(email: String): Result<String> {
    TODO("not implemented")
}

// 步骤 2:编写失败的测试(红)
// 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("user@example.com").shouldBeSuccess("user@example.com")
    }

    "空邮箱返回失败" {
        validateEmail("").shouldBeFailure()
    }

    "不含 @ 的邮箱返回失败" {
        validateEmail("userexample.com").shouldBeFailure()
    }
})

// 步骤 3:运行测试 - 验证失败
// $ ./gradlew test
// EmailValidatorTest > 有效邮箱返回成功 失败
//   kotlin.NotImplementedError: An operation is not implemented

// 步骤 4:实现最小代码(绿)
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 > 有效邮箱返回成功 通过
// EmailValidatorTest > 空邮箱返回失败 通过
// EmailValidatorTest > 不含 @ 的邮箱返回失败 通过

// 步骤 6:如果需要则重构,并验证测试仍然通过

Kotest 规范风格

StringSpec(最简单)

class CalculatorTest : StringSpec({
    "两个正数相加" {
        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", "alice@example.com")
                validator.validate(user).shouldBeValid()
            }
        }

        context("使用无效名称时") {
            it("拒绝空白名称") {
                val user = CreateUserRequest("", "alice@example.com")
                validator.validate(user).shouldBeInvalid()
            }

            it("拒绝超过最大长度的名称") {
                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,
        { "用户 ${value.id} 应为活跃状态并有最后登录时间" },
        { "用户 ${value.id} 不应为活跃状态" },
    )
}

// 使用
user should beActiveUser()

MockK

基本模拟

class UserServiceTest : FunSpec({
    val repository = mockk<UserRepository>()
    val logger = mockk<Logger>(relaxed = true) // 宽松模拟:返回默认值
    val service = UserService(repository, logger)

    beforeTest {
        clearMocks(repository, logger)
    }

    test("findUser 委托给 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 对于未知 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", "alice@example.com"))

    slot.captured.name shouldBe "Alice"
    slot.captured.email shouldBe "alice@example.com"
    slot.captured.id.shouldNotBeNull()
}

Spy 和部分模拟

test("spy 真实对象") {
    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
        }
    }
})

自定义生成器

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("解析有效日期") {
        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("拒绝无效日期") {
        withData(
            nameFn = { "拒绝 '$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("插入并检索用户") {
        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("保存并查找用户") {
        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

# 为 CI 生成 XML 报告
./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 返回列表") {
        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 创建用户") {
        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 并坚持使用)
  • 模拟数据类(使用真实实例)
  • 在协程测试中使用 Thread.sleep()(使用 advanceTimeBy
  • 在 TDD 中跳过红阶段
  • 直接测试私有函数
  • 忽略不稳定的测试

与 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 tests with coverage
      run: ./gradlew test koverXmlReport

    - name: Verify coverage
      run: ./gradlew koverVerify

    - name: Upload coverage
      uses: codecov/codecov-action@v5
      with:
        files: build/reports/kover/report.xml
        token: ${{ secrets.CODECOV_TOKEN }}

请记住:测试即文档。它们展示了您的 Kotlin 代码应如何使用。使用 Kotest 的表达性匹配器使测试可读,并使用 MockK 进行清晰的依赖模拟。

📄 原始文档

完整文档(英文):

https://skills.sh/affaan-m/everything-claude-code/kotlin-testing

💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。