Skip to content
AKarda-kazancimainbrew()
Personal blog
AK
arda-kazanci › journal ›kotlin-2-4-20-lazy-assertion-messages-kotlin-test.md
← Journal.md
kotlin-2-4-20-lazy-assertion-messages-kotlin-test.md154 lines · Markdown · UTF-8
Markdown Preview · kotlin-2-4-20-lazy-assertion-messages-kotlin-test.md Rendered
journal/kotlin-2-4-20-lazy-assertion-messages-kotlin-test.md

Kotlin 2.4.20: Lazy assertion messages in kotlin.test (KEEP-0465)

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.

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, 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, released alongside StackTraceRecoverable, 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.

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" }
build.gradle.kts
buildscript {
dependencies {
classpath(libs.kotlin.gradle.plugin)
}
}
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) checks whether values are different and calls the message provider only if there is a mismatch.

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.

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).

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").

SHARE ENTRYLinkedIn ↗X ↗Markdown / AI ↗
Discussions ardakazanci/blog-comments
● GiscusView discussions on GitHub ↗
⑂Reactions & comments/ Sign in with GitHub to react or join the discussion.
Notifications Newsletter · beehiiv
✓ Active channelSubscribe directly ↗
✉Get new posts by email/ Android, Kotlin, and Compose notes. Straight to your inbox.

SUBSCRIPTION CONFIG · EMAIL DISPATCH

Stay synced with new entries

Receive practical engineering notes on Android architecture, Kotlin, and Jetpack Compose whenever a new article is published.

channel = "email"spam = falseunsubscribe = "anytime"
K subscriber.propertiesEmail address