Kotlin unit tests with lambdas and runCatching
Write a minimal test function that reports passes and failures, including expected exceptions.
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
Explanation
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.
Explore the dry run
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 |
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.