All skills
Skillintermediate
Kotlin 测试模式
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
Claude Code Knowledge Pack7/10/2026
Overview
Kotlin 测试模式
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
何时使用
- 编写新的 Kotlin 函数或类
- 为现有 Kotlin 代码添加测试覆盖率
- 实现基于属性的测试
- 在 Kotlin 项目中遵循 TDD 工作流
- 为代码覆盖率配置 Kover
工作原理
- 确定目标代码 — 找到要测试的函数、类或模块
- 编写 Kotest 规范 — 选择与测试范围匹配的规范样式(StringSpec、FunSpec、BehaviorSpec)
- 模拟依赖项 — 使用 MockK 来隔离被测单元
- 运行测试(红色阶段) — 验证测试是否按预期失败
- 实现代码(绿色阶段) — 编写最少的代码以使测试通过
- 重构 — 改进实现,同时保持测试通过
- 检查覆盖率 — 运行
./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 工作流
RED-GREEN-REFACTOR 周期
RED -> 首先编写一个失败的测试
GREEN -> 编写最少的代码使测试通过
REFACTOR -> 改进代码同时保持测试通过
REPEAT -> 继续下一个需求
Kotlin 中逐步进行 TDD
// Step 1: Define the interface/signature
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result {
TODO("not implemented")
}
// Step 2: Write failing test (RED)
// EmailValidatorTest.kt
package com.example.validator
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()
}
})
// Step 3: Run tests - verify FAIL
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// Step 4: Implement minimal code (GREEN)
fun validateEmail(email: String): Result {
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)
}
// Step 5: Run tests - verify PASS
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// Step 6: Refactor if needed, verify tests still pass
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()
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 {
service.getUser("999")
}
}
})
BehaviorSpec(BDD 风格)
class OrderServiceTest : BehaviorSpec({
val repository = mockk()
val paymentService = mockk()
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 {
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 匹配器
核心匹配器
// Equality
result shouldBe expected
result shouldNotBe unexpected
// Strings
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Collections
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Nulls
result.shouldNotBeNull()
result.shouldBeNull()
// Types
result.shouldBeInstanceOf()
// Numbers
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// Exceptions
shouldThrow {
validateAge(-1)
}.message shouldBe "Age must be positive"
shouldNotThrow {
validateAge(25)
}
自定义匹配器
fun beActiveUser() = object : Matcher {
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" },
)
}
// Usage
user should beActiveUser()
MockK
基本模拟
class UserServiceTest : FunSpec({
val repository = mockk()
val logger = mockk(relaxed = true) // Relaxed: returns defaults
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()
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) // Simulate async work
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
参数捕获
test("save captures the user argument") {
val slot = slot()
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()
}
间谍和部分模拟
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() } // Overridden
// Other methods use real implementation
}
协程测试
用于挂起函数的 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 {
withTimeout(100) {
service.slowOperation() // Takes > 100ms
}
}
}
}
})
测试 Flow
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()
val results = mutableListOf<List>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // Only this should trigger search
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
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 属性测试
// Note: The serialization roundtrip test below requires the User data class
// to be annotated with @Serializable (from kotlinx.serialization).
class PropertyTest : FunSpec({
test("string reverse is involutory") {
forAll { 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(json)
decoded shouldBe user
}
}
})
自定义生成器
val userArb: Arb = Arb.bind(
Arb.string(minSize = 1, maxSize = 50),
Arb.email(),
Arb.enum(),
) { name, email, role ->
User(
id = UserId(UUID.randomUUID().toString()),
name = name,
email = Email(email),
role = role,
)
}
val moneyArb: Arb = Arb.bind(
Arb.long(1L..1_000_000L),
Arb.enum(),
) { 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 {
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) {