--- name: kotlin-testing description: 使用Kotest、MockK、协程测试、基于属性的测试和Kover覆盖率的Kotlin测试模式。遵循TDD方法论和地道的Kotlin实践。 origin: ECC --- # Kotlin 测试模式 遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。 ## 何时使用 * 编写新的 Kotlin 函数或类 * 为现有 Kotlin 代码添加测试覆盖率 * 实现基于属性的测试 * 在 Kotlin 项目中遵循 TDD 工作流 * 为代码覆盖率配置 Kover ## 工作原理 1. **确定目标代码** — 找到要测试的函数、类或模块 2. **编写 Kotest 规范** — 选择与测试范围匹配的规范样式(StringSpec、FunSpec、BehaviorSpec) 3. **模拟依赖项** — 使用 MockK 来隔离被测单元 4. **运行测试(红色阶段)** — 验证测试是否按预期失败 5. **实现代码(绿色阶段)** — 编写最少的代码以使测试通过 6. **重构** — 改进实现,同时保持测试通过 7. **检查覆盖率** — 运行 `./gradlew koverHtmlReport` 并验证 80%+ 的覆盖率 ## 示例 以下部分包含每个测试模式的详细、可运行示例: ### 快速参考 * **Kotest 规范** — [Kotest 规范样式](#kotest-规范样式) 中的 StringSpec、FunSpec、BehaviorSpec、DescribeSpec 示例 * **模拟** — [MockK](#mockk) 中的 MockK 设置、协程模拟、参数捕获 * **TDD 演练** — [Kotlin 的 TDD 工作流](#kotlin-的-tdd-工作流) 中 EmailValidator 的完整 RED/GREEN/REFACTOR 周期 * **覆盖率** — [Kover 覆盖率](#kover-覆盖率) 中的 Kover 配置和命令 * **Ktor 测试** — [Ktor testApplication 测试](#ktor-testapplication-测试) 中的 testApplication 设置 ### Kotlin 的 TDD 工作流 #### RED-GREEN-REFACTOR 周期 ``` RED -> 首先编写一个失败的测试 GREEN -> 编写最少的代码使测试通过 REFACTOR -> 改进代码同时保持测试通过 REPEAT -> 继续下一个需求 ``` #### Kotlin 中逐步进行 TDD ```kotlin // 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 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 { 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(最简单) ```kotlin 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) ```kotlin 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 风格) ```kotlin 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 风格) ```kotlin 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 匹配器 #### 核心匹配器 ```kotlin 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() // Numbers count shouldBeGreaterThan 0 price shouldBeInRange 1.0..100.0 // Exceptions shouldThrow { validateAge(-1) }.message shouldBe "Age must be positive" shouldNotThrow { validateAge(25) } ``` #### 自定义匹配器 ```kotlin 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 #### 基本模拟 ```kotlin 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() } }) ``` #### 协程模拟 ```kotlin 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" } }) ``` #### 参数捕获 ```kotlin 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() } ``` #### 间谍和部分模拟 ```kotlin 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 ```kotlin 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 { withTimeout(100) { service.slowOperation() // Takes > 100ms } } } } }) ``` #### 测试 Flow ```kotlin 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() val results = mutableListOf>() 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 ```kotlin 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 属性测试 ```kotlin 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 { 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 } } }) ``` #### 自定义生成器 ```kotlin 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 ```kotlin 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 ```kotlin 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 扩展 ```kotlin // 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 配置 ```kotlin // 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 } } } } ``` #### 覆盖率命令 ```bash # 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 测试 ```kotlin 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>() 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 } } }) ``` ### 测试命令 ```bash # 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 的 `coEvery`/`coVerify` * 对协程测试使用 `runTest` * 测试行为,而非实现 * 对纯函数使用基于属性的测试 * 为清晰起见使用 `data class` 测试固件 **不应做:** * 混合使用测试框架(选择 Kotest 并坚持使用) * 模拟数据类(使用真实实例) * 在协程测试中使用 `Thread.sleep()`(改用 `advanceTimeBy`) * 跳过 TDD 中的红色阶段 * 直接测试私有函数 * 忽略不稳定的测试 ### 与 CI/CD 集成 ```yaml # 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 来清晰地模拟依赖项。