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=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 <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 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