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.
@BeforeEachcreates a fresh subject so tests cannot leak state into each other.@Nestedgroups tests by scenario and mirrors the structure of the class under test.- Use
assertAllwhen 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
}
}
})| Style | Shape | Fits |
|---|---|---|
| StringSpec | Flat list of string-named tests | Small focused units |
| FunSpec | Test functions with nested contexts | Behaviour trees |
| BehaviorSpec | Given/When/Then | Domain rules read by non-programmers |
| DescribeSpec | describe/it | Teams 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.
Related
Setting up Kotlin: Gradle, the K2 compiler and project layout Exceptions, Result and error-handling patterns
Last refreshed 2026-09-18.