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
kotlin
1// Step 1: Define the interface/signature
2// EmailValidator.kt
3package com.example.validator
4
5fun validateEmail(email: String): Result<String> {
6 TODO("not implemented")
7}
8
9// Step 2: Write failing test (RED)
10// EmailValidatorTest.kt
11package com.example.validator
12
13import io.kotest.core.spec.style.StringSpec
14import io.kotest.matchers.result.shouldBeFailure
15import io.kotest.matchers.result.shouldBeSuccess
16
17class EmailValidatorTest : StringSpec({
18 "valid email returns success" {
19 validateEmail("user@example.com").shouldBeSuccess("user@example.com")
20 }
21
22 "empty email returns failure" {
23 validateEmail("").shouldBeFailure()
24 }
25
26 "email without @ returns failure" {
27 validateEmail("userexample.com").shouldBeFailure()
28 }
29})
30
31// Step 3: Run tests - verify FAIL
32// $ ./gradlew test
33// EmailValidatorTest > valid email returns success FAILED
34// kotlin.NotImplementedError: An operation is not implemented
35
36// Step 4: Implement minimal code (GREEN)
37fun validateEmail(email: String): Result<String> {
38 if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank"))
39 if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @"))
40 val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
41 if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format"))
42 return Result.success(email)
43}
44
45// Step 5: Run tests - verify PASS
46// $ ./gradlew test
47// EmailValidatorTest > valid email returns success PASSED
48// EmailValidatorTest > empty email returns failure PASSED
49// EmailValidatorTest > email without @ returns failure PASSED
50
51// Step 6: Refactor if needed, verify tests still pass
Kotest Spec Styles
StringSpec (Simplest)
kotlin
1class CalculatorTest : StringSpec({
2 "add two positive numbers" {
3 Calculator.add(2, 3) shouldBe 5
4 }
5
6 "add negative numbers" {
7 Calculator.add(-1, -2) shouldBe -3
8 }
9
10 "add zero" {
11 Calculator.add(0, 5) shouldBe 5
12 }
13})
FunSpec (JUnit-like)
kotlin
1class UserServiceTest : FunSpec({
2 val repository = mockk<UserRepository>()
3 val service = UserService(repository)
4
5 test("getUser returns user when found") {
6 val expected = User(id = "1", name = "Alice")
7 coEvery { repository.findById("1") } returns expected
8
9 val result = service.getUser("1")
10
11 result shouldBe expected
12 }
13
14 test("getUser throws when not found") {
15 coEvery { repository.findById("999") } returns null
16
17 shouldThrow<UserNotFoundException> {
18 service.getUser("999")
19 }
20 }
21})
BehaviorSpec (BDD Style)
kotlin
1class OrderServiceTest : BehaviorSpec({
2 val repository = mockk<OrderRepository>()
3 val paymentService = mockk<PaymentService>()
4 val service = OrderService(repository, paymentService)
5
6 Given("a valid order request") {
7 val request = CreateOrderRequest(
8 userId = "user-1",
9 items = listOf(OrderItem("product-1", quantity = 2)),
10 )
11
12 When("the order is placed") {
13 coEvery { paymentService.charge(any()) } returns PaymentResult.Success
14 coEvery { repository.save(any()) } answers { firstArg() }
15
16 val result = service.placeOrder(request)
17
18 Then("it should return a confirmed order") {
19 result.status shouldBe OrderStatus.CONFIRMED
20 }
21
22 Then("it should charge payment") {
23 coVerify(exactly = 1) { paymentService.charge(any()) }
24 }
25 }
26
27 When("payment fails") {
28 coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
29
30 Then("it should throw PaymentException") {
31 shouldThrow<PaymentException> {
32 service.placeOrder(request)
33 }
34 }
35 }
36 }
37})
DescribeSpec (RSpec Style)
kotlin
1class UserValidatorTest : DescribeSpec({
2 describe("validateUser") {
3 val validator = UserValidator()
4
5 context("with valid input") {
6 it("accepts a normal user") {
7 val user = CreateUserRequest("Alice", "alice@example.com")
8 validator.validate(user).shouldBeValid()
9 }
10 }
11
12 context("with invalid name") {
13 it("rejects blank name") {
14 val user = CreateUserRequest("", "alice@example.com")
15 validator.validate(user).shouldBeInvalid()
16 }
17
18 it("rejects name exceeding max length") {
19 val user = CreateUserRequest("A".repeat(256), "alice@example.com")
20 validator.validate(user).shouldBeInvalid()
21 }
22 }
23 }
24})
Kotest Matchers
Core Matchers
kotlin
1import io.kotest.matchers.shouldBe
2import io.kotest.matchers.shouldNotBe
3import io.kotest.matchers.string.*
4import io.kotest.matchers.collections.*
5import io.kotest.matchers.nulls.*
6
7// Equality
8result shouldBe expected
9result shouldNotBe unexpected
10
11// Strings
12name shouldStartWith "Al"
13name shouldEndWith "ice"
14name shouldContain "lic"
15name shouldMatch Regex("[A-Z][a-z]+")
16name.shouldBeBlank()
17
18// Collections
19list shouldContain "item"
20list shouldHaveSize 3
21list.shouldBeSorted()
22list.shouldContainAll("a", "b", "c")
23list.shouldBeEmpty()
24
25// Nulls
26result.shouldNotBeNull()
27result.shouldBeNull()
28
29// Types
30result.shouldBeInstanceOf<User>()
31
32// Numbers
33count shouldBeGreaterThan 0
34price shouldBeInRange 1.0..100.0
35
36// Exceptions
37shouldThrow<IllegalArgumentException> {
38 validateAge(-1)
39}.message shouldBe "Age must be positive"
40
41shouldNotThrow<Exception> {
42 validateAge(25)
43}
Custom Matchers
kotlin
1fun beActiveUser() = object : Matcher<User> {
2 override fun test(value: User) = MatcherResult(
3 value.isActive && value.lastLogin != null,
4 { "User ${value.id} should be active with a last login" },
5 { "User ${value.id} should not be active" },
6 )
7}
8
9// Usage
10user should beActiveUser()
MockK
Basic Mocking
kotlin
1class UserServiceTest : FunSpec({
2 val repository = mockk<UserRepository>()
3 val logger = mockk<Logger>(relaxed = true) // Relaxed: returns defaults
4 val service = UserService(repository, logger)
5
6 beforeTest {
7 clearMocks(repository, logger)
8 }
9
10 test("findUser delegates to repository") {
11 val expected = User(id = "1", name = "Alice")
12 every { repository.findById("1") } returns expected
13
14 val result = service.findUser("1")
15
16 result shouldBe expected
17 verify(exactly = 1) { repository.findById("1") }
18 }
19
20 test("findUser returns null for unknown id") {
21 every { repository.findById(any()) } returns null
22
23 val result = service.findUser("unknown")
24
25 result.shouldBeNull()
26 }
27})
Coroutine Mocking
kotlin
1class AsyncUserServiceTest : FunSpec({
2 val repository = mockk<UserRepository>()
3 val service = UserService(repository)
4
5 test("getUser suspending function") {
6 coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
7
8 val result = service.getUser("1")
9
10 result.name shouldBe "Alice"
11 coVerify { repository.findById("1") }
12 }
13
14 test("getUser with delay") {
15 coEvery { repository.findById("1") } coAnswers {
16 delay(100) // Simulate async work
17 User(id = "1", name = "Alice")
18 }
19
20 val result = service.getUser("1")
21 result.name shouldBe "Alice"
22 }
23})
Argument Capture
kotlin
1test("save captures the user argument") {
2 val slot = slot<User>()
3 coEvery { repository.save(capture(slot)) } returns Unit
4
5 service.createUser(CreateUserRequest("Alice", "alice@example.com"))
6
7 slot.captured.name shouldBe "Alice"
8 slot.captured.email shouldBe "alice@example.com"
9 slot.captured.id.shouldNotBeNull()
10}
Spy and Partial Mocking
kotlin
1test("spy on real object") {
2 val realService = UserService(repository)
3 val spy = spyk(realService)
4
5 every { spy.generateId() } returns "fixed-id"
6
7 spy.createUser(request)
8
9 verify { spy.generateId() } // Overridden
10 // Other methods use real implementation
11}
Coroutine Testing
runTest for Suspend Functions
kotlin
1import kotlinx.coroutines.test.runTest
2
3class CoroutineServiceTest : FunSpec({
4 test("concurrent fetches complete together") {
5 runTest {
6 val service = DataService(testScope = this)
7
8 val result = service.fetchAllData()
9
10 result.users.shouldNotBeEmpty()
11 result.products.shouldNotBeEmpty()
12 }
13 }
14
15 test("timeout after delay") {
16 runTest {
17 val service = SlowService()
18
19 shouldThrow<TimeoutCancellationException> {
20 withTimeout(100) {
21 service.slowOperation() // Takes > 100ms
22 }
23 }
24 }
25 }
26})
Testing Flows
kotlin
1import io.kotest.matchers.collections.shouldContainInOrder
2import kotlinx.coroutines.flow.MutableSharedFlow
3import kotlinx.coroutines.flow.toList
4import kotlinx.coroutines.launch
5import kotlinx.coroutines.test.advanceTimeBy
6import kotlinx.coroutines.test.runTest
7
8class FlowServiceTest : FunSpec({
9 test("observeUsers emits updates") {
10 runTest {
11 val service = UserFlowService()
12
13 val emissions = service.observeUsers()
14 .take(3)
15 .toList()
16
17 emissions shouldHaveSize 3
18 emissions.last().shouldNotBeEmpty()
19 }
20 }
21
22 test("searchUsers debounces input") {
23 runTest {
24 val service = SearchService()
25 val queries = MutableSharedFlow<String>()
26
27 val results = mutableListOf<List<User>>()
28 val job = launch {
29 service.searchUsers(queries).collect { results.add(it) }
30 }
31
32 queries.emit("a")
33 queries.emit("ab")
34 queries.emit("abc") // Only this should trigger search
35 advanceTimeBy(500)
36
37 results shouldHaveSize 1
38 job.cancel()
39 }
40 }
41})
TestDispatcher
kotlin
1import kotlinx.coroutines.test.StandardTestDispatcher
2import kotlinx.coroutines.test.advanceUntilIdle
3
4class DispatcherTest : FunSpec({
5 test("uses test dispatcher for controlled execution") {
6 val dispatcher = StandardTestDispatcher()
7
8 runTest(dispatcher) {
9 var completed = false
10
11 launch {
12 delay(1000)
13 completed = true
14 }
15
16 completed shouldBe false
17 advanceTimeBy(1000)
18 completed shouldBe true
19 }
20 }
21})
Property-Based Testing
Kotest Property Testing
kotlin
1import io.kotest.core.spec.style.FunSpec
2import io.kotest.property.Arb
3import io.kotest.property.arbitrary.*
4import io.kotest.property.forAll
5import io.kotest.property.checkAll
6import kotlinx.serialization.json.Json
7import kotlinx.serialization.encodeToString
8import kotlinx.serialization.decodeFromString
9
10// Note: The serialization roundtrip test below requires the User data class
11// to be annotated with @Serializable (from kotlinx.serialization).
12
13class PropertyTest : FunSpec({
14 test("string reverse is involutory") {
15 forAll<String> { s ->
16 s.reversed().reversed() == s
17 }
18 }
19
20 test("list sort is idempotent") {
21 forAll(Arb.list(Arb.int())) { list ->
22 list.sorted() == list.sorted().sorted()
23 }
24 }
25
26 test("serialization roundtrip preserves data") {
27 checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
28 User(name = name, email = "$email@test.com")
29 }) { user ->
30 val json = Json.encodeToString(user)
31 val decoded = Json.decodeFromString<User>(json)
32 decoded shouldBe user
33 }
34 }
35})
Custom Generators
kotlin
1val userArb: Arb<User> = Arb.bind(
2 Arb.string(minSize = 1, maxSize = 50),
3 Arb.email(),
4 Arb.enum<Role>(),
5) { name, email, role ->
6 User(
7 id = UserId(UUID.randomUUID().toString()),
8 name = name,
9 email = Email(email),
10 role = role,
11 )
12}
13
14val moneyArb: Arb<Money> = Arb.bind(
15 Arb.long(1L..1_000_000L),
16 Arb.enum<Currency>(),
17) { amount, currency ->
18 Money(amount, currency)
19}
Data-Driven Testing
withData in Kotest
kotlin
1class ParserTest : FunSpec({
2 context("parsing valid dates") {
3 withData(
4 "2026-01-15" to LocalDate(2026, 1, 15),
5 "2026-12-31" to LocalDate(2026, 12, 31),
6 "2000-01-01" to LocalDate(2000, 1, 1),
7 ) { (input, expected) ->
8 parseDate(input) shouldBe expected
9 }
10 }
11
12 context("rejecting invalid dates") {
13 withData(
14 nameFn = { "rejects '$it'" },
15 "not-a-date",
16 "2026-13-01",
17 "2026-00-15",
18 "",
19 ) { input ->
20 shouldThrow<DateParseException> {
21 parseDate(input)
22 }
23 }
24 }
25})
Test Lifecycle and Fixtures
BeforeTest / AfterTest
kotlin
1class DatabaseTest : FunSpec({
2 lateinit var db: Database
3
4 beforeSpec {
5 db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
6 transaction(db) {
7 SchemaUtils.create(UsersTable)
8 }
9 }
10
11 afterSpec {
12 transaction(db) {
13 SchemaUtils.drop(UsersTable)
14 }
15 }
16
17 beforeTest {
18 transaction(db) {
19 UsersTable.deleteAll()
20 }
21 }
22
23 test("insert and retrieve user") {
24 transaction(db) {
25 UsersTable.insert {
26 it[name] = "Alice"
27 it[email] = "alice@example.com"
28 }
29 }
30
31 val users = transaction(db) {
32 UsersTable.selectAll().map { it[UsersTable.name] }
33 }
34
35 users shouldContain "Alice"
36 }
37})
Kotest Extensions
kotlin
1// Reusable test extension
2class DatabaseExtension : BeforeSpecListener, AfterSpecListener {
3 lateinit var db: Database
4
5 override suspend fun beforeSpec(spec: Spec) {
6 db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
7 }
8
9 override suspend fun afterSpec(spec: Spec) {
10 // cleanup
11 }
12}
13
14class UserRepositoryTest : FunSpec({
15 val dbExt = DatabaseExtension()
16 register(dbExt)
17
18 test("save and find user") {
19 val repo = UserRepository(dbExt.db)
20 // ...
21 }
22})
Kover Coverage
Gradle Configuration
kotlin
1// build.gradle.kts
2plugins {
3 id("org.jetbrains.kotlinx.kover") version "0.9.7"
4}
5
6kover {
7 reports {
8 total {
9 html { onCheck = true }
10 xml { onCheck = true }
11 }
12 filters {
13 excludes {
14 classes("*.generated.*", "*.config.*")
15 }
16 }
17 verify {
18 rule {
19 minBound(80) // Fail build below 80% coverage
20 }
21 }
22 }
23}
Coverage Commands
bash
1# Run tests with coverage
2./gradlew koverHtmlReport
3
4# Verify coverage thresholds
5./gradlew koverVerify
6
7# XML report for CI
8./gradlew koverXmlReport
9
10# View HTML report (use the command for your OS)
11# macOS: open build/reports/kover/html/index.html
12# Linux: xdg-open build/reports/kover/html/index.html
13# 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
1class ApiRoutesTest : FunSpec({
2 test("GET /users returns list") {
3 testApplication {
4 application {
5 configureRouting()
6 configureSerialization()
7 }
8
9 val response = client.get("/users")
10
11 response.status shouldBe HttpStatusCode.OK
12 val users = response.body<List<UserResponse>>()
13 users.shouldNotBeEmpty()
14 }
15 }
16
17 test("POST /users creates user") {
18 testApplication {
19 application {
20 configureRouting()
21 configureSerialization()
22 }
23
24 val response = client.post("/users") {
25 contentType(ContentType.Application.Json)
26 setBody(CreateUserRequest("Alice", "alice@example.com"))
27 }
28
29 response.status shouldBe HttpStatusCode.Created
30 }
31 }
32})
Testing Commands
bash
1# Run all tests
2./gradlew test
3
4# Run specific test class
5./gradlew test --tests "com.example.UserServiceTest"
6
7# Run specific test
8./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
9
10# Run with verbose output
11./gradlew test --info
12
13# Run with coverage
14./gradlew koverHtmlReport
15
16# Run detekt (static analysis)
17./gradlew detekt
18
19# Run ktlint (formatting check)
20./gradlew ktlintCheck
21
22# Continuous testing
23./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
1# GitHub Actions example
2test:
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v4
6 - uses: actions/setup-java@v4
7 with:
8 distribution: 'temurin'
9 java-version: '21'
10
11 - name: Run tests with coverage
12 run: ./gradlew test koverXmlReport
13
14 - name: Verify coverage
15 run: ./gradlew koverVerify
16
17 - name: Upload coverage
18 uses: codecov/codecov-action@v5
19 with:
20 files: build/reports/kover/report.xml
21 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.