Kotlin Unit Testing Guide: JUnit 5, Assertions, and Test Structure

Kotlin Unit Testing Guide: JUnit 5, Assertions, and Test Structure

Kotlin runs on the JVM and integrates seamlessly with the Java testing ecosystem. JUnit 5 is the standard choice for Kotlin projects, but Kotlin's language features — named parameters, extension functions, infix notation, and data classes — enable more expressive test code than Java allows.

This guide covers how to structure Kotlin unit tests with JUnit 5 and write assertions idiomatically.

Setup

Add JUnit 5 to your Gradle build:

// build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}

For Maven:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

A Basic Test Class

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class CalculatorTest {

    private val calc = Calculator()

    @Test
    fun `addition returns correct sum`() {
        val result = calc.add(2, 3)
        assertEquals(5, result)
    }

    @Test
    fun `division by zero throws exception`() {
        assertThrows<ArithmeticException> {
            calc.divide(10, 0)
        }
    }
}

Backtick function names allow spaces, making test intent readable in reports without camelCase decoding.

Kotlin-Friendly Assertions

JUnit 5's Assertions class works fine, but the API is more fluent with assertk or simply using Kotlin's standard idioms.

JUnit 5 Built-in

assertEquals(expected, actual)
assertNotEquals(a, b)
assertTrue(condition)
assertFalse(condition)
assertNull(value)
assertNotNull(value)
assertThrows<ExceptionType> { block }
assertDoesNotThrow { block }

Grouped Assertions with assertAll

assertAll runs all assertions even if earlier ones fail:

assertAll("user fields",
    { assertEquals("Alice", user.name) },
    { assertEquals(30, user.age) },
    { assertTrue(user.isActive) }
)

This shows all failures at once instead of stopping at the first one.

Lifecycle Hooks

JUnit 5 provides annotations for setup and teardown:

import org.junit.jupiter.api.*

class DatabaseTest {

    companion object {
        @JvmStatic
        @BeforeAll
        fun setUpClass() {
            // runs once before all tests in this class
        }

        @JvmStatic
        @AfterAll
        fun tearDownClass() {
            // runs once after all tests
        }
    }

    @BeforeEach
    fun setUp() {
        // runs before each test
    }

    @AfterEach
    fun tearDown() {
        // runs after each test
    }
}

Note @JvmStatic — JUnit 5 requires @BeforeAll and @AfterAll methods to be static, which in Kotlin means companion object + @JvmStatic.

To avoid this, use @TestInstance(TestInstance.Lifecycle.PER_CLASS):

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DatabaseTest {

    @BeforeAll
    fun setUpClass() {
        // no companion object needed — this instance persists across tests
    }
}

Parameterized Tests

JUnit 5 supports parameterized tests via @ParameterizedTest:

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

class ParserTest {

    @ParameterizedTest
    @ValueSource(strings = ["hello", "world", "kotlin"])
    fun `non-empty strings are valid identifiers`(input: String) {
        assertTrue(isValidIdentifier(input))
    }

    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource(
        "1, 2, 3",
        "0, 0, 0",
        "-1, 1, 0",
        "100, 200, 300"
    )
    fun `addition is correct`(a: Int, b: Int, expected: Int) {
        assertEquals(expected, a + b)
    }
}

For complex parameter types, use @MethodSource:

companion object {
    @JvmStatic
    fun userProvider() = listOf(
        Arguments.of(User("Alice", 25), true),
        Arguments.of(User("", 25), false),
        Arguments.of(User("Bob", -1), false),
    )
}

@ParameterizedTest
@MethodSource("userProvider")
fun `user validation`(user: User, expected: Boolean) {
    assertEquals(expected, user.isValid())
}

Nested Test Classes

@Nested groups related tests and allows shared setup per group:

class OrderTest {

    @Nested
    inner class `when order is empty` {
        private val order = Order()

        @Test
        fun `total is zero`() {
            assertEquals(0.0, order.total())
        }

        @Test
        fun `item count is zero`() {
            assertEquals(0, order.itemCount())
        }
    }

    @Nested
    inner class `when order has items` {
        private val order = Order().apply {
            addItem(Item("Widget", 9.99))
            addItem(Item("Gadget", 24.99))
        }

        @Test
        fun `total reflects all items`() {
            assertEquals(34.98, order.total(), 0.001)
        }
    }
}

inner class allows access to the outer class's state. Test reports show the nested structure, making failure context clear.

Tagging and Filtering

Tag tests to run subsets in different environments:

import org.junit.jupiter.api.Tag

@Tag("fast")
@Test
fun `quick computation test`() { ... }

@Tag("integration")
@Test
fun `database read test`() { ... }

Run by tag:

./gradlew test -Djunit.jupiter.tags.include=fast
./gradlew <span class="hljs-built_in">test -Djunit.jupiter.tags.exclude=integration

Disabling Tests

@Disabled("Flaky — tracked in HEL-123")
@Test
fun `intermittent network test`() { ... }

Or conditionally:

import org.junit.jupiter.api.condition.*

@EnabledOnOs(OS.LINUX)
@Test
fun `linux-specific behavior`() { ... }

@EnabledIfSystemProperty(named = "env", matches = "ci")
@Test
fun `ci-only test`() { ... }

Testing Data Classes

Kotlin data classes generate equals and hashCode automatically, making assertion straightforward:

data class Point(val x: Int, val y: Int)

@Test
fun `point translation is correct`() {
    val point = Point(1, 2)
    val translated = point.copy(x = point.x + 3, y = point.y + 4)
    assertEquals(Point(4, 6), translated)
}

Testing Sealed Classes

Pattern-match with when in assertions:

sealed class Result<out T>
data class Success<T>(val value: T) : Result<T>()
data class Failure(val error: String) : Result<Nothing>()

@Test
fun `successful parse returns value`() {
    val result = parse("42")
    when (result) {
        is Success -> assertEquals(42, result.value)
        is Failure -> fail("Expected success but got: ${result.error}")
    }
}

Running Tests

./gradlew test           <span class="hljs-comment"># run all tests
./gradlew <span class="hljs-built_in">test --tests <span class="hljs-string">"*.CalculatorTest"   <span class="hljs-comment"># specific class
./gradlew <span class="hljs-built_in">test --tests <span class="hljs-string">"*.CalculatorTest.addition*"  <span class="hljs-comment"># specific test

Continuous Production Testing

JUnit 5 tests validate logic at build time. For 24/7 production monitoring against your live service, HelpMeTest runs behavioral test scenarios in plain English — no code, no Kotlin toolchain, just test descriptions and assertions against live endpoints.

Summary

  • JUnit 5 + useJUnitPlatform() is the standard Kotlin test setup
  • Backtick function names enable readable test names
  • @TestInstance(Lifecycle.PER_CLASS) avoids companion object for lifecycle methods
  • @ParameterizedTest with @CsvSource and @MethodSource for data-driven tests
  • @Nested inner classes group related tests with per-group setup
  • Data classes and sealed classes test idiomatically with assertEquals and when

Read more