Kotlin Unit Testing Guide: JUnit 5, Assertions, and Test Structure
Kotlin runs on the JVM and integrates seamlessly with the Java testing ecosystem.
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 test -Djunit.jupiter.tags.exclude=integrationDisabling 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 # run all tests
./gradlew test --tests "*.CalculatorTest" # specific class
./gradlew test --tests "*.CalculatorTest.addition*" # specific testContinuous 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)avoidscompanion objectfor lifecycle methods@ParameterizedTestwith@CsvSourceand@MethodSourcefor data-driven tests@Nestedinner classes group related tests with per-group setup- Data classes and sealed classes test idiomatically with
assertEqualsandwhen