Merge changes from topic "assertWithMessage" into androidx-main am: 8ab545643c

Original change: https://android-review.googlesource.com/c/platform/frameworks/support/+/2557536

Change-Id: I0846335c6a68d5a72a8f2e9b024c074725529503
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/room/integration-tests/kotlintestapp/build.gradle b/room/integration-tests/kotlintestapp/build.gradle
index 8241224..1e0b08c 100644
--- a/room/integration-tests/kotlintestapp/build.gradle
+++ b/room/integration-tests/kotlintestapp/build.gradle
@@ -103,7 +103,6 @@
         exclude group: "com.android.support", module: "support-annotations"
         exclude module: "hamcrest-core"
     })
-    androidTestImplementation(libs.truth) // Kruth still lacks assertWithMessage
     androidTestImplementation(project(":internal-testutils-kmp"))
     androidTestImplementation(libs.kotlinTest)
     androidTestImplementation(project(":room:room-guava"))
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/ProvidedTypeConverterTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/ProvidedTypeConverterTest.kt
index 6698c89..df85ab8 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/ProvidedTypeConverterTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/ProvidedTypeConverterTest.kt
@@ -16,6 +16,8 @@
 package androidx.room.integration.kotlintestapp.test
 
 import android.content.Context
+import androidx.kruth.assertThat
+import androidx.kruth.assertWithMessage
 import androidx.room.Database
 import androidx.room.Entity
 import androidx.room.Insert
@@ -38,12 +40,10 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
-import com.google.common.truth.Truth.assertThat
 import java.nio.ByteBuffer
 import java.util.Date
 import java.util.Objects
 import java.util.UUID
-import org.junit.Assert
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -75,9 +75,9 @@
             val pet: Pet = TestUtil.createPet(3)
             pet.mName = "pet"
             db.petDao().insertOrReplace(pet)
-            Assert.fail("Show have thrown an IllegalArgumentException")
+            assertWithMessage("Show have thrown an IllegalArgumentException").fail()
         } catch (throwable: Throwable) {
-            assertThat(throwable).isInstanceOf(IllegalArgumentException::class.java)
+            assertThat(throwable).isInstanceOf<IllegalArgumentException>()
         }
     }
 
@@ -91,9 +91,9 @@
             val pet: Pet = TestUtil.createPet(3)
             pet.mName = "pet"
             db.petDao().insertOrReplace(pet)
-            Assert.fail("Show have thrown an IllegalArgumentException")
+            assertWithMessage("Show have thrown an IllegalArgumentException").fail()
         } catch (throwable: Throwable) {
-            assertThat(throwable).isInstanceOf(IllegalArgumentException::class.java)
+            assertThat(throwable).isInstanceOf<IllegalArgumentException>()
         }
     }
 
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx2PagingSourceTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx2PagingSourceTest.kt
index 77f2bb0..ee0a09f 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx2PagingSourceTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx2PagingSourceTest.kt
@@ -17,6 +17,7 @@
 package androidx.room.integration.kotlintestapp.test
 
 import androidx.kruth.assertThat
+import androidx.kruth.assertWithMessage
 import androidx.paging.Pager
 import androidx.paging.PagingState
 import androidx.paging.rxjava2.RxPagingSource
@@ -28,7 +29,6 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.google.common.truth.Truth.assertWithMessage
 import io.reactivex.Single
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx3PagingSourceTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx3PagingSourceTest.kt
index cef0319..ecd722a 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx3PagingSourceTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/Rx3PagingSourceTest.kt
@@ -17,6 +17,7 @@
 package androidx.room.integration.kotlintestapp.test
 
 import androidx.kruth.assertThat
+import androidx.kruth.assertWithMessage
 import androidx.paging.Pager
 import androidx.paging.PagingState
 import androidx.paging.rxjava3.RxPagingSource
@@ -28,7 +29,6 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.google.common.truth.Truth.assertWithMessage
 import io.reactivex.rxjava3.core.Single
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
diff --git a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/SuspendingQueryTest.kt b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/SuspendingQueryTest.kt
index eb8cd5a..a80338c 100644
--- a/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/SuspendingQueryTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTest/java/androidx/room/integration/kotlintestapp/test/SuspendingQueryTest.kt
@@ -23,6 +23,7 @@
 import androidx.arch.core.executor.ArchTaskExecutor
 import androidx.arch.core.executor.TaskExecutor
 import androidx.kruth.assertThat
+import androidx.kruth.assertWithMessage
 import androidx.room.Room
 import androidx.room.RoomDatabase
 import androidx.room.integration.kotlintestapp.NewThreadDispatcher
@@ -37,7 +38,6 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import androidx.test.platform.app.InstrumentationRegistry
-import com.google.common.truth.Truth.assertWithMessage
 import java.io.IOException
 import java.util.concurrent.CountDownLatch
 import java.util.concurrent.ExecutorService
diff --git a/room/integration-tests/kotlintestapp/src/androidTestWithKspGenJava/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt b/room/integration-tests/kotlintestapp/src/androidTestWithKspGenJava/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt
index 2c551596..04ff446 100644
--- a/room/integration-tests/kotlintestapp/src/androidTestWithKspGenJava/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt
+++ b/room/integration-tests/kotlintestapp/src/androidTestWithKspGenJava/java/androidx/room/integration/kotlintestapp/NullabilityAwareTypeConversionTest.kt
@@ -18,6 +18,7 @@
 
 import android.database.Cursor
 import androidx.kruth.assertThat
+import androidx.kruth.assertWithMessage
 import androidx.room.Dao
 import androidx.room.Database
 import androidx.room.Entity
@@ -33,7 +34,6 @@
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.google.common.truth.Truth.assertWithMessage
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/BooleanSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/BooleanSubject.kt
index b083763..7352c54 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/BooleanSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/BooleanSubject.kt
@@ -16,18 +16,19 @@
 
 package androidx.kruth
 
-import kotlin.test.assertTrue
-
 /**
  * Propositions for boolean subjects.
  */
-class BooleanSubject(actual: Boolean?) : Subject<Boolean>(actual) {
+class BooleanSubject internal constructor(
+    actual: Boolean?,
+    metadata: FailureMetadata = FailureMetadata(),
+) : Subject<Boolean>(actual = actual, metadata = metadata) {
 
     /**
      * Fails if the subject is false or `null`.
      */
     fun isFalse() {
-        assertTrue(
+        asserter.assertTrue(
             actual == false,
             "expected to be false, but was $actual"
         )
@@ -37,7 +38,7 @@
      * Fails if the subject is true or `null`.
      */
     fun isTrue() {
-        assertTrue(
+        asserter.assertTrue(
             actual == true,
             "expected to be true, but was $actual"
         )
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
index 9d1ac4b..da5d8f3 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ComparableSubject.kt
@@ -16,14 +16,15 @@
 
 package androidx.kruth
 
-import kotlin.test.fail
-
 /**
  * Propositions for [Comparable] typed subjects.
  *
  * @param T the type of the object being tested by this [ComparableSubject]
  */
-open class ComparableSubject<T : Comparable<T>> constructor(actual: T?) : Subject<T>(actual) {
+open class ComparableSubject<T : Comparable<T>> internal constructor(
+    actual: T?,
+    metadata: FailureMetadata = FailureMetadata(),
+) : Subject<T>(actual = actual, metadata = metadata) {
 
     /**
      * Checks that the subject is less than [other].
@@ -31,10 +32,11 @@
      * @throws NullPointerException if [actual] or [other] is `null`.
      */
     fun isLessThan(other: T?) {
-        if (actual == null || other == null) {
-            throw NullPointerException("Expected to be less than $other, but was $actual")
-        } else if (actual >= other) {
-            fail("Expected to be less than $other, but was $actual")
+        requireNonNull(actual) { "Expected to be less than $other, but was $actual" }
+        requireNonNull(other) { "Expected to be less than $other, but was $actual" }
+
+        if (actual >= other) {
+            asserter.fail("Expected to be less than $other, but was $actual")
         }
     }
 
@@ -47,7 +49,7 @@
         requireNonNull(actual) { "Expected to be at least $other, but was $actual" }
         requireNonNull(other) { "Expected to be at least $other, but was $actual" }
         if (actual < other) {
-            fail("Expected to be at least $other, but was $actual")
+            asserter.fail("Expected to be at least $other, but was $actual")
         }
     }
 }
\ No newline at end of file
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailingOrdered.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailingOrdered.kt
index 559e0e6..1c5fafb 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailingOrdered.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailingOrdered.kt
@@ -16,16 +16,15 @@
 
 package androidx.kruth
 
-import kotlin.test.fail
-
 /**
  * Always fails with the provided error message.
  */
 internal class FailingOrdered(
+    private val asserter: KruthAsserter,
     private val message: () -> String,
 ) : Ordered {
 
     override fun inOrder() {
-        fail(message())
+        asserter.fail(message())
     }
 }
\ No newline at end of file
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailureMetadata.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailureMetadata.kt
new file mode 100644
index 0000000..e966b77
--- /dev/null
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/FailureMetadata.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.kruth
+
+internal data class FailureMetadata(
+    val messagesToPrepend: List<String> = emptyList(),
+) {
+
+    fun withMessage(messageToPrepend: String): FailureMetadata =
+        copy(messagesToPrepend = messagesToPrepend + messageToPrepend)
+
+    fun formatMessage(message: String? = null): String {
+        val messages = if (message == null) messagesToPrepend else messagesToPrepend + message
+
+        return when {
+            messages.isEmpty() -> message ?: ""
+            messages.size == 1 -> messages.single()
+            else -> messages.joinToString(separator = ". ", postfix = ".")
+        }
+    }
+}
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/IterableSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/IterableSubject.kt
index 3a3c4f6..53662ec 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/IterableSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/IterableSubject.kt
@@ -16,9 +16,6 @@
 
 package androidx.kruth
 
-import kotlin.test.assertEquals
-import kotlin.test.fail
-
 /**
  * Propositions for [Iterable] subjects.
  *
@@ -31,7 +28,10 @@
  * - Assertions may also require that the elements in the given [Iterable] implement
  * [Any.hashCode] correctly.
  */
-open class IterableSubject<T>(actual: Iterable<T>?) : Subject<Iterable<T>>(actual) {
+open class IterableSubject<T> internal constructor(
+    actual: Iterable<T>?,
+    metadata: FailureMetadata = FailureMetadata(),
+) : Subject<Iterable<T>>(actual = actual, metadata = metadata) {
 
     override fun isEqualTo(expected: Any?) {
         // method contract requires testing iterables for equality
@@ -57,7 +57,7 @@
         requireNonNull(actual) { "Expected to be empty, but was null" }
 
         if (!actual.isEmpty()) {
-            fail("Expected to be empty")
+            asserter.fail("Expected to be empty")
         }
     }
 
@@ -66,7 +66,7 @@
         requireNonNull(actual) { "Expected not to be empty, but was null" }
 
         if (actual.isEmpty()) {
-            fail("Expected to be not empty")
+            asserter.fail("Expected to be not empty")
         }
     }
 
@@ -75,7 +75,7 @@
         require(expectedSize >= 0) { "expectedSize must be >= 0, but was $expectedSize" }
         requireNonNull(actual) { "Expected to have size $expectedSize, but was null" }
 
-        assertEquals(expectedSize, actual.count())
+        asserter.assertEquals(expectedSize, actual.count())
     }
 
     /** Checks (with a side-effect failure) that the subject contains the supplied item. */
@@ -85,12 +85,12 @@
         if (element !in actual) {
             val matchingItems = actual.retainMatchingToString(listOf(element))
             if (matchingItems.isNotEmpty()) {
-                fail(
+                asserter.fail(
                     "Expected to contain $element, but did not. " +
                         "Though it did contain $matchingItems"
                 )
             } else {
-                fail("Expected to contain $element, but did not")
+                asserter.fail("Expected to contain $element, but did not")
             }
         }
     }
@@ -100,7 +100,7 @@
         requireNonNull(actual) { "Expected not to contain $element, but was null" }
 
         if (element in actual) {
-            fail("Expected not to contain $element")
+            asserter.fail("Expected not to contain $element")
         }
     }
 
@@ -111,7 +111,7 @@
         val duplicates = actual.groupBy { it }.values.filter { it.size > 1 }
 
         if (duplicates.isNotEmpty()) {
-            fail("Expected not to contain duplicates, but contained $duplicates")
+            asserter.fail("Expected not to contain duplicates, but contained $duplicates")
         }
     }
 
@@ -134,12 +134,12 @@
 
         val matchingItems = actual.retainMatchingToString(expected)
         if (matchingItems.isNotEmpty()) {
-            fail(
+            asserter.fail(
                 "Expected to contain any of $expected, but did not. " +
                     "Though it did contain $matchingItems"
             )
         } else {
-            fail("Expected to contain any of $expected, but did not")
+            asserter.fail("Expected to contain any of $expected, but did not")
         }
     }
 
@@ -192,7 +192,7 @@
         if (missing.isNotEmpty()) {
             val nearMissing = actualList.retainMatchingToString(missing)
 
-            fail(
+            asserter.fail(
                 """
                     Expected to contain at least $expected, but did not.
                     Missing $missing, though it did contain $nearMissing.
@@ -204,7 +204,7 @@
             return NoopOrdered
         }
 
-        return FailingOrdered {
+        return FailingOrdered(asserter) {
             buildString {
                 append("Required elements were all found, but order was wrong.")
                 append("Expected order: $expected.")
@@ -295,7 +295,9 @@
                      * values had multiple elements. Granted, Fuzzy Truth already does this, so maybe it's OK?
                      * But Fuzzy Truth doesn't (yet) make the mismatched value so prominent.
                      */
-                    fail("Expected $actualElement to be equal to $requiredElement, but was not")
+                    asserter.fail(
+                        "Expected $actualElement to be equal to $requiredElement, but was not"
+                    )
                 }
 
                 // Missing elements; elements that are not missing will be removed as we iterate.
@@ -328,7 +330,7 @@
                      * so return an object that will fail the test if the user calls inOrder().
                      */
 
-                    return FailingOrdered {
+                    return FailingOrdered(asserter) {
                         """
                              Contents match. Expected the order to also match, but was not.
                              Expected: $required.
@@ -337,7 +339,7 @@
                     }
                 }
 
-                fail(
+                asserter.fail(
                     """
                         Contents do not match.
                         Expected: $required.
@@ -356,7 +358,7 @@
         // extras. If the required iterator has elements, they're missing elements.
 
         if (actualIter.hasNext()) {
-            fail(
+            asserter.fail(
                 """
                     Contents do not match.
                     Expected: $required.
@@ -367,7 +369,7 @@
         }
 
         if (requiredIter.hasNext()) {
-            fail(
+            asserter.fail(
                 """
                     Contents do not match.
                     Expected: $required.
@@ -419,7 +421,7 @@
         val present = excluded.intersect(actual)
 
         if (present.isNotEmpty()) {
-            fail(
+            asserter.fail(
                 """
                     Expected not to contain any of $excluded but contained $present.
                     Actual: $actual.
@@ -515,7 +517,7 @@
             .zipWithNext(::Pair)
             .forEach { (a, b) ->
                 if (!predicate(a, b)) {
-                    fail(message(a, b))
+                    asserter.fail(message(a, b))
                 }
             }
     }
@@ -542,7 +544,7 @@
 
         val nonIterables = iterable.filterNot { it is Iterable<*> }
         if (nonIterables.isNotEmpty()) {
-            fail(
+            asserter.fail(
                 "The actual value is an Iterable, and you've written a test that compares it to " +
                     "some objects that are not Iterables. Did you instead mean to check " +
                     "whether its *contents* match any of the *contents* of the given values? " +
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Kruth.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Kruth.kt
index 89114369..d665351 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Kruth.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Kruth.kt
@@ -45,3 +45,11 @@
 
 fun <K, V> assertThat(actual: Map<K, V>?): MapSubject<K, V> =
     MapSubject(actual)
+
+/**
+ * Begins an assertion that, if it fails, will prepend the given message to the failure message.
+ */
+fun assertWithMessage(messageToPrepend: String): StandardSubjectBuilder =
+    StandardSubjectBuilder(
+        metadata = FailureMetadata(messagesToPrepend = listOf(messageToPrepend)),
+    )
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthAsserter.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthAsserter.kt
new file mode 100644
index 0000000..9964628
--- /dev/null
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthAsserter.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.kruth
+
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.contract
+
+@OptIn(ExperimentalContracts::class)
+internal class KruthAsserter(
+    private val formatMessage: (String?) -> String?,
+) {
+
+    /**
+     * Fails the current test with the specified message and cause exception.
+     *
+     * @param message the message to report.
+     * @param cause the exception to set as the root cause of the reported failure.
+     */
+    fun fail(message: String? = null, cause: Throwable? = null): Nothing {
+        kotlin.test.fail(message = formatMessage(message), cause = cause)
+    }
+
+    /**
+     * Asserts that the specified value is `true`.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun assertTrue(actual: Boolean, message: String? = null) {
+        contract { returns() implies actual }
+        kotlin.test.assertTrue(actual = actual, message = formatMessage(message))
+    }
+
+    /**
+     * Asserts that the specified value is `false`.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun assertFalse(actual: Boolean, message: String? = null) {
+        contract { returns() implies !actual }
+        kotlin.test.assertFalse(actual = actual, message = formatMessage(message))
+    }
+
+    /**
+     * Asserts that the specified values are equal.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun assertEquals(expected: Any?, actual: Any?, message: String? = null) {
+        kotlin.test.assertEquals(
+            expected = expected,
+            actual = actual,
+            message = formatMessage(message),
+        )
+    }
+
+    /**
+     * Asserts that the specified values are not equal.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun assertNotEquals(illegal: Any?, actual: Any?, message: String? = null) {
+        kotlin.test.assertNotEquals(
+            illegal = illegal,
+            actual = actual,
+            message = formatMessage(message),
+        )
+    }
+
+    /**
+     * Asserts that the specified value is `null`.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun assertNull(actual: Any?, message: String? = null) {
+        contract { returns() implies (actual == null) }
+        kotlin.test.assertNull(actual = actual, message = formatMessage(message))
+    }
+
+    /**
+     * Asserts that the specified value is not `null`.
+     *
+     * @param message the message to report if the assertion fails.
+     */
+    fun <T : Any> assertNotNull(actual: T?, message: String? = null): T {
+        contract { returns() implies (actual != null) }
+        kotlin.test.assertNotNull(actual = actual, message = formatMessage(message))
+
+        return requireNonNull(actual)
+    }
+}
\ No newline at end of file
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthExt.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthExt.kt
index e44dc61..4e59342 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthExt.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/KruthExt.kt
@@ -29,11 +29,11 @@
 // See: https://github.com/google/truth/issues/621
 inline fun <reified T : Throwable> assertThrows(block: () -> Unit): ThrowableSubject<T> {
     val e = assertFailsWith<T>(block = block)
-    return ThrowableSubject(e)
+    return assertThat(e)
 }
 
 inline fun <T : Throwable> assertThrows(exceptionClass: KClass<T>, block: () -> Unit):
     ThrowableSubject<T> {
     val e = assertFailsWith<T>(exceptionClass = exceptionClass, block = block)
-    return ThrowableSubject(e)
+    return assertThat(e)
 }
\ No newline at end of file
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/MapSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/MapSubject.kt
index c5fdabd..8cc9fe9 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/MapSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/MapSubject.kt
@@ -16,16 +16,14 @@
 
 package androidx.kruth
 
-import kotlin.test.fail
-
-class MapSubject<K, V>(actual: Map<K, V>?) : Subject<Map<K, V>>(actual) {
+class MapSubject<K, V> internal constructor(actual: Map<K, V>?) : Subject<Map<K, V>>(actual) {
 
     /** Fails if the map is not empty. */
     fun isEmpty() {
         requireNonNull(actual) { "Expected to be empty, but was null" }
 
         if (actual.isNotEmpty()) {
-            fail("Expected to be empty, but was $actual")
+            asserter.fail("Expected to be empty, but was $actual")
         }
     }
 
@@ -34,7 +32,7 @@
         requireNonNull(actual) { "Expected to contain $key, but was null" }
 
         if (!actual.containsKey(key)) {
-            fail("Expected to contain $key, but was ${actual.keys}")
+            asserter.fail("Expected to contain $key, but was ${actual.keys}")
         }
     }
 }
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StandardSubjectBuilder.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StandardSubjectBuilder.kt
new file mode 100644
index 0000000..85fe74e
--- /dev/null
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StandardSubjectBuilder.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.kruth
+
+/**
+ * In a fluent assertion chain, an object with which you can do any of the following:
+ *
+ * - Set an optional message with [withMessage].
+ * - For the types of [Subject] built into Kruth, directly specify the value under test
+ * with [withMessage].
+ */
+class StandardSubjectBuilder internal constructor(
+    private val metadata: FailureMetadata = FailureMetadata(),
+) {
+
+    /**
+     * Returns a new instance that will output the given message before the main failure message. If
+     * this method is called multiple times, the messages will appear in the order that they were
+     * specified.
+     */
+    fun withMessage(messageToPrepend: String): StandardSubjectBuilder =
+        StandardSubjectBuilder(metadata = metadata.withMessage(messageToPrepend = messageToPrepend))
+
+    fun <T> that(actual: T): Subject<T> =
+        Subject(actual = actual, metadata = metadata)
+
+    fun <T : Comparable<T>> that(actual: T?): ComparableSubject<T> =
+        ComparableSubject(actual = actual, metadata = metadata)
+
+    fun <T : Throwable> that(actual: T?): ThrowableSubject<T> =
+        ThrowableSubject(actual = actual, metadata = metadata)
+
+    fun that(actual: Boolean?): BooleanSubject =
+        BooleanSubject(actual = actual, metadata = metadata)
+
+    fun that(actual: String?): StringSubject =
+        StringSubject(actual = actual, metadata = metadata)
+
+    fun <T> that(actual: Iterable<T>?): IterableSubject<T> =
+        IterableSubject(actual = actual, metadata = metadata)
+
+    /**
+     * Reports a failure.
+     *
+     * To set a message, first call [withMessage] (or, more commonly, use the shortcut
+     * [assertWithMessage].
+     */
+    fun fail(): Nothing {
+        kotlin.test.fail(metadata.formatMessage())
+    }
+}
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StringSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StringSubject.kt
index 69497e0..dd3e03a 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StringSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/StringSubject.kt
@@ -16,34 +16,41 @@
 
 package androidx.kruth
 
-import kotlin.test.assertContains
-import kotlin.test.assertNotNull
-import kotlin.test.fail
-
 /**
  * Propositions for string subjects.
  */
-class StringSubject(actual: String?) : ComparableSubject<String>(actual) {
+class StringSubject internal constructor(
+    actual: String?,
+    metadata: FailureMetadata = FailureMetadata(),
+) : ComparableSubject<String>(actual = actual, metadata = metadata) {
 
     /**
      * Fails if the string does not contain the given sequence.
      */
     fun contains(charSequence: CharSequence) {
-        assertNotNull(actual)
-        assertContains(actual, charSequence)
+        asserter.assertNotNull(actual)
+
+        asserter.assertTrue(
+            message = "Expected to contain \"$charSequence\", but was: \"$actual\"",
+            actual = actual.contains(charSequence),
+        )
     }
 
     /** Fails if the string does not have the given length.  */
     fun hasLength(expectedLength: Int) {
-        assertNotNull(actual)
-        assertThat(actual.length).isEqualTo(expectedLength)
+        asserter.assertNotNull(actual)
+
+        asserter.assertTrue(
+            message = "Expected to have length $expectedLength, but was: \"$actual\"",
+            actual = actual.length == expectedLength,
+        )
     }
 
     /** Fails if the string is not equal to the zero-length "empty string."  */
     fun isEmpty() {
-        assertNotNull(actual)
+        asserter.assertNotNull(actual)
         if (actual.isNotEmpty()) {
-            fail(
+            asserter.fail(
                 """
                     expected to be empty
                     | but was $actual
@@ -54,18 +61,18 @@
 
     /** Fails if the string is equal to the zero-length "empty string."  */
     fun isNotEmpty() {
-        assertNotNull(actual)
+        asserter.assertNotNull(actual)
         if (actual.isEmpty()) {
-            fail("expected not to be empty")
+            asserter.fail("expected not to be empty")
         }
     }
 
     /** Fails if the string contains the given sequence.  */
     fun doesNotContain(string: CharSequence) {
-        assertNotNull(actual, "expected a string that does not contain $string")
+        asserter.assertNotNull(actual, "expected a string that does not contain $string")
 
         if (actual.contains(string)) {
-            fail(
+            asserter.fail(
                 """
                     expected not to contain $string
                     | but was $actual
@@ -76,10 +83,10 @@
 
     /** Fails if the string does not start with the given string.  */
     fun startsWith(string: String) {
-        assertNotNull(actual, "expected a string that starts with $string")
+        asserter.assertNotNull(actual, "expected a string that starts with $string")
 
         if (!actual.startsWith(string)) {
-            fail(
+            asserter.fail(
                 """
                     expected to start with $string
                     | but was $actual
@@ -90,10 +97,10 @@
 
     /** Fails if the string does not end with the given string.  */
     fun endsWith(string: String) {
-        assertNotNull(actual, "expected a string that ends with $string")
+        asserter.assertNotNull(actual, "expected a string that ends with $string")
 
         if (!actual.endsWith(string)) {
-            fail(
+            asserter.fail(
                 """
                     expected to end with $string
                     | but was $actual
@@ -125,13 +132,17 @@
         fun isEqualTo(expected: String?) {
             when {
                 (actual == null) && (expected != null) ->
-                    fail("Expected a string equal to \"$expected\" (case is ignored), but was null")
+                    asserter.fail(
+                        "Expected a string equal to \"$expected\" (case is ignored), but was null"
+                    )
 
                 (expected == null) && (actual != null) ->
-                    fail("Expected a string that is null (null reference), but was \"$actual\"")
+                    asserter.fail(
+                        "Expected a string that is null (null reference), but was \"$actual\""
+                    )
 
                 !actual.equals(expected, ignoreCase = true) ->
-                    fail(
+                    asserter.fail(
                         "Expected a string equal to \"$expected\" (case is ignored), " +
                             "but was \"$actual\""
                     )
@@ -145,10 +156,12 @@
         fun isNotEqualTo(unexpected: String?) {
             when {
                 (actual == null) && (unexpected == null) ->
-                    fail("Expected a string not equal to null (null reference), but it was null")
+                    asserter.fail(
+                        "Expected a string not equal to null (null reference), but it was null"
+                    )
 
                 actual.equals(unexpected, ignoreCase = true) ->
-                    fail(
+                    asserter.fail(
                         "Expected a string not equal to \"$unexpected\" (case is ignored), " +
                             "but it was equal. Actual string: \"$actual\"."
                     )
@@ -161,29 +174,31 @@
 
             when {
                 actual == null ->
-                    fail(
+                    asserter.fail(
                         "Expected a string that contains \"$expected\" (case is ignored), " +
                             "but was null"
                     )
 
                 !actual.contains(expected, ignoreCase = true) ->
-                    fail("Expected to contain \"$expected\" (case is ignored), but was \"$actual\"")
+                    asserter.fail(
+                        "Expected to contain \"$expected\" (case is ignored), but was \"$actual\""
+                    )
             }
         }
 
         /** Fails if the string contains the given sequence (while ignoring case).  */
         fun doesNotContain(expected: CharSequence?) {
-            checkNotNull(expected)
+            requireNonNull(expected)
 
             when {
                 actual == null ->
-                    fail(
+                    asserter.fail(
                         "Expected a string that does not contain \"$expected\" " +
                             "(case is ignored), but was null"
                     )
 
                 actual.contains(expected, ignoreCase = true) ->
-                    fail(
+                    asserter.fail(
                         "Expected a string that does not contain \"$expected\" " +
                             "(case is ignored), but it was. Actual string: \"$actual\"."
                     )
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Subject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Subject.kt
index c70574bc..12f59e3 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Subject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/Subject.kt
@@ -16,11 +16,7 @@
 
 package androidx.kruth
 
-import kotlin.test.assertFalse
-import kotlin.test.assertIs
-import kotlin.test.assertIsNot
-import kotlin.test.assertTrue
-import kotlin.test.fail
+import kotlin.reflect.typeOf
 
 // As opposed to Truth, which limits visibility on `actual` and the generic type, we purposely make
 // them visible in Kruth to allow for an easier time extending in Kotlin.
@@ -32,7 +28,12 @@
  *
  * To create a [Subject] instance, most users will call an [assertThat] method.
  */
-open class Subject<out T>(val actual: T?) {
+open class Subject<out T> internal constructor(
+    val actual: T?,
+    private val metadata: FailureMetadata = FailureMetadata(),
+) {
+
+    internal val asserter: KruthAsserter = KruthAsserter(formatMessage = metadata::formatMessage)
 
     /**
      *  Fails if the subject is not null.
@@ -83,7 +84,7 @@
     /** Fails if the subject is not the same instance as the given object.  */
     open fun isSameInstanceAs(expected: Any?) {
         if (actual !== expected) {
-            fail(
+            asserter.fail(
                 "Expected ${actual.toStringForAssert()} to be the same instance as " +
                     "${expected.toStringForAssert()}, but was not"
             )
@@ -93,26 +94,39 @@
     /** Fails if the subject is the same instance as the given object.  */
     open fun isNotSameInstanceAs(unexpected: Any?) {
         if (actual === unexpected) {
-            fail("Expected ${actual.toStringForAssert()} not to be specific instance, but it was")
+            asserter.fail(
+                "Expected ${actual.toStringForAssert()} not to be specific instance, but it was"
+            )
         }
     }
 
     /**
      * Fails if the subject is not an instance of the given class.
      */
-    inline fun <reified V> isInstanceOf() = assertIs<V>(actual)
+    inline fun <reified V> isInstanceOf() {
+        if (actual !is V) {
+            doFail("Expected $actual to be an instance of ${typeOf<V>()} but it was not")
+        }
+    }
 
     /**
      * Fails if the subject is an instance of the given class.
      **/
     inline fun <reified V> isNotInstanceOf() {
-        assertIsNot<V>(actual)
+        if (actual is V) {
+            doFail("Expected $actual to be not an instance of ${typeOf<V>()} but it was")
+        }
+    }
+
+    @PublishedApi
+    internal fun doFail(message: String) {
+        asserter.fail(message = message)
     }
 
     /** Fails unless the subject is equal to any element in the given [iterable]. */
     open fun isIn(iterable: Iterable<*>?) {
         if (actual !in requireNonNull(iterable)) {
-            fail("Expected $actual to be in $iterable, but was not")
+            asserter.fail("Expected $actual to be in $iterable, but was not")
         }
     }
 
@@ -124,7 +138,7 @@
     /** Fails if the subject is equal to any element in the given [iterable]. */
     open fun isNotIn(iterable: Iterable<*>?) {
         if (actual in requireNonNull(iterable)) {
-            fail("Expected $actual not to be in $iterable, but it was")
+            asserter.fail("Expected $actual not to be in $iterable, but it was")
         }
     }
 
@@ -132,67 +146,68 @@
     open fun isNoneOf(first: Any?, second: Any?, vararg rest: Any?) {
         isNotIn(listOf(first, second, *rest))
     }
-}
 
-private fun Any?.standardIsEqualTo(expected: Any?) {
-    assertTrue(
-        compareForEquality(expected),
-        "expected: ${expected.toStringForAssert()} but was: ${toStringForAssert()}",
-    )
-}
-
-private fun Any?.standardIsNotEqualTo(unexpected: Any?) {
-    assertFalse(
-        compareForEquality(unexpected),
-        "expected ${toStringForAssert()} not be equal to ${unexpected.toStringForAssert()}, " +
-            "but it was",
-    )
-}
-
-/**
- * Returns whether [this] equals [expected].
- *
- * The equality check follows the rules described on [Subject.isEqualTo].
- */
-private fun Any?.compareForEquality(expected: Any?): Boolean {
-    @Suppress("SuspiciousEqualsCombination") // Intentional for behaviour compatibility.
-    // This is migrated from Truth's equality helper, which has very specific logic for handling the
-    // magic "casting" they do between types. See:
-    // https://github.com/google/truth/blob/master/core/src/main/java/com/google/common/truth/Subject.java#L210
-    return when {
-        this == null && expected == null -> true
-        this == null || expected == null -> false
-        this is ByteArray && expected is ByteArray -> contentEquals(expected)
-        this is IntArray && expected is IntArray -> contentEquals(expected)
-        this is LongArray && expected is LongArray -> contentEquals(expected)
-        this is FloatArray && expected is FloatArray -> contentEquals(expected)
-        this is DoubleArray && expected is DoubleArray -> contentEquals(expected)
-        this is ShortArray && expected is ShortArray -> contentEquals(expected)
-        this is CharArray && expected is CharArray -> contentEquals(expected)
-        this is Array<*> && expected is Array<*> -> contentDeepEquals(expected)
-        isIntegralBoxedPrimitive() && expected.isIntegralBoxedPrimitive() -> {
-            integralValue() == expected.integralValue()
-        }
-        this is Double && expected is Double -> compareTo(expected) == 0
-        this is Float && expected is Float -> compareTo(expected) == 0
-        this is Double && expected is Int -> compareTo(expected.toDouble()) == 0
-        this is Float && expected is Int -> toDouble().compareTo(expected.toDouble()) == 0
-        else -> this === expected || this == expected
+    private fun Any?.standardIsEqualTo(expected: Any?) {
+        asserter.assertTrue(
+            compareForEquality(expected),
+            "expected: ${expected.toStringForAssert()} but was: ${toStringForAssert()}",
+        )
     }
-}
 
-private fun Any?.isIntegralBoxedPrimitive(): Boolean {
-    return this is Byte || this is Short || this is Char || this is Int || this is Long
-}
+    private fun Any?.standardIsNotEqualTo(unexpected: Any?) {
+        asserter.assertFalse(
+            compareForEquality(unexpected),
+            "expected ${toStringForAssert()} not be equal to ${unexpected.toStringForAssert()}, " +
+                "but it was",
+        )
+    }
 
-private fun Any?.integralValue(): Long = when (this) {
-    is Char -> code.toLong()
-    is Number -> toLong()
-    else -> throw AssertionError("$this must be either a Char or a Number.")
-}
+    /**
+     * Returns whether [this] equals [expected].
+     *
+     * The equality check follows the rules described on [Subject.isEqualTo].
+     */
+    private fun Any?.compareForEquality(expected: Any?): Boolean {
+        @Suppress("SuspiciousEqualsCombination") // Intentional for behaviour compatibility.
+        // This is migrated from Truth's equality helper, which has very specific logic for handling the
+        // magic "casting" they do between types. See:
+        // https://github.com/google/truth/blob/master/core/src/main/java/com/google/common/truth/Subject.java#L210
+        return when {
+            this == null && expected == null -> true
+            this == null || expected == null -> false
+            this is ByteArray && expected is ByteArray -> contentEquals(expected)
+            this is IntArray && expected is IntArray -> contentEquals(expected)
+            this is LongArray && expected is LongArray -> contentEquals(expected)
+            this is FloatArray && expected is FloatArray -> contentEquals(expected)
+            this is DoubleArray && expected is DoubleArray -> contentEquals(expected)
+            this is ShortArray && expected is ShortArray -> contentEquals(expected)
+            this is CharArray && expected is CharArray -> contentEquals(expected)
+            this is Array<*> && expected is Array<*> -> contentDeepEquals(expected)
+            isIntegralBoxedPrimitive() && expected.isIntegralBoxedPrimitive() -> {
+                integralValue() == expected.integralValue()
+            }
 
-private fun Any?.toStringForAssert(): String = when {
-    this == null -> toString()
-    isIntegralBoxedPrimitive() -> "${this::class.qualifiedName}<$this>"
-    else -> toString()
+            this is Double && expected is Double -> compareTo(expected) == 0
+            this is Float && expected is Float -> compareTo(expected) == 0
+            this is Double && expected is Int -> compareTo(expected.toDouble()) == 0
+            this is Float && expected is Int -> toDouble().compareTo(expected.toDouble()) == 0
+            else -> this === expected || this == expected
+        }
+    }
+
+    private fun Any?.isIntegralBoxedPrimitive(): Boolean {
+        return this is Byte || this is Short || this is Char || this is Int || this is Long
+    }
+
+    private fun Any?.integralValue(): Long = when (this) {
+        is Char -> code.toLong()
+        is Number -> toLong()
+        else -> asserter.fail("$this must be either a Char or a Number.")
+    }
+
+    private fun Any?.toStringForAssert(): String = when {
+        this == null -> toString()
+        isIntegralBoxedPrimitive() -> "${this::class.qualifiedName}<$this>"
+        else -> toString()
+    }
 }
diff --git a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ThrowableSubject.kt b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ThrowableSubject.kt
index 635b09a..2305ed4 100644
--- a/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ThrowableSubject.kt
+++ b/testutils/testutils-kmp/src/commonMain/kotlin/androidx/kruth/ThrowableSubject.kt
@@ -19,12 +19,15 @@
 /**
  * Propositions for [Throwable] subjects.
  */
-class ThrowableSubject<T : Throwable>(actual: T?) : Subject<T>(actual) {
+class ThrowableSubject<T : Throwable> internal constructor(
+    actual: T?,
+    private val metadata: FailureMetadata = FailureMetadata(),
+) : Subject<T>(actual = actual, metadata = metadata) {
 
     /**
      * Returns a [StringSubject] to make assertions about the throwable's message.
      */
     fun hasMessageThat(): StringSubject {
-        return StringSubject(actual?.message)
+        return StringSubject(actual = actual?.message, metadata = metadata)
     }
 }
\ No newline at end of file
diff --git a/testutils/testutils-kmp/src/commonTest/kotlin/androidx/kruth/AssertWithMessageTest.kt b/testutils/testutils-kmp/src/commonTest/kotlin/androidx/kruth/AssertWithMessageTest.kt
new file mode 100644
index 0000000..57e27d4
--- /dev/null
+++ b/testutils/testutils-kmp/src/commonTest/kotlin/androidx/kruth/AssertWithMessageTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.kruth
+
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+class AssertWithMessageTest {
+
+    private val assert = assertWithMessage("Msg1").withMessage("Msg2")
+
+    @Test
+    fun that_customObjects_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that(Some(value = 0)).isEqualTo(Some(value = 1))
+        }
+    }
+
+    @Test
+    fun that_comparable_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that(1).isLessThan(0)
+        }
+    }
+
+    @Test
+    fun that_throwable_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that(Exception("Msg")).hasMessageThat().contains("NonExistentString")
+        }
+    }
+
+    @Test
+    fun that_boolean_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that(false).isTrue()
+        }
+    }
+
+    @Test
+    fun that_string_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that("Str").contains("NonExistentString")
+        }
+    }
+
+    @Test
+    fun that_iterable_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.that(listOf(1, 2, 3)).contains(4)
+        }
+    }
+
+    @Test
+    fun fail_errorContainsMessage() {
+        assertFailsWithMessage {
+            assert.fail()
+        }
+    }
+
+    private fun assertFailsWithMessage(block: () -> Unit) {
+        try {
+            block()
+            fail("Expected to fail but didn't")
+        } catch (e: AssertionError) {
+            val msg = assertNotNull(e.message)
+            assertTrue(msg.startsWith("Msg1"))
+            assertContains(msg, "Msg2")
+        }
+    }
+
+    private data class Some(val value: Int)
+}
\ No newline at end of file