Kotlin Testing

affaan-m/ECC/.kiro/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.

AI 產生的概覽

使用 Kotest、MockK、協程測試、屬性測試與 Kover 覆蓋率撰寫 Kotlin 測試的參考指南。

功能
提供 Kotlin 測試模式與可直接執行的範例,涵蓋 Kotest 規格風格、匹配器、MockK 模擬、協程與 Flow 測試、屬性測試、資料驅動測試、測試 fixture,以及 Kover 覆蓋率設定。內容也示範 RED-GREEN-REFACTOR 的 TDD 循環,並列出用來執行測試、覆蓋率與靜態分析的 Gradle 指令。產出是指引與程式碼範例,而不是產生的檔案。
適用情境
適用於為 Kotlin 函式、類別或模組撰寫或補充測試時,在 Kotlin 專案中遵循 TDD 時,或為以 Kotest 為基礎的測試套件設定覆蓋率與模擬時。
執行需求
此技能不隨附指令碼,只有說明與範例。套用這些範例需要一個使用 Gradle 的 Kotlin 專案,以及 Kotest、MockK、kotlinx-coroutines-test、kotlinx-serialization 與 Kover 外掛,另需 JDK 與可解析相依套件的網路連線。

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位於.kiro/skills/kotlin-testing提交ef648e0

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架