Unit Testing with kotlin.test
Automated tests turn “I think this works” into “I can prove this works, every time I change the code.” kotlin.test is Kotlin’s standard testing API — a small, platform-independent library of annotations (@Test, @BeforeTest) and assertion functions (assertEquals, assertTrue, assertFailsWith) that sits on top of a real test runner. On the JVM that runner is usually JUnit, but the same kotlin.test code can run unmodified on Kotlin/JS and Kotlin/Native projects too. This lesson covers the full API, how assertions actually work under the hood, and the mistakes that make Kotlin tests unreliable or hard to read.
Overview: How kotlin.test Works
kotlin.test is not itself a test runner — it is a thin, multiplatform facade over one. On the JVM, you add the kotlin-test library together with a runner adapter such as kotlin-test-junit5 (for JUnit 5) or kotlin-test-junit (for JUnit 4). The annotations and assertion functions you write are the same regardless of which adapter you pick; only the underlying engine that discovers and executes the tests changes. This is why the exact same test source file can compile and run on Kotlin/JVM, Kotlin/JS, and Kotlin/Native in a multiplatform project — the compiler swaps in a platform-specific implementation of the same kotlin.test declarations.
A test class is a plain Kotlin class. Any function annotated with @Test becomes a test case: the runner instantiates the class (typically once per test method, so each test starts from a clean object) and invokes that function with no arguments. Inside the function you call assertion functions like assertEquals(expected, actual). Each assertion function either returns normally (the check passed) or throws an AssertionError with a descriptive message (the check failed); the runner catches that error and reports the test as failed, while an uncaught exception of a different type is usually reported as an error rather than a failure.
assertEquals compares its arguments with Kotlin’s structural equality operator, ==, which calls equals() on the expected value. This is exactly why data classes are so convenient in tests: data class Point(val x: Int, val y: Int) gets a generated equals() (and hashCode(), toString(), copy()) for free, so assertEquals(Point(3, 4), someResult) just works without you ever writing an equals() override. For a plain class that only inherits the default Any.equals() (reference identity), assertEquals would only pass if both arguments were literally the same object.
assertFailsWith<T> { ... } is the idiomatic way to test that code throws. It runs the lambda inside a try/catch: if an exception of type T (or a subtype) is thrown, the assertion passes and returns that exception so you can inspect its message or properties; if nothing is thrown, or an exception of the wrong type is thrown, the assertion function itself throws an AssertionError describing the mismatch. Because Kotlin’s type system is null-safe by default, kotlin.test also ships null-focused assertions — assertNull and assertNotNull — instead of forcing you to compare against a literal null with assertEquals. assertNotNull is especially useful because it smart-casts: after it passes, the compiler treats the checked value as non-null for the rest of the block.
Syntax
A typical kotlin.test file looks like this:
import kotlin.test.Test
import kotlin.test.assertEquals
class ExampleTest {
@Test
fun testName() {
val actual = functionUnderTest()
assertEquals(expectedValue, actual)
}
}
| Element | Meaning |
|---|---|
class ExampleTest |
A plain class; by convention named after the class or file it tests, suffixed with Test. |
@Test |
Marks a public, no-argument, Unit-returning function as a test case the runner should execute. |
@BeforeTest / @AfterTest |
Run before / after every @Test function in the class — used for setup and cleanup. |
@Ignore |
Skips a test without deleting it, useful for temporarily disabling a known-broken test. |
assertEquals(expected, actual, message?) |
Fails unless expected == actual. Argument order matters for the failure message. |
assertTrue(condition) / assertFalse(condition) |
Fails unless the boolean condition holds. |
assertNull(value) / assertNotNull(value) |
Checks nullability directly instead of comparing to a literal null. |
assertFailsWith<ExceptionType> { block } |
Fails unless block throws ExceptionType (or a subtype); returns the thrown exception. |
assertSame(a, b) / assertNotSame(a, b) |
Compares with === (referential identity) instead of ==. |
fail(message) |
Immediately fails the test with the given message. |
Examples
Example 1: Basic assertions
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CalculatorTest {
private fun add(a: Int, b: Int): Int = a + b
@Test
fun `add returns the sum of two numbers`() {
val result = add(2, 3)
assertEquals(5, result)
assertTrue(result > 0)
}
}
Output:
CalculatorTest > add returns the sum of two numbers PASSED
1 test completed, 0 failed
The backtick-quoted function name lets test names read like sentences in reports instead of forcing camelCase. assertEquals(5, result) passes because 5 == 5; assertTrue(result > 0) passes because the boolean expression evaluates to true. If either check failed, the runner would report that specific test as failed and print an AssertionError message showing what was expected versus what was actually returned, while the rest of the test suite would keep running.
Example 2: Testing that code throws with assertFailsWith
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertEquals
class BankAccountTest {
class InsufficientFundsException(message: String) : Exception(message)
class BankAccount(private var balance: Int) {
fun withdraw(amount: Int) {
if (amount > balance) {
throw InsufficientFundsException("Cannot withdraw $amount, balance is $balance")
}
balance -= amount
}
}
@Test
fun `withdrawing more than the balance throws`() {
val account = BankAccount(balance = 100)
val exception = assertFailsWith<InsufficientFundsException> {
account.withdraw(150)
}
assertEquals("Cannot withdraw 150, balance is 100", exception.message)
}
}
Output:
BankAccountTest > withdrawing more than the balance throws PASSED
1 test completed, 0 failed
assertFailsWith<InsufficientFundsException> runs the lambda and expects exactly that exception type (or a subtype) to be thrown. Because it is, the assertion passes and hands back the caught exception as its return value, which the test then inspects with a second assertion on exception.message. If withdraw(150) had not thrown at all, or had thrown a different exception type, assertFailsWith itself would fail with a clear message explaining which case occurred.
Example 3: Data classes, structural equality, and setup
import kotlin.test.Test
import kotlin.test.BeforeTest
import kotlin.test.assertEquals
data class Point(val x: Int, val y: Int)
class PointTest {
private lateinit var origin: Point
@BeforeTest
fun setUp() {
origin = Point(0, 0)
}
@Test
fun `translating the origin produces the expected point`() {
val moved = origin.copy(x = 3, y = 4)
assertEquals(Point(3, 4), moved)
}
}
Output:
PointTest > translating the origin produces the expected point PASSED
1 test completed, 0 failed
@BeforeTest runs setUp() before every test in the class, so origin always starts as a fresh Point(0, 0) instead of leaking state between tests. origin.copy(x = 3, y = 4) is generated for free because Point is a data class — it returns a new Point with x and y overridden and every other property left unchanged. The final assertEquals(Point(3, 4), moved) compares two different Point objects and passes, because the generated equals() compares their x and y properties, not their object identity.
How It Works Step by Step
The exact kotlin.test/JUnit machinery runs outside your program, inside the test runner and build tool. It helps to see the same mechanics written out by hand. The program below reimplements what assertEquals and assertFailsWith do internally, using only ordinary control flow:
fun add(a: Int, b: Int): Int = a + b
fun main() {
val expected = 5
val actual = add(2, 3)
if (expected == actual) {
println("PASSED: add(2, 3) returned $actual")
} else {
println("FAILED: expected $expected but got $actual")
}
val exceptionThrown = try {
listOf(1, 2, 3)[10]
false
} catch (e: IndexOutOfBoundsException) {
println("PASSED: accessing an out-of-range index threw ${e::class.simpleName}")
true
}
println(if (exceptionThrown) "All checks passed" else "Some checks failed")
}
Output:
PASSED: add(2, 3) returned 5
PASSED: accessing an out-of-range index threw IndexOutOfBoundsException
All checks passed
Step by step: first, expected == actual is evaluated with structural equality, exactly like assertEquals does — this is the whole check, just without the AssertionError machinery. Second, the try block deliberately triggers an out-of-range list access; the catch block only runs if an IndexOutOfBoundsException is thrown, which mirrors what assertFailsWith<IndexOutOfBoundsException> { listOf(1, 2, 3)[10] } does for you automatically: run the block, catch the expected exception type, and treat “nothing was thrown” or “the wrong type was thrown” as a failure. In real kotlin.test code you never write this try/catch yourself; the assertion function does it and turns a failed check into a properly reported test failure instead of crashing your whole test run.
Common Mistakes
Mistake 1: Reversing the expected and actual arguments
Wrong:
import kotlin.test.Test
import kotlin.test.assertEquals
class MathTest {
private fun square(n: Int): Int = n * n
@Test
fun `square of four is sixteen`() {
val actual = square(4)
assertEquals(actual, 16)
}
}
This test still passes, because equality is symmetric — but kotlin.test‘s signature is assertEquals(expected, actual, message?), and swapping the arguments swaps the wording of the failure message too. If square had a bug and returned, say, 12 instead of 16, the report with arguments in the right order would read “expected <16> but was <12>”; reversed, it reads “expected <12> but was <16>” — backwards from what actually happened, which wastes time while debugging.
Corrected:
import kotlin.test.Test
import kotlin.test.assertEquals
class MathTest {
private fun square(n: Int): Int = n * n
@Test
fun `square of four is sixteen`() {
val actual = square(4)
assertEquals(16, actual)
}
}
Keeping expected first is a small convention, but following it consistently makes every failure message trustworthy at a glance.
Mistake 2: Using !! instead of a proper null assertion
Wrong:
import kotlin.test.Test
import kotlin.test.assertEquals
class UserRepositoryTest {
private fun findUserNameById(id: Int): String? =
if (id == 1) "Ada" else null
@Test
fun `returns null for a missing user`() {
val name = findUserNameById(99)
assertEquals("Ada", name!!)
}
}
findUserNameById(99) legitimately returns null here, so name!! throws a NullPointerException before assertEquals ever runs. The test report shows a raw NPE stack trace instead of a clear kotlin.test assertion failure, and the test doesn’t document what behavior was actually expected. Reaching for !! to “just make it compile” defeats the entire point of writing an assertion.
Corrected:
import kotlin.test.Test
import kotlin.test.assertNull
class UserRepositoryTest {
private fun findUserNameById(id: Int): String? =
if (id == 1) "Ada" else null
@Test
fun `returns null for a missing user`() {
val name = findUserNameById(99)
assertNull(name)
}
}
assertNull says exactly what the test means, and if it ever fails, the message clearly states what non-null value was found instead of crashing the whole test run with an unrelated NPE.
Best Practices
- Name test functions as full sentences describing behavior (using backticks, e.g.
`returns null for a missing user`) rather than terse camelCase names. - Keep
expectedbeforeactualin everyassertEqualscall so failure messages are always readable. - Use
@BeforeTestfor shared setup instead of duplicating initialization in every test, and keep tests independent so they can run in any order. - Prefer
assertFailsWith<T> { }over a manualtry/catchwith a call tofail()— it is shorter and gives a clearer failure message. - Reach for
assertNull/assertNotNullinstead of!!or comparing against a literalnullwithassertEquals. - Remember that
assertEqualsonDouble/Floatuses exact equality — for computed floating-point results, compare within a tolerance yourself (for example,assertTrue(kotlin.math.abs(expected - actual) < 0.0001)) instead of expecting an exact match. - Lean on data classes for test fixtures and expected values — their generated
equals()makesassertEqualscomparisons trivial and readable. - Keep unit tests fast and deterministic: no real network calls, file I/O, or system clock reads — hide those behind an interface you can fake in tests.
- Run the test suite on every change through your build tool (for example
./gradlew test) or CI, not just manually before a release.
Practice Exercises
- Write a function
fun isPalindrome(s: String): Booleanand akotlin.testclass with at least three@Testfunctions covering: a palindrome like"level", a non-palindrome like"kotlin", and the edge case of an empty string. - Write a
data class Temperature(val celsius: Double)with a functiontoFahrenheit(): Double. Write a test that checks the conversion — remember that comparingDoubleresults with plainassertEqualsrequires the math to land on an exact value, so decide whether to round the result first or compare with a tolerance. - Write a small stack-like class with
pushandpopfunctions on a list ofInt, wherepop()on an empty stack throwsNoSuchElementException. Write one test that pushes and pops a value successfully, and one that usesassertFailsWith<NoSuchElementException>to verify the empty-stack case.
Summary
kotlin.testis Kotlin’s standard, multiplatform testing API; on the JVM it typically runs on top of JUnit via a runner adapter.@Testmarks a function as a test case;@BeforeTest/@AfterTestrun setup and cleanup around every test in a class;@Ignoreskips a test.assertEquals(expected, actual)compares with structural equality (==), which is why data classes’ generatedequals()make test comparisons so convenient.assertFailsWith<T> { }verifies a block throws an exception of typeTand returns it so you can assert further on its properties.- Prefer
assertNull/assertNotNullover!!in tests for clear, informative failure messages instead of raw crashes. assertEqualson floating-point values is an exact comparison — use a tolerance check for computed results.- Good tests are independent, fast, and deterministic, with shared setup handled by
@BeforeTestrather than mutable state carried between tests.
