Testing Kotlin with JUnit 5, Kotest and MockK

Structure tests and assertions, parameterise cases, use property-based testing, mock with MockK, and wire coverage and CI.

JUnit 5 and parameterised tests

import org.junit.jupiter.api.*
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals

class PriceCalculatorTest {

    private lateinit var calculator: PriceCalculator

    @BeforeEach
    fun setUp() {
        calculator = PriceCalculator(taxRate = 0.2)
    }

    @Test
    fun appliesDiscountBeforeTax() {
        val total = calculator.total(unitPrice = 100, quantity = 3, discount = 0.1)
        assertEquals(324, total)
    }

    @ParameterizedTest(name = "{0} x {1} is {2}")
    @CsvSource("1, 100, 120", "3, 100, 360", "0, 100, 0")
    fun totals(quantity: Int, unitPrice: Int, expected: Int) {
        assertEquals(expected, calculator.total(unitPrice, quantity, 0.0))
    }

    @Test
    fun rejectsNegativeQuantity() {
        assertThrows<IllegalArgumentException> {
            calculator.total(unitPrice = 100, quantity = -1, discount = 0.0)
        }
    }
}
  • One behaviour per test and a name that states the rule; a failure should tell you what broke without reading the body.
  • @BeforeEach creates a fresh subject so tests cannot leak state into each other.
  • @Nested groups tests by scenario and mirrors the structure of the class under test.
  • Use assertAll when several assertions belong to one case so the first failure does not hide the rest.

Kotest and property-based testing

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.list
import io.kotest.property.checkAll

class CartSpec : StringSpec({
    "an empty cart totals zero" {
        Cart().total() shouldBe 0
    }

    "adding an item increases the count" {
        val cart = Cart().apply { add("sku-1") }
        cart.lines shouldHaveSize 1
    }

    "totals are never negative for non-negative prices" {
        checkAll(Arb.list(Arb.int(0..10_000), 0..50)) { prices ->
            val cart = Cart.fromPrices(prices)
            (cart.total() >= 0) shouldBe true
        }
    }
})
StyleShapeFits
StringSpecFlat list of string-named testsSmall focused units
FunSpecTest functions with nested contextsBehaviour trees
BehaviorSpecGiven/When/ThenDomain rules read by non-programmers
DescribeSpecdescribe/itTeams coming from Jest or RSpec

MockK in practice

import io.mockk.*
import io.mockk.coEvery
import kotlinx.coroutines.test.runTest

class UserServiceTest {
    private val client = mockk<ApiClient>()
    private val service = UserService(client)

    @Test
    fun returnsUserOnSuccess() = runTest {
        coEvery { client.get("/users/1") } returns """{"id":"1","name":"Ada"}"""

        val result = service.load("1")

        assertEquals("Ada", (result as Either.Ok).value.name)
        coVerify(exactly = 1) { client.get("/users/1") }
    }

    @Test
    fun mapsHttpFailureToTypedError() = runTest {
        coEvery { client.get(any()) } throws HttpException(503, "unavailable")

        val result = service.load("1")

        assertTrue((result as Either.Err).error is LoadError.Http)
    }

    // relax only the calls the test does not care about
    @Test
    fun usesRelaxedMock() {
        val logger = mockk<Logger>(relaxed = true)
        serviceWith(logger).load("1")
        verify { logger.log(any()) }
    }
}
⚠️
A relaxed mock returns default values for everything, which hides a dependency that was never stubbed. Use it sparingly, and never for the object whose behaviour the test is actually verifying.

FAQ

Is property-based testing worth it for ordinary code?
Yes for pure logic with invariants: money arithmetic, parsing, sorting, serialisation round trips. One property test with a hundred generated cases often finds an edge case that a dozen handwritten examples miss.
How much coverage should CI enforce?
Set a floor that fails the build on a regression rather than a target to chase — for example a drop of more than one percent. Coverage is a smoke alarm, not a quality measure.

Setting up Kotlin: Gradle, the K2 compiler and project layout Exceptions, Result and error-handling patterns

Last refreshed 2026-09-18.