Kotlin unit tests with lambdas and runCatching
Write a minimal test function that reports passes and failures, including expected exceptions.
On this page
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
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")
}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
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")}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
| Step / line | What happens | State after step | Output 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.