---
title: "Kotlin 2.4.20: Lazy assertion messages in kotlin.test (KEEP-0465)"
description: "Kotlin 2.4.20 introduces lambda overloads for kotlin.test error messages under KEEP-0465. Here is how they behave on AGP 9, how smart-cast contracts hold up, and why assertNotNull was excluded."
pubDatetime: 2026-11-08
author: "Arda Kazancı"
canonicalUrl: "https://ardakazanci.com/posts/kotlin-2-4-20-lazy-assertion-messages-kotlin-test/"
tags: ["kotlin", "android", "debugging"]
---
Up to Kotlin version 2.4.20, every validation operation in the `kotlin.test` package treated the error message as an `eager String?`. In practice, this meant that Kotlin would still parse and allocate the message string on every call, even if all tests in your test suite passed. If you passed a static string like "Order ID is empty," this likely was not an issue. However, if you passed a list of 500 items concatenated with `joinToString()`, an array via `contentToString()`, or a diagram from the [Power-Assert compiler plugin](https://kotlinlang.org/docs/power-assert.html), Kotlin evaluated those strings every time the assertion executed.

With Kotlin 2.4.20 (`org.jetbrains.kotlin:kotlin-test:2.4.20`), the standard library finally caught up with JUnit 5. In line with the proposal in [KEEP-0465](https://github.com/Kotlin/KEEP/blob/main/proposals/stdlib/KEEP-0465-kotlin.test-lazy-assertion-messages.md), released alongside [`StackTraceRecoverable`](/posts/kotlin-coroutines-debugging-stacktrace-recoverable/), validation functions now accept a lambda that is called only if the test fails.

## Configuring on AGP 9

I tried using these in the Android Gradle Plugin (AGP) 9, but ran into two issues. First, AGP 9 includes its own Kotlin toolchain; therefore, unless you explicitly add `kotlin-gradle-plugin` to your root build script, updating from a previous version in your version catalog will not update the compiler. Second, replacing the `kotlin-test` core dependency with `kotlin-test-junit` resolved the "unresolved reference" error that occurred for `Test`.

```toml title="gradle/libs.versions.toml"
[versions]
agp = "9.6.0-alpha03"
kotlin = "2.4.21"
junit = "4.13.2"

[libraries]
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
```

```kotlin title="build.gradle.kts"
buildscript {
    dependencies {
        classpath(libs.kotlin.gradle.plugin)
    }
}
```

```kotlin title="app/build.gradle.kts"
dependencies {
    testImplementation(libs.junit)
    testImplementation(libs.kotlin.test)
}

kotlin {
    compilerOptions {
        // Avoids placing @OptIn(ExperimentalKotlinTestApi::class) on every test class
        optIn.add("kotlin.test.ExperimentalKotlinTestApi")
    }
}
```

## How the new overloads work

The logic in `Assertions.kt` ([PR #6242](https://github.com/JetBrains/kotlin/pull/6242)) checks whether values are different and calls the message provider only if there is a mismatch.

```kotlin title="LazyAssertionMessagesTest.kt"
import kotlin.test.ExperimentalKotlinTestApi
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

@OptIn(ExperimentalKotlinTestApi::class)
class LazyAssertionMessagesTest {

    @Test
    fun skipsFormattingWhenAssertionPasses() {
        var formatCount = 0
        val records = List(500) { "REC-$it" }

        // Passing test: lambda must never run
        assertEquals(expected = 500, actual = records.size) {
            formatCount++
            "Unexpected count for: ${records.joinToString()}"
        }
        assertEquals(0, formatCount)

        // Failing test: lambda runs exactly once
        val error = assertFailsWith<AssertionError> {
            assertEquals(expected = 10, actual = records.size) {
                formatCount++
                "Count mismatch on ${records.take(2)}"
            }
        }
        assertEquals(1, formatCount)
        assertContains(error.message.orEmpty(), "Count mismatch on [REC-0, REC-1]")
    }
}
```

These new `@OptIn(ExperimentalKotlinTestApi::class)` overloads support `assertTrue`, `assertFalse`, `assertEquals`, `assertSame`, `assertIs`, `assertNull`, `assertContains` across collections, sequences, arrays, and ranges, as well as `assertContentEquals`.

I wondered if my smart contract assertions would still work correctly when used with these latest lambda expressions. Yes, they do; because contracts for `assertTrue`, `assertFalse`, `assertNull`, and `assertIs` (`returns() implies ...`) are still supported. You can continue to access variables immediately after the assertion call.

```kotlin title="ContractSmartCastTest.kt"
import kotlin.test.ExperimentalKotlinTestApi
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue

sealed interface PaymentState {
    data class Completed(val transactionId: String) : PaymentState
    data class Failed(val code: String) : PaymentState
}

@OptIn(ExperimentalKotlinTestApi::class)
class ContractSmartCastTest {

    @Test
    fun smartCastsWorkWithLazyLambdas() {
        val apiKey: String? = "live_secret_key_99"
        assertTrue(apiKey != null) { "API key was null" }
        assertEquals(18, apiKey.length)

        val state: PaymentState = PaymentState.Completed("TXN-7741")
        assertIs<PaymentState.Completed>(state) { "Expected completed state: $state" }
        assertEquals("TXN-7741", state.transactionId)
    }
}
```

## The assertNotNull pitfall

If you take a closer look at the updated functions, you will notice that `assertNotNull` is missing from the list.

If you write `assertNotNull` with a lambda expression appended to the end, it compiles without any warnings, but the error message will never appear if an error occurs. An older overload treats this lambda expression as a callback to be executed after verifying that the value is not null ([KT-86760](https://youtrack.jetbrains.com/issue/KT-86760)).

```kotlin title="AssertNotNullPitfallTest.kt"
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull

class AssertNotNullPitfallTest {

    @Test
    fun trailingLambdaOnAssertNotNullNeverRunsOnFailure() {
        var callbackInvoked = false
        val missingUser: String? = null

        val error = assertFailsWith<AssertionError> {
            assertNotNull(missingUser) {
                callbackInvoked = true
                "Missing user payload"
            }
        }

        assertFalse(callbackInvoked)
        assertFalse(error.message.orEmpty().contains("Missing user payload"))
    }
}
```

In `kotlin-test-2.4.21-sources.jar`, the lazy overload is already present inside `Assertions.kt` as a commented-out declaration. Until the older callback overload is deprecated in Kotlin 2.5.0, stick to a regular string for not-null checks: `assertNotNull(value, "Value is null")`.
