Phase 7 · Professional & AppliedModule 36~38 min read

Testing

Prove your code works with JUnit 5, Kotest, and MockK — including coroutines and flows.

What you'll learn

Professional code is tested code. Automated tests catch regressions, document intent, and let you refactor without fear. Kotlin has first-class testing tools — this module tours JUnit 5, Kotest, MockK, and how to test coroutines, so you can prove your code works.

By the end you'll be able to:

  • Write unit tests with JUnit 5 and assertions
  • Understand the expressive Kotest style
  • Isolate code from dependencies using MockK
  • Test suspending functions and flows, and apply TDD

JUnit 5

JUnit is the JVM's standard test framework and works perfectly with Kotlin. A test is a method annotated @Test that makes assertions. A lovely Kotlin touch: you can name test functions with backticks so they read as plain sentences in the report:

CalculatorTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals

class Calculator {
    fun add(a: Int, b: Int) = a + b
}

class CalculatorTest {
    private val calc = Calculator()

    @Test
    fun `adds two numbers`() {          // backtick names read like sentences
        assertEquals(5, calc.add(2, 3))
    }

    @Test
    fun `handles negative numbers`() {
        assertEquals(-1, calc.add(2, -3))
    }
}

Tip

Structure each test as Arrange, Act, Assert: set up the inputs, call the code under test, then assert the result. One clear behaviour per test makes failures instantly diagnosable.

Kotest

Kotest is a popular Kotlin-native framework with expressive matchers and several testing styles (StringSpec, FunSpec, BehaviorSpec…). Its shouldBe-style assertions read almost like English:

CalculatorSpec.kt
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldContain

class CalculatorSpec : StringSpec({
    "adds two numbers" {
        Calculator().add(2, 3) shouldBe 5      // fluent, readable matchers
    }
    "list contains an element" {
        listOf(1, 2, 3) shouldContain 2
    }
})

Mocking with MockK

A unit test should test one unit in isolation. When your class depends on a database, network, or other service, you replace that dependency with a mock — a stand-in whose behaviour you script. MockK is the idiomatic Kotlin mocking library:

MockkTest.kt
import io.mockk.every
import io.mockk.mockk

interface UserRepo { fun findName(id: Int): String }

class Service(private val repo: UserRepo) {
    fun greet(id: Int) = "Hello, ${repo.findName(id)}"
}

// In the test — replace the real repo with a controllable fake:
val repo = mockk<UserRepo>()
every { repo.findName(1) } returns "Ada"       // stub the behaviour
val greeting = Service(repo).greet(1)          // "Hello, Ada"

Note

Mocks let you test the "happy path" and force error cases (make the repo throw, return null, or be slow) that would be hard to reproduce with the real dependency. You can also verifythat a method was called with the expected arguments.

Testing coroutines

Testing suspending code needs a coroutine context. The kotlinx-coroutines-test library provides runTest, which runs your test in a special scope with a virtual clock — so delay(1000) completes instantly instead of actually waiting a second:

AsyncTest.kt
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals

suspend fun loadValue(): Int { delay(1000L); return 42 }

class AsyncTest {
    @Test
    fun `loads the value`() = runTest {   // virtual clock: delays are skipped
        assertEquals(42, loadValue())      // finishes instantly, not in 1s
    }
}

Test-driven development

TDD flips the usual order: write a failing test first, then the minimum code to make it pass, then refactor — the red, green, refactor cycle. It keeps you focused on behaviour and guarantees every line is covered by a test. You don't have to practise TDD strictly, but writing tests early always beats bolting them on later.

Key idea

Aim to test behaviour, not implementation. A good test survives a refactor: it says "given this input, I expect this result," without caring how the result is computed. Brittle tests that assert on internals are worse than no tests at all.

Recap & quick check

Key takeaways

  • Automated tests catch regressions, document intent, and make refactoring safe.
  • JUnit 5 is the JVM standard; Kotlin's backtick names make test reports read like sentences.
  • Kotest offers Kotlin-native styles and fluent shouldBe matchers.
  • MockK replaces real dependencies with scripted fakes so you can test a unit in isolation.
  • runTest from kotlinx-coroutines-test uses a virtual clock, so coroutine delays don't slow tests.

Quick check

1. What marks a method as a JUnit test?

2. Why use a mock in a unit test?

3. What does runTest do for coroutine tests?

4. What is the red-green-refactor cycle?

5. What should a good test focus on?

Tested code is trustworthy code. Next we'll learn how Kotlin projects are actually built and assembled with Gradle and the Kotlin ecosystem.