Kotlin Testing

affaan-m/ECC/skills/kotlin-testing

作者 affaan-mef648e01899ba3e8dc6371642deaaf64b4477775无许可证275K 个星标收录于 2026年10月9日更新于 2026年10月9日仓库4天前更新

Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices. Use when writing Kotlin tests with Kotest or MockK, or testing coroutines and checking coverage.

AI 生成的概览

使用 Kotest、MockK、协程测试、基于属性的测试和 Kover 覆盖率编写 Kotlin 测试的参考指南。

功能
提供 Kotlin 测试模式与可运行示例,涵盖 Kotest 规范风格、匹配器、MockK 模拟与参数捕获、协程与 Flow 测试、基于属性的测试、数据驱动测试、生命周期夹具、Kover 覆盖率配置以及 Ktor testApplication 测试。还概述了 TDD 的 RED-GREEN-REFACTOR 流程,并列出 Gradle 测试与覆盖率命令。该技能仅为说明文档,本身不生成文件。
适用场景
适用于为 Kotlin 代码编写或补充测试、在 Kotlin 项目中遵循 TDD,或在 Kotest 规范风格与 MockK 模拟方式之间做选择时。也适合测试协程、Flow 和 Ktor 路由,以及配置或校验 Kover 覆盖率阈值。
运行要求
不附带脚本,仅为说明文档。实践其中的示例需要 Kotlin 项目与 Gradle、Kotest、MockK、kotlinx-coroutines-test,序列化往返示例还需 kotlinx-serialization,覆盖率需要 Kover,testApplication 测试可选需要 Ktor。

Kotlin Testing Patterns

Comprehensive Kotlin testing patterns for writing reliable, maintainable tests following TDD methodology with Kotest and MockK.

When to Use

  • Writing new Kotlin functions or classes
  • Adding test coverage to existing Kotlin code
  • Implementing property-based tests
  • Following TDD workflow in Kotlin projects
  • Configuring Kover for code coverage

How It Works

  1. Identify target code — Find the function, class, or module to test
  2. Write a Kotest spec — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope
  3. Mock dependencies — Use MockK to isolate the unit under test
  4. Run tests (RED) — Verify the test fails with the expected error
  5. Implement code (GREEN) — Write minimal code to pass the test
  6. Refactor — Improve the implementation while keeping tests green
  7. Check coverage — Run ./gradlew koverHtmlReport and verify 80%+ coverage

Examples

The following sections contain detailed, runnable examples for each testing pattern:

Quick Reference

TDD Workflow for Kotlin

The RED-GREEN-REFACTOR Cycle
RED     -> Write a failing test firstGREEN   -> Write minimal code to pass the testREFACTOR -> Improve code while keeping tests greenREPEAT  -> Continue with next requirement
Step-by-Step TDD in Kotlin
kotlin
// Step 1: Define the interface/signature// EmailValidator.ktpackage com.example.validator
fun validateEmail(email: String): Result<String> {    TODO("not implemented")}
// Step 2: Write failing test (RED)// EmailValidatorTest.ktpackage com.example.validator
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.result.shouldBeFailureimport 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()    }})
// 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 Spec Styles

StringSpec (Simplest)
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-like)
kotlin
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 Style)
kotlin
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 Style)
kotlin
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 Matchers

Core Matchers
kotlin
import io.kotest.matchers.shouldBeimport io.kotest.matchers.shouldNotBeimport io.kotest.matchers.string.*import io.kotest.matchers.collections.*import io.kotest.matchers.nulls.*
// Equalityresult shouldBe expectedresult shouldNotBe unexpected
// Stringsname shouldStartWith "Al"name shouldEndWith "ice"name shouldContain "lic"name shouldMatch Regex("[A-Z][a-z]+")name.shouldBeBlank()
// Collectionslist shouldContain "item"list shouldHaveSize 3list.shouldBeSorted()list.shouldContainAll("a", "b", "c")list.shouldBeEmpty()
// Nullsresult.shouldNotBeNull()result.shouldBeNull()
// Typesresult.shouldBeInstanceOf<User>()
// Numberscount shouldBeGreaterThan 0price shouldBeInRange 1.0..100.0
// ExceptionsshouldThrow<IllegalArgumentException> {    validateAge(-1)}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> {    validateAge(25)}
Custom Matchers
kotlin
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" },    )}
// Usageuser should beActiveUser()

MockK

Basic Mocking
kotlin
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()    }})
Coroutine Mocking
kotlin
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"    }})
Argument Capture
kotlin
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()}
Spy and Partial Mocking
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}

Coroutine Testing

runTest for Suspend Functions
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<TimeoutCancellationException> {                withTimeout(100) {                    service.slowOperation() // Takes > 100ms                }            }        }    }})
Testing Flows
kotlin
import io.kotest.matchers.collections.shouldContainInOrderimport kotlinx.coroutines.flow.MutableSharedFlowimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.launchimport kotlinx.coroutines.test.advanceTimeByimport 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
kotlin
import kotlinx.coroutines.test.StandardTestDispatcherimport 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        }    }})

Property-Based Testing

Kotest Property Testing
kotlin
import io.kotest.core.spec.style.FunSpecimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.forAllimport io.kotest.property.checkAllimport kotlinx.serialization.json.Jsonimport kotlinx.serialization.encodeToStringimport 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 protected]")        }) { user ->            val json = Json.encodeToString(user)            val decoded = Json.decodeFromString<User>(json)            decoded shouldBe user        }    }})
Custom Generators
kotlin
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)}

Data-Driven Testing

withData in Kotest
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<DateParseException> {                parseDate(input)            }        }    }})

Test Lifecycle and Fixtures

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] = "[email protected]"            }        }
        val users = transaction(db) {            UsersTable.selectAll().map { it[UsersTable.name] }        }
        users shouldContain "Alice"    }})
Kotest Extensions
kotlin
// Reusable test extensionclass 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 Coverage

Gradle Configuration
kotlin
// build.gradle.ktsplugins {    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            }        }    }}
Coverage Commands
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
Coverage Targets
Code TypeTarget
Critical business logic100%
Public APIs90%+
General code80%+
Generated / config codeExclude

Ktor testApplication Testing

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<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", "[email protected]"))            }
            response.status shouldBe HttpStatusCode.Created        }    }})

Testing Commands

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

Best Practices

DO:

  • Write tests FIRST (TDD)
  • Use Kotest's spec styles consistently across the project
  • Use MockK's coEvery/coVerify for suspend functions
  • Use runTest for coroutine testing
  • Test behavior, not implementation
  • Use property-based testing for pure functions
  • Use data class test fixtures for clarity

DON'T:

  • Mix testing frameworks (pick Kotest and stick with it)
  • Mock data classes (use real instances)
  • Use Thread.sleep() in coroutine tests (use advanceTimeBy)
  • Skip the RED phase in TDD
  • Test private functions directly
  • Ignore flaky tests

Integration with CI/CD

yaml
# GitHub Actions exampletest:  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 }}

Remember: Tests are documentation. They show how your Kotlin code is meant to be used. Use Kotest's expressive matchers to make tests readable and MockK for clean mocking of dependencies.

来源与署名

来源:affaan-m/ECC位于skills/kotlin-testing提交ef648e0

许可证: 无许可证

内容归原作者所有。SourceWeft 从公开仓库中收录这些内容。

举报或申请下架