Kotlin · Course navigation

Kotlin unit tests with lambdas and runCatching

Write a minimal test function that reports passes and failures, including expected exceptions.

On this page
Before you start

Kotlin functions, lambdas, and exceptions.

  • Write a minimal test function that reports passes and failures, including expected exceptions.
  • Predict the sample output and explain the state changes.
  • Complete the practice task and check the boundary cases.

The concept

A unit test calls a function with chosen input and compares the result with the expected one. Kotlin projects usually use JUnit or kotlin.test, but the idea fits in a few lines. A test helper takes a name and a lambda, runs the lambda, and prints PASS or FAIL. Kotlin's check and require functions throw IllegalStateException and IllegalArgumentException when a condition is false, which makes them natural assertions.

runCatching captures an exception as a Result instead of letting it escape. A test for an error case runs the code and asserts that the Result holds the expected exception type. Passing the test body as a lambda lets the runner decide how to run it. Test the normal cases, the edges, and the error cases.

Code example

Kotlin
fun percent(text: String): Int {
    val value = text.toIntOrNull() ?: throw NumberFormatException("not a number: $text")
    require(value in 0..100) { "out of range: $value" }
    return value
}

var failures = 0

fun test(name: String, body: () -> Unit) {
    val outcome = runCatching(body)
    if (outcome.isSuccess) {
        println("PASS $name")
    } else {
        failures++
        println("FAIL $name: ${outcome.exceptionOrNull()?.message}")
    }
}

inline fun <reified E : Throwable> assertThrows(code: () -> Unit) {
    val error = runCatching(code).exceptionOrNull()
    check(error is E) { "expected ${E::class.simpleName} but got ${error?.let { it::class.simpleName }}" }
}

fun main() {
    test("parses 42") { check(percent("42") == 42) }
    test("accepts the boundary 100") { check(percent("100") == 100) }
    test("rejects 101") { assertThrows<IllegalArgumentException> { percent("101") } }
    test("rejects text") { assertThrows<NumberFormatException> { percent("abc") } }
    test("a wrong expectation") { check(percent("7") == 8) }
    println("failures: $failures")
}
Output
PASS parses 42
PASS accepts the boundary 100
PASS rejects 101
PASS rejects text
FAIL a wrong expectation: Check failed.
failures: 1

Follow the code

Open the walkthrough to inspect the example one step at a time.

Show guided walkthrough
Guided walkthroughReady
1fun percent(text: String): Int {
2 val value = text.toIntOrNull() ?: throw NumberFormatException("not a number: $text")
3 require(value in 0..100) { "out of range: $value" }
4 return value
5}
6
7var failures = 0
8
9fun test(name: String, body: () -> Unit) {
10 val outcome = runCatching(body)
11 if (outcome.isSuccess) {
12 println("PASS $name")
13 } else {
14 failures++
15 println("FAIL $name: ${outcome.exceptionOrNull()?.message}")
16 }
17}
18
19inline fun <reified E : Throwable> assertThrows(code: () -> Unit) {
20 val error = runCatching(code).exceptionOrNull()
21 check(error is E) { "expected ${E::class.simpleName} but got ${error?.let { it::class.simpleName }}" }
22}
23
24fun main() {
25 test("parses 42") { check(percent("42") == 42) }
26 test("accepts the boundary 100") { check(percent("100") == 100) }
27 test("rejects 101") { assertThrows<IllegalArgumentException> { percent("101") } }
28 test("rejects text") { assertThrows<NumberFormatException> { percent("abc") } }
29 test("a wrong expectation") { check(percent("7") == 8) }
30 println("failures: $failures")
31}

Follow the example

Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.

View the complete dry-run table
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput so far
require throws IllegalArgumentException with the message if the condition is false.—
—
Run the test body and capture success or the exception.—
—
A generic helper: the code must fail with exactly the expected type. reified lets it check E at run time.—
—
A normal case passes.—
PASS parses 42
101 is out of range, so require throws the expected type.—
PASS parses 42
PASS accepts the boundary 100
PASS rejects 101
This test is wrong on purpose, to show how a failure is reported.—
PASS parses 42
PASS accepts the boundary 100
PASS rejects 101
One check failed, with the default message of check.—
PASS parses 42
PASS accepts the boundary 100
PASS rejects 101
PASS rejects text
FAIL a wrong expectation: Check failed.
failures: 1

Behind the scenes

How Kotlin processes this code

Kotlin commonly compiles to JVM bytecode, which the JVM executes.

Cost and common mistakes

Each test is O(1) plus the cost of the code under test.

  • The failing test is only there to show the report. A real suite should be green.
  • runCatching also catches AssertionError and other Throwables you may not want to hide. Be deliberate about what you catch.
  • For real projects use kotlin.test or JUnit, which add test discovery, reports, and better failure messages.

Try it yourself

How would you add a boundary test for 0?

Reveal answer and reasoning

test("accepts the boundary 0") { check(percent("0") == 0) } because 0 is the lowest allowed value.

Further reading