Everything-claude-code kotlin-testing
使用Kotest、MockK、协程测试、基于属性的测试和Kover覆盖率的Kotlin测试模式。遵循TDD方法论和地道的Kotlin实践。
install
source · Clone the upstream repo
git clone https://github.com/affaan-m/everything-claude-code
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/affaan-m/everything-claude-code "$T" && mkdir -p ~/.claude/skills && cp -r "$T/docs/zh-CN/skills/kotlin-testing" ~/.claude/skills/affaan-m-everything-claude-code-kotlin-testing-4686e3 && rm -rf "$T"
manifest:
docs/zh-CN/skills/kotlin-testing/SKILL.mdsource content
Kotlin 测试模式
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
何时使用
- 编写新的 Kotlin 函数或类
- 为现有 Kotlin 代码添加测试覆盖率
- 实现基于属性的测试
- 在 Kotlin 项目中遵循 TDD 工作流
- 为代码覆盖率配置 Kover
工作原理
- 确定目标代码 — 找到要测试的函数、类或模块
- 编写 Kotest 规范 — 选择与测试范围匹配的规范样式(StringSpec、FunSpec、BehaviorSpec)
- 模拟依赖项 — 使用 MockK 来隔离被测单元
- 运行测试(红色阶段) — 验证测试是否按预期失败
- 实现代码(绿色阶段) — 编写最少的代码以使测试通过
- 重构 — 改进实现,同时保持测试通过
- 检查覆盖率 — 运行
并验证 80%+ 的覆盖率./gradlew koverHtmlReport
示例
以下部分包含每个测试模式的详细、可运行示例:
快速参考
- 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<String> { TODO("not implemented") } // Step 2: Write failing test (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() } }) // 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<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) } // 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<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.* // 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<User>() // Numbers count shouldBeGreaterThan 0 price shouldBeInRange 1.0..100.0 // Exceptions 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" }, ) } // Usage user should beActiveUser()
MockK
基本模拟
class UserServiceTest : FunSpec({ val repository = mockk<UserRepository>() val logger = mockk<Logger>(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<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) // 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<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() }
间谍和部分模拟
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
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() // Takes > 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") // Only this should trigger search 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 // 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<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 扩展
// Reusable test extension 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) { // cleanup } } 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) // Fail build below 80% coverage } } } }
覆盖率命令
# Run tests with coverage ./gradlew koverHtmlReport # Verify coverage thresholds ./gradlew koverVerify # XML report for CI ./gradlew koverXmlReport # View HTML report (use the command for your OS) # 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 } } })
测试命令
# Run all tests ./gradlew test # Run specific test class ./gradlew test --tests "com.example.UserServiceTest" # Run specific test ./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found" # Run with verbose output ./gradlew test --info # Run with coverage ./gradlew koverHtmlReport # Run detekt (static analysis) ./gradlew detekt # Run ktlint (formatting check) ./gradlew ktlintCheck # Continuous testing ./gradlew test --continuous
最佳实践
应做:
- 先写测试(TDD)
- 在整个项目中一致地使用 Kotest 的规范样式
- 对挂起函数使用 MockK 的
/coEverycoVerify - 对协程测试使用
runTest - 测试行为,而非实现
- 对纯函数使用基于属性的测试
- 为清晰起见使用
测试固件data class
不应做:
- 混合使用测试框架(选择 Kotest 并坚持使用)
- 模拟数据类(使用真实实例)
- 在协程测试中使用
(改用Thread.sleep()
)advanceTimeBy - 跳过 TDD 中的红色阶段
- 直接测试私有函数
- 忽略不稳定的测试
与 CI/CD 集成
# GitHub Actions example 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 来清晰地模拟依赖项。