--- name: kotlin-testing description: Kotlin testing patterns with Kotest, MockK, coroutine testing, property-based testing, and Kover coverage. Follows TDD methodology with idiomatic Kotlin practices. 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 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 - **Kotest specs** — StringSpec, FunSpec, BehaviorSpec, DescribeSpec examples in [Kotest Spec Styles](#kotest-spec-styles) - **Mocking** — MockK setup, coroutine mocking, argument capture in [MockK](#mockk) - **TDD walkthrough** — Full RED/GREEN/REFACTOR cycle with EmailValidator in [TDD Workflow for Kotlin](#tdd-workflow-for-kotlin) - **Coverage** — Kover configuration and commands in [Kover Coverage](#kover-coverage) - **Ktor testing** — testApplication setup in [Ktor testApplication Testing](#ktor-testapplication-testing) ### 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 ```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 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() 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 Style) ```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 Style) ```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 Matchers #### Core Matchers ```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) } ``` #### Custom Matchers ```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 #### Basic Mocking ```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() } }) ``` #### Coroutine Mocking ```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" } }) ``` #### Argument Capture ```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() } ``` #### 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 { withTimeout(100) { service.slowOperation() // Takes > 100ms } } } } }) ``` #### Testing Flows ```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 } } }) ``` ### Property-Based Testing #### Kotest Property Testing ```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 } } }) ``` #### Custom Generators ```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) } ``` ### 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 { 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] = "alice@example.com" } } val users = transaction(db) { UsersTable.selectAll().map { it[UsersTable.name] } } users shouldContain "Alice" } }) ``` #### Kotest Extensions ```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 Coverage #### Gradle Configuration ```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 } } } } ``` #### 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 Type | Target | |-----------|--------| | Critical business logic | 100% | | Public APIs | 90%+ | | General code | 80%+ | | Generated / config code | Exclude | ### 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>() 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 } } }) ``` ### 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 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.