Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices.
.claude/skills/affaan-m-kotlin-testing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 156% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 174% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 228% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 398% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 249% | 0% |
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
./gradlew koverHtmlReport 并验证 80%+ 的覆盖率以下部分包含每个测试模式的详细、可运行示例:
RED -> 首先编写一个失败的测试
GREEN -> 编写最少的代码使测试通过
REFACTOR -> 改进代码同时保持测试通过
REPEAT -> 继续下一个需求kotlin// 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
kotlinclass 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 } })
kotlinclass 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") } } })
kotlinclass 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) } } } } })
kotlinclass 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() } } } })
kotlinimport 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) }
kotlinfun 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()
kotlinclass 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() } })
kotlinclass 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" } })
kotlintest("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() }
kotlintest("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 }
kotlinimport 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 } } } } })
kotlinimport 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() } } })
kotlinimport 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 } } })
kotlinimport 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 } } })
kotlinval 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) }
kotlinclass 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) } } } })
kotlinclass 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" } })
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) // ... } })
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%+ | | 生成的 / 配置代码 | 排除 |
kotlinclass 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 } } })
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
应做:
coEvery/coVerifyrunTestdata class 测试固件不应做:
Thread.sleep()(改用 advanceTimeBy)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 来清晰地模拟依赖项。
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-10 | pass→pass | 9,834 | 7,705 | -22% | 1 | 1 | 0% | 1,931 | 7,158 | +271% | 0 | 0 | — |
case-11 | fail→fail | 13,262 | 9,700 | -27% | 1 | 1 | 0% | 2,696 | 7,633 | +183% | 0 | 0 | — |
case-16 | pass→pass | 10,710 | 9,889 | -8% | 1 | 1 | 0% | 2,263 | 7,520 | +232% | 0 | 0 | — |
case-01 | fail→pass | 16,330 | 13,922 | -15% | 1 | 1 | 0% | 3,447 | 8,815 | +156% | 0 | 0 | — |
case-02 | fail→fail | 13,214 | 10,642 | -19% | 1 | 1 | 0% | 2,773 | 7,832 | +182% | 0 | 0 | — |
case-03 | fail→pass | 14,320 | 12,482 | -13% | 1 | 1 | 0% | 3,018 | 8,257 | +174% | 0 | 0 | — |
case-04 | pass→pass | 15,888 | 11,335 | -29% | 1 | 1 | 0% | 3,289 | 7,935 | +141% | 0 | 0 | — |
case-05 | pass→pass | 8,517 | 4,054 | -52% | 1 | 1 | 0% | 1,679 | 6,395 | +281% | 0 | 0 | — |
case-06 | pass→pass | 10,476 | 6,637 | -37% | 1 | 1 | 0% | 2,218 | 6,877 | +210% | 0 | 0 | — |
case-07 | pass→pass | 9,431 | 6,084 | -35% | 1 | 1 | 0% | 2,028 | 6,842 | +237% | 0 | 0 | — |
case-08 | fail→pass | 10,726 | 6,533 | -39% | 1 | 1 | 0% | 2,069 | 6,779 | +228% | 0 | 0 | — |
case-09 | pass→pass | 9,107 | 6,757 | -26% | 1 | 1 | 0% | 1,733 | 6,844 | +295% | 0 | 0 | — |
case-12 | pass→pass | 5,866 | 3,774 | -36% | 1 | 1 | 0% | 1,218 | 6,265 | +414% | 0 | 0 | — |
case-13 | pass→pass | 10,191 | 12,523 | +23% | 1 | 1 | 0% | 2,384 | 8,355 | +250% | 0 | 0 | — |
case-14 | fail→pass | 6,753 | 6,831 | +1% | 1 | 1 | 0% | 1,398 | 6,957 | +398% | 0 | 0 | — |
case-15 | fail→pass | 10,824 | 12,498 | +15% | 1 | 1 | 0% | 2,348 | 8,205 | +249% | 0 | 0 | — |
case-17 | fail→pass | 8,732 | 6,345 | -27% | 1 | 1 | 0% | 1,686 | 6,759 | +301% | 0 | 0 | — |
case-18 | pass→pass | 8,515 | 5,042 | -41% | 1 | 1 | 0% | 1,901 | 6,605 | +247% | 0 | 0 | — |
case-19 | fail→pass | 8,786 | 5,868 | -33% | 1 | 1 | 0% | 1,778 | 6,777 | +281% | 0 | 0 | — |
case-20 | fail→fail | 9,767 | 7,986 | -18% | 1 | 1 | 0% | 1,875 | 7,214 | +285% | 0 | 0 | — |
case-21 | pass→pass | 8,461 | 6,171 | -27% | 1 | 1 | 0% | 1,746 | 6,686 | +283% | 0 | 0 | — |
case-22 | pass→pass | 8,470 | 8,988 | +6% | 1 | 1 | 0% | 1,811 | 7,383 | +308% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +32 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.