Testing

kotlin.test and JUnit 5, parameterized tests, MockK, and testing coroutines with runTest.

JUnit 5 Assertions @ParameterizedTest MockK runTest
๐Ÿงช

kotlin.test & JUnit 5

Most Kotlin/JVM projects run on JUnit 5 directly, using its annotations as-is โ€” @Test, @BeforeEach, @Nested all work unchanged from Kotlin. kotlin.test is a thin multiplatform layer on top (useful if the same test code needs to run on Kotlin/JS or Kotlin/Native too), but for a pure JVM project, writing directly against JUnit 5 is the more common and more directly documented path.

A JUnit 5 test class @Test
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Assertions.assertEquals

class CalculatorTest {

    private lateinit var calculator: Calculator

    // Runs before every test method in this class
    @BeforeEach
    fun setUp() {
        calculator = Calculator()
    }

    @Test
    fun `adding two positive numbers returns their sum`() {
        val result = calculator.add(2, 3)
        assertEquals(5, result)
    }
}
๐Ÿ’ก
Kotlin allows a function name written as an arbitrary string in backticks โ€” fun \`adding two positive numbers returns their sum\`() โ€” which is legal specifically because a test method is never called directly from other code, only invoked by the test runner via reflection. It's the idiomatic way to write a readable test name without a separate @DisplayName annotation, and most test reports display the backtick name verbatim.
โœ…

Assertion Styles

JUnit 5's own Assertions class covers the basics โ€” equality, truthiness, exceptions, nullability โ€” and is often all a project needs. Some teams layer a fluent assertion library like AssertJ or Kotest's matchers on top for more expressive failure messages and chainable checks; either is a project-level choice, not something the language mandates.

JUnit 5 built-in assertions Assertions
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class UserValidatorTest {

    @Test
    fun `rejects a blank name`() {
        val result = UserValidator.validate(name = "")
        assertFalse(result.isValid)
        assertEquals("name cannot be blank", result.error)
    }

    @Test
    fun `throws for a negative age`() {
        // assertThrows returns the thrown exception for further assertions
        val exception = assertThrows<IllegalArgumentException> {
            UserValidator.requireValidAge(-1)
        }
        assertEquals("age must be non-negative", exception.message)
    }

    @Test
    fun `nullable result is null for an unknown id`() {
        assertNull(UserRepository.findById(-1))
    }
}
โ„น๏ธ
Kotlin's reified generics (see Generics) let JUnit 5's Kotlin extensions expose assertThrows<ExceptionType> { block } as an inline function โ€” a much cleaner call than Java's equivalent, which needs the exception's Class object passed explicitly as an argument.
๐Ÿ”

Parameterized Tests

A parameterized test runs the same test body once per input, reported as separate test results โ€” the direct equivalent of Go's table-driven test pattern, but with the table declared as an annotation rather than a slice literal you range over by hand.

@ValueSource and @CsvSource @ParameterizedTest
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.junit.jupiter.params.provider.CsvSource

class StringUtilsTest {

    @ParameterizedTest
    @ValueSource(strings = ["racecar", "level", "noon"])
    fun `recognizes palindromes`(input: String) {
        assertTrue(input.isPalindrome())
    }

    // CsvSource: multiple named arguments per test case
    @ParameterizedTest
    @CsvSource(
        "2, 3, 5",
        "10, -5, 5",
        "0, 0, 0"
    )
    fun `adds two numbers correctly`(a: Int, b: Int, expected: Int) {
        assertEquals(expected, a + b)
    }
}
๐Ÿ’ก
Reach for @MethodSource instead of @CsvSource once your test cases need real objects rather than primitives and strings โ€” it points at a companion object function returning a Stream of arguments, which composes naturally with data class test-case objects for readability.
๐ŸŽญ

MockK Basics

Mockito is Java's default, but it struggles with Kotlin's language features โ€” final classes by default, extension functions, top-level functions โ€” because it works by generating subclasses at runtime. MockK was built for Kotlin specifically and handles all of that natively, which is why it's the more common choice in Kotlin-first codebases.

Stubbing and verifying with MockK every / verify
import io.mockk.*
import org.junit.jupiter.api.Test

interface UserRepository {
    fun findById(id: Int): User?
}

class UserServiceTest {

    @Test
    fun `returns the user's display name`() {
        // mockk() creates a fake implementation of the interface
        val repository = mockk<UserRepository>()

        // every { } stubs what the mock returns for a given call
        every { repository.findById(1) } returns User(1, "Ada")

        val service = UserService(repository)
        val name = service.displayNameFor(1)

        assertEquals("Ada", name)

        // verify { } asserts a specific call actually happened
        verify(exactly = 1) { repository.findById(1) }
    }
}
โ„น๏ธ
A strict mock (the default) throws if you call a method with no stubbed behavior โ€” good for catching unintended interactions. mockk<UserRepository>(relaxed = true) instead returns sensible defaults (empty collections, zero, null) for every unstubbed call, which trades that safety for less setup boilerplate when a test only cares about one or two specific interactions.
โฑ

runTest for Coroutines

A regular test function can't call a suspend function directly โ€” it isn't itself suspend, and there's no coroutine scope to launch into. runTest, from kotlinx-coroutines-test, solves both: it provides a TestScope and, more importantly, runs on virtual time, so a test that calls delay(10_000) completes instantly instead of actually waiting ten seconds.

Testing a suspend function runTest
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import org.junit.jupiter.api.Test

suspend fun fetchGreeting(): String {
    delay(5000)  // simulates a slow network call
    return "Hello"
}

class GreetingTest {

    @Test
    fun `fetches the greeting without actually waiting 5 seconds`() = runTest {
        val result = fetchGreeting()   // delay() is skipped forward on virtual time
        assertEquals("Hello", result)
    }
    // This test runs in milliseconds of real wall-clock time,
    // despite the 5-second delay() inside fetchGreeting()
}
๐Ÿ’ก
Use runTest's TestScope to launch background coroutines under test and control their virtual-time progression explicitly with functions like advanceTimeBy or advanceUntilIdle when a test needs to assert on state at a specific point mid-execution, not just at completion.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Test method@Test fun \`readable name\`()Backtick names avoid needing @DisplayName
Setup hook@BeforeEach fun setUp()Runs before every test in the class
Equality assertionassertEquals(expected, actual)Expected first, actual second
Exception assertionassertThrows<E> { block }Reified generic; returns the thrown exception
Simple parameterization@ParameterizedTest @ValueSource(...)One primitive/string argument per case
Multi-arg parameterization@CsvSource("a, b, c")Comma-separated arguments per test case
Create a mockmockk<Interface>()Strict by default; throws on unstubbed calls
Lenient mockmockk<Interface>(relaxed = true)Returns defaults for unstubbed calls
Stub a callevery { mock.f() } returns xDefines the mock's behavior
Verify a call happenedverify(exactly = 1) { mock.f() }Asserts on interaction, not just return value
Test a suspend function@Test fun f() = runTest { }Provides a TestScope; runs on virtual time
Advance virtual timeadvanceTimeBy / advanceUntilIdleControls delay() progression inside runTest