Skip to content

06 · Testing with JUnit/Kotest

Untested Kotlin code is a liability the moment more than one person touches it. This module covers the two most common testing setups in the Kotlin ecosystem: JUnit 5, the JVM-wide standard (and what Kotlin projects default to), and Kotest, a Kotlin-native framework with a more expressive, idiomatic style. Both are wired into a project via Gradle, and Module 10 uses JUnit to test the capstone project.

JUnit 5 basics

A JUnit test is a function annotated @Test inside a class. Assertions come from org.junit.jupiter.api.Assertions (or Kotlin-friendlier top-level equivalents).

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue

class Calculator {
    fun add(a: Int, b: Int) = a + b
    fun isEven(n: Int) = n % 2 == 0
}

class CalculatorTest {
    private val calculator = Calculator()

    @Test
    fun `add returns the sum of two numbers`() {
        assertEquals(5, calculator.add(2, 3))
    }

    @Test
    fun `isEven correctly identifies even numbers`() {
        assertTrue(calculator.isEven(4))
    }
}

Kotlin lets you write test method names as backtick-quoted strings with spaces (`add returns the sum of two numbers`()) — test reports show the readable sentence instead of a cramped camelCase name.

Setup and teardown with @BeforeEach/@AfterEach

Shared setup that needs to run fresh before every test goes in a @BeforeEach method — this avoids one test's leftover state leaking into another.

import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals

class ShoppingCart {
    private val items = mutableListOf<String>()
    fun add(item: String) = items.add(item)
    fun itemCount() = items.size
}

class ShoppingCartTest {
    private lateinit var cart: ShoppingCart

    @BeforeEach
    fun setUp() {
        cart = ShoppingCart()   // a brand-new cart before every single test method
    }

    @Test
    fun `new cart starts empty`() {
        assertEquals(0, cart.itemCount())
    }

    @Test
    fun `adding an item increases the count`() {
        cart.add("apple")
        assertEquals(1, cart.itemCount())
    }
}

Testing exceptions

assertThrows runs a block and asserts it throws the expected exception type, then gives you the caught exception to make further assertions on.

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertEquals

class Account(private var balance: Int) {
    fun withdraw(amount: Int) {
        check(amount <= balance) { "Insufficient funds" }
        balance -= amount
    }
}

class AccountTest {
    @Test
    fun `withdrawing more than the balance throws`() {
        val account = Account(50)
        val exception = assertThrows(IllegalStateException::class.java) {
            account.withdraw(100)
        }
        assertEquals("Insufficient funds", exception.message)
    }
}

Parameterized tests

Instead of copy-pasting near-identical test methods, @ParameterizedTest runs the same test body once per supplied value.

import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.junit.jupiter.api.Assertions.assertTrue

class PositivityTest {
    @ParameterizedTest
    @ValueSource(ints = [1, 2, 100, 9999])
    fun `these numbers are all positive`(value: Int) {
        assertTrue(value > 0)
    }
}

Kotest: a Kotlin-native alternative

Kotest reads more like natural language and comes with a large library of shouldBe-style matchers. StringSpec is its simplest style — each test is just a string description plus a lambda.

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

class CalculatorSpec : StringSpec({
    "adding two numbers should return their sum" {
        val calculator = Calculator()
        calculator.add(2, 3) shouldBe 5
    }

    "a list of fruits should contain banana" {
        val fruits = listOf("apple", "banana", "cherry")
        fruits shouldContain "banana"
    }
})

FunSpec is closer to plain JUnit-style naming if you prefer test(...) blocks over string-literal test names:

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.assertions.throwables.shouldThrow

class AccountSpec : FunSpec({
    test("withdrawing more than the balance throws") {
        val account = Account(50)
        shouldThrow<IllegalStateException> {
            account.withdraw(100)
        }
    }

    test("add returns the sum") {
        Calculator().add(2, 3) shouldBe 5
    }
})

Testing suspend functions

A suspend function can't be called directly from a normal @Test method. kotlinx-coroutines-test's runTest gives you a coroutine scope built for tests — it also runs any delay() calls instantly instead of actually waiting, so tests using Module 3's coroutines stay fast.

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

suspend fun fetchAnswer(): Int {
    delay(1000)   // in a real test, this doesn't actually wait a full second
    return 42
}

class AsyncTest {
    @Test
    fun `fetchAnswer eventually returns 42`() = runTest {
        val answer = fetchAnswer()
        assertEquals(42, answer)
    }
}

JUnit vs. Kotest

JUnit 5 Kotest
Style Annotated methods (@Test) Spec classes (StringSpec, FunSpec, ...)
Assertions assertEquals(expected, actual) actual shouldBe expected
Ecosystem JVM-wide standard, huge tooling support Kotlin-only, very expressive matchers
Parameterized tests @ParameterizedTest + @ValueSource/@CsvSource Built-in data-driven testing (forAll, table tests)
Good default when Working across Java+Kotlin, or team already knows JUnit Kotlin-only codebase wanting more readable specs

How It Actually Works

JUnit 5 has no special awareness of Kotlin — @Test is a plain annotation retained at runtime (RetentionPolicy.RUNTIME), and the JUnit Platform engine finds test methods purely by reflection: it scans compiled .class files on the test classpath, looks for methods carrying that annotation, instantiates the declaring class with its no-arg constructor, and invokes the method via java.lang.reflect.Method.invoke(). Nothing about this pipeline is Kotlin-specific — CalculatorTest is a completely ordinary JVM class from the runner's point of view, Calculator() inside it compiles to a normal constructor call, and calculator.add(2, 3) is a normal invokevirtual.

Backtick-quoted test names like `add returns the sum of two numbers`() are possible because the JVM's class file format allows almost any string as a method name at the bytecode level (invokevirtual targets are resolved by a name-and-descriptor pair in the constant pool, not by identifier rules) — Kotlin source syntax normally restricts identifiers to valid characters, but backticks are an escape hatch letting you write a method name containing spaces, since the bytecode was always going to accept it. Reflection-based test discovery then reads that literal name straight out of the compiled method and displays it in reports verbatim.

@BeforeEach relies on the same reflection scan: the JUnit engine instantiates a fresh instance of the test class for every single test method (this is the default JUnit 5 lifecycle, PER_METHOD), so cart = ShoppingCart() in setUp() isn't resetting shared state between tests — there is no shared state, because each test method literally runs against its own separate ShoppingCartTest object, freshly constructed and then torn down. That per-method isolation is why lateinit var cart is even safe to use here: the property is guaranteed to be assigned in @BeforeEach before any @Test method on that fresh instance can run.

Cheat sheet

Need JUnit 5 Kotest
Mark a test @Test A string literal or test("...") block
Run before each test @BeforeEach beforeTest { }
Assert equality assertEquals(expected, actual) actual shouldBe expected
Assert an exception assertThrows(Type::class.java) { } shouldThrow<Type> { }
Parameterize @ParameterizedTest + @ValueSource withData(...) / table tests
Test a suspend fun runTest { } (from kotlinx-coroutines-test) same runTest { } works inside a spec

Exercise

Write a small PasswordValidator class with a function isValid(password: String): Boolean that requires at least 8 characters and at least one digit. Then write a JUnit 5 test class with: one test for a valid password, one for a too-short password, one for a password with no digit, and a @ParameterizedTest with @ValueSource covering several more valid passwords at once.