Communitygithub.com

kotlin-testing

Kotlin 测试模式,涵盖 Kotest、MockK、协程测试、基于属性的测试以及 Kover 覆盖率。遵循 TDD 方法论和惯用的 Kotlin 实践。在编写 Kotlin 测试(使用 Kotest 或 MockK)、测试协程或检查覆盖率时使用。

kotlin-testing 是什么?

该技能提供了一整套 Kotlin 测试模式,旨在帮助开发者使用 Kotest 和 MockK 编写可靠、可维护的测试。它全面涵盖了 Kotest 的各种规范风格(StringSpec、FunSpec、BehaviorSpec、DescribeSpec)、MockK 的高级模拟技巧(包括协程模拟和参数捕获)、基于属性的测试(利用 Kotest 的 forAll 和 checkAll)、协程测试(使用 runTest 和 TestDispatcher)、以及 Kover 覆盖率配置。该技能遵循标准的 TDD 红-绿-重构工作流,并通过详细的逐步示例和实际的 EmailValidator 实现来展示完整的 RED/GREEN/REFACTOR 循环。它适用于桌面和 Android Kotlin 项目,以及 Jetpack Compose 和 Ktor 框架,使开发者能够自信地编写测试、提升代码覆盖率并交付高质量代码。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/kotlin-testing

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

Kotlin 测试模式

全面的 Kotlin 测试模式,用于编写遵循 TDD 方法论且使用 Kotest 和 MockK 的可靠、可维护的测试。

何时使用

  • 编写新的 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("[email protected]").shouldBeSuccess("[email protected]")
    }

    "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", "[email protected]")
                validator.validate(user).shouldBeValid()
            }
        }

        context("with invalid name") {
            it("rejects blank name") {
                val user = CreateUserRequest("", "[email protected]")
                validator.validate(user).shouldBeInvalid()
            }

            it("rejects name exceeding max length") {
                val user = CreateUserRequest("A".repeat(256), "[email protected]")
                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", "[email protected]"))

    slot.captured.name shouldBe "Alice"
    slot.captured.email shouldBe "[email protected]"
    slot.captured.id.shouldNotBeNull()
}

间谍和部分模拟

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 数据类
// 使用 @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
        }
    }
})

自定义生成器

import io.kotest.property.Arb
import io.kotest.property.arbitrary.*

// 用户自定义生成器
val Arb.Companion.user: Arb<User>
    get() = arbitrary {
        User(
            id = it.nextUUID().toString(),
            name = it.nextString(3..20, Codepoint.alphabetical()),
            email = "${it.nextString(5..15, Codepoint.alphanumeric())}@${it.nextString(3..8, Codepoint.alphabetical())}.com",
            age = it.nextUInt(18u..120u).toInt(),
        )
    }

// 使用方式
class UserGeneratorTest : FunSpec({
    test("valid users pass validation") {
        checkAll(Arb.user) { user ->
            val result = UserValidator().validate(user)
            result.shouldBeValid()
        }
    }
})

Kover 覆盖率

build.gradle.kts 配置

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.9.0"
    id("org.jetbrains.kotlinx.kover") version "0.7.5"
}

koverReport {
    defaults {
        html {
            onCheck = true
        }
        verify {
            onCheck = true
            rule {
                minBound(80)
            }
        }
    }
}

常用命令

# 生成 HTML 报告
./gradlew koverHtmlReport

# 生成 XML 报告(用于 CI)
./gradlew koverXmlReport

# 验证覆盖率是否满足最低阈值
./gradlew koverVerify

# 仅针对特定源的覆盖率
./gradlew koverHtmlReport --tests '*UserService*'

# 重置并重新生成覆盖率数据
./gradlew clean koverHtmlReport

排除某些文件

koverReport {
    filters {
        excludes {
            classes(
                "*Generated*",
                "*MapperImpl*",
                "**/model/**",
            )
        }
    }
}

Ktor testApplication 测试

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import io.kotest.core.spec.style.FunSpec

class UserRoutesTest : FunSpec({
    test("GET /users returns list") {
        testApplication {
            application {
                module() // 加载你的 Ktor 模块
            }

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

            response.status shouldBe HttpStatusCode.OK
            response.bodyAsText() shouldContain "["
        }
    }

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

            val response = client.post("/users") {
                contentType(ContentType.Application.Json)
                setBody("""{"name": "Alice", "email": "[email protected]"}""")
            }

            response.status shouldBe HttpStatusCode.Created
        }
    }
})

Jetpack Compose 测试

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test

class LoginScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loginScreen_showsSubmitButton() {
        composeTestRule.setContent {
            LoginScreen()
        }

        composeTestRule
            .onNodeWithText("登录")
            .assertExists()
    }

    @Test
    fun loginScreen_showsErrorOnEmptyEmail() {
        composeTestRule.setContent {
            LoginScreen()
        }

        composeTestRule.onNodeWithText("登录").performClick()

        composeTestRule
            .onNodeWithText("邮箱不能为空")
            .assertIsDisplayed()
    }

    @Test
    fun loginScreen_navigatesOnSuccess() {
        composeTestRule.setContent {
            LoginScreen(onLoginSuccess = { /* mock */ })
        }

        composeTestRule.onNodeWithTag("email-input").performTextInput("[email protected]")
        composeTestRule.onNodeWithTag("password-input").performTextInput("password123")
        composeTestRule.onNodeWithText("登录").performClick()

        // 验证谓词被调用
        // ...
    }
}

常见陷阱

1. 协程范围不匹配

为每个测试创建新的范围,不要重复使用,以避免泄漏。

2. 模拟泄漏

始终在 beforeTest 中清理模拟:

beforeTest {
    clearMocks(repository, analyticsService)
}

3. 测试之间的可变状态

将可变状态保留在测试类范围之外,以便每个测试都获得新的实例。

4. 忽略嵌套测试的异常

不要忘记在嵌套范围内使用 shouldThrow,因为它不会自动传播。

// 错误:异常未捕获
context("nested") {
    someFunction() // 如果抛出异常,测试不会失败
}

// 正确
context("nested") {
    shouldThrow<Exception> {
        someFunction()
    }
}

架构决策记录

决策替代方案理由
Kotest > JUnit 5JUnit 5 + Hamcrest更好的 Kotlin 集成,更丰富的匹配器,内置属性测试
MockK > MockitoMockito + mockito-kotlin原生 Kotlin 支持,协程模拟,更好的 Kotlin DSL
runTest > runBlocking 用于测试runBlocking更好的时间控制,自动超时,避免测试中的主线程阻塞
FunSpec > StringSpec 用于复杂测试StringSpec更好的组织,嵌套能力,命名测试组

配置要求

build.gradle.kts 依赖

dependencies {
    // Kotest
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest:kotest-assertions-core:5.8.0")
    testImplementation("io.kotest:kotest-property:5.8.0")

    // MockK
    testImplementation("io.mockk:mockk:1.13.10")

    // 协程测试
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")

    // Kover(插件)
    // id("org.jetbrains.kotlinx.kover") version "0.7.5"

    // Ktor 测试
    // testImplementation("io.ktor:ktor-server-test-host:2.3.9")
}

settings.gradle.kts 插件

pluginManagement {
    repositories {
        mavenCentral()
        gradlePluginPortal()
    }
}

rootProject.name = "kotlin-testing-examples"

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

相关技能