Skip to main content
Kodelyth ECC
Skill

kotlin-testing

Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices.

Invoke via:use kotlin-testing
Origin:ECC

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

  • Identify target code — Find the function, class, or module to test
  • Write a Kotest spec — Choose a spec style (StringSpec, FunSpec, BehaviorSpec) matching the test scope
  • Mock dependencies — Use MockK to isolate the unit under test
  • Run tests (RED) — Verify the test fails with the expected error
  • Implement code (GREEN) — Write minimal code to pass the test
  • Refactor — Improve the implementation while keeping tests green
  • 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 first
GREEN   -> Write minimal code to pass the test
REFACTOR -> Improve code while keeping tests green
REPEAT  -> Continue with next requirement

#### Step-by-Step TDD in 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("[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)

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)

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)

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)

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

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) }

#### Custom Matchers

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

#### Basic Mocking

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

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

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

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

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

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 } } })

Property-Based Testing

#### Kotest Property Testing

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 protected]") }) { user -> val json = Json.encodeToString(user) val decoded = Json.decodeFromString<User>(json) decoded shouldBe user } } })

#### Custom Generators

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

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

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

// 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 Coverage

#### Gradle Configuration

// 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 } } } }

#### Coverage Commands

# 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 Type | Target | |-----------|--------| | Critical business logic | 100% | | Public APIs | 90%+ | | General code | 80%+ | | Generated / config code | Exclude |

Ktor testApplication Testing

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

# 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

# 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 }}

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.