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.
[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" }buildscript { dependencies { classpath(libs.kotlin.gradle.plugin) }}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.
import kotlin.test.ExperimentalKotlinTestApiimport kotlin.test.Testimport kotlin.test.assertContainsimport kotlin.test.assertEqualsimport 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.
import kotlin.test.ExperimentalKotlinTestApiimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertIsimport 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).
import kotlin.test.Testimport kotlin.test.assertFailsWithimport kotlin.test.assertFalseimport 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").