Repository: kotest/kotest (kotlin)

Issue:
withClue({ "lazy message" }) { ... } logs "() -> kotlin.String"
**Please describe the feature you'd like to see including any solutions in mind if you have any**

```kotlin
withClue({ "lazy message" }) {
    2 shouldBe 3
}
```

fails with `() -> kotlin.String`, and `lazy message` is never printed:

```
() -> kotlin.String
expected:<3> but was:<2>
Expected :3
Actual   :2
<Click to see difference>
```

The reason is that the compiler calls `withClue(Any, ...)` function, so `() -> String` is printed.

I would say in most of the cases, lazy clue is needed so it is computed only in case of failure, and the current API makes is hard to use.

`withClue(lazy { ... }) { ... }` is too verbose.

WDYT of making `withClue(Lazy)` as deprecated, and replacing it with `withClue(() -> Any, ...)`?

Pull request description:
Fixes https://github.com/kotest/kotest/issues/3342

The PR adds

```kotlin
withClue({ "message: $..." }) { ...assertions... }
{ "message: $:..." }.asClue { ...assertions... }
```

Patch:
diff --git a/documentation/docs/assertions/clues.md b/documentation/docs/assertions/clues.md
index 00b1289e752..66dea51cba0 100644
--- a/documentation/docs/assertions/clues.md
+++ b/documentation/docs/assertions/clues.md
@@ -4,9 +4,11 @@ slug: clues.html
 ---
 
 :::note
-Clues only work if you are using the Kotest test framework in conjuction with the assertions library
+Clues only work if you are using the Kotest assertions library
 :::
 
+A rule of thumb is that a failing test should look like a good bug report.
+In other words, it should tell you what went wrong, and ideally why it went wrong.
 
 Sometimes a failed assertion contains enough information in the error message to know what went wrong.
 
@@ -22,7 +24,7 @@ Might give an error like:
 expected: "sksamuel" but was: "sam@myemailaddress.com"
 ```
 
-And you would be able to see that you were populating the username field with an email address.
+In this case, it looks like the system populates the username field with an email address.
 
 But let's say you had a test like this:
 
@@ -55,9 +57,39 @@ Name should be present
 <null> should not equal <null>
 ```
 
+The error message became much better, however, it is still not as good as it could be.
+For instance, it might be helpful to know the user's id to check the database.
+
+We can use `asClue` to add the user's id to the error message:
+
+```kotlin
+withClue({ "Name should be present (user_id=${user.id})" }) {
+  user.name shouldNotBe null
+}
+```
+
 We can also use the `asClue` extension function to turn any object into the clue message.
 
-For example:
+```kotlin
+{ "Name should be present (user_id=${user.id})" }.asClue {
+user.name shouldNotBe null
+}
+```
+
+The message will be computed only in case the test fails, so it is safe to use it with expensive operations.
+
+:::tip
+Test failures include a failed assertion, test name, clues, and stacktrace.
+Consider using them in such a way, so they answer both what has failed, and why it failed.
+It will make the tests easier to maintain, especially when it comes to reverse-engineering the intention of the test author.
+:::
+
+:::tip
+Every time you see a code comment above an assertion, consider using `asClue`, or `withClue` instead.
+The comments are not visible in the test failures, especially on CI, while clues will be visible.
+:::
+
+You can use domain objects as clues as well:
 
 ```kotlin
 data class HttpResponse(val status: Int, val body: String)
@@ -77,3 +109,33 @@ HttpResponse(status=404, body=the content)
 Expected :200
 Actual   :404
 ```
+
+:::note
+Kotest considers all `() -> Any?` clues as lazy clues, and would compute them and use `.toString()` on the resulting value
+instead of calling `.toString()` on the function itself.
+In most cases, it should do exactly what you need, however, if clue object implements `() -> Any?`, and you want
+using `clue.toString()`, then consider wrapping the clue manually as `{ clue.toString() }.asClue { ... }`.
+:::
+
+## Nested clues
+
+Clues can be nested, and they all will be visible in the failed assertion messages:
+
+```kotlin
+{ "Verifying user_id=${user.name}" }.asClue {
+  "email_confirmed should be false since we've just created the user" {
+    user.emailConfirmed shouldBe false
+  }
+  "login" {
+    user.login shouldBe "sksamuel"
+  }
+}
+```
+
+The failure might look like
+
+```
+Verifying user_id=42
+email_confirmed should be false since we've just created the user
+<true> should equal <false>
+```
diff --git a/documentation/versioned_docs/version-5.2/assertions/clues.md b/documentation/versioned_docs/version-5.2/assertions/clues.md
index 00b1289e752..659b453c171 100644
--- a/documentation/versioned_docs/version-5.2/assertions/clues.md
+++ b/documentation/versioned_docs/version-5.2/assertions/clues.md
@@ -4,7 +4,7 @@ slug: clues.html
 ---
 
 :::note
-Clues only work if you are using the Kotest test framework in conjuction with the assertions library
+Clues only work if you are using the Kotest assertions library or Kotest test framework
 :::
 
 
diff --git a/documentation/versioned_docs/version-5.3/assertions/clues.md b/documentation/versioned_docs/version-5.3/assertions/clues.md
index 00b1289e752..659b453c171 100644
--- a/documentation/versioned_docs/version-5.3/assertions/clues.md
+++ b/documentation/versioned_docs/version-5.3/assertions/clues.md
@@ -4,7 +4,7 @@ slug: clues.html
 ---
 
 :::note
-Clues only work if you are using the Kotest test framework in conjuction with the assertions library
+Clues only work if you are using the Kotest assertions library or Kotest test framework
 :::
 
 
diff --git a/documentation/versioned_docs/version-5.4/assertions/clues.md b/documentation/versioned_docs/version-5.4/assertions/clues.md
index 00b1289e752..f34c96ca62a 100644
--- a/documentation/versioned_docs/version-5.4/assertions/clues.md
+++ b/documentation/versioned_docs/version-5.4/assertions/clues.md
@@ -4,7 +4,7 @@ slug: clues.html
 ---
 
 :::note
-Clues only work if you are using the Kotest test framework in conjuction with the assertions library
+Clues only work if you are using the Kotest assertions library or Kotest test framework
 :::
 
 
@@ -38,7 +38,7 @@ If this failed, you would simply get:
 
 Which isn't particularly helpful. This is where `withClue` comes into play.
 
-The `withClue` and `asClue` helpers can add extra context to assertions so failures are self explanatory:
+The `withClue` and `asClue` helpers can add extra context to assertions so failures are self-explanatory:
 
 For example, we can use `withClue` with a string message
 
diff --git a/documentation/versioned_docs/version-5.5/assertions/clues.md b/documentation/versioned_docs/version-5.5/assertions/clues.md
index 00b1289e752..2fad2b80265 100644
--- a/documentation/versioned_docs/version-5.5/assertions/clues.md
+++ b/documentation/versioned_docs/version-5.5/assertions/clues.md
@@ -4,9 +4,11 @@ slug: clues.html
 ---
 
 :::note
-Clues only work if you are using the Kotest test framework in conjuction with the assertions library
+Clues only work if you are using the Kotest assertions library or Kotest test framework
 :::
 
+A rule of thumb is that a failing test should look like a good bug report.
+In other words, it should tell you what went wrong, and ideally why it went wrong.
 
 Sometimes a failed assertion contains enough information in the error message to know what went wrong.
 
@@ -22,7 +24,7 @@ Might give an error like:
 expected: "sksamuel" but was: "sam@myemailaddress.com"
 ```
 
-And you would be able to see that you were populating the username field with an email address.
+In this case, it looks like the system populates the username field with an email address.
 
 But let's say you had a test like this:
 
@@ -55,9 +57,39 @@ Name should be present
 <null> should not equal <null>
 ```
 
+The error message became much better, however, it is still not as good as it could be.
+For instance, it might be helpful to know the user's id to check the database.
+
+We can use `asClue` to add the user's id to the error message:
+
+```kotlin
+withClue({ "Name should be present (user_id=${user.id})" }) {
+  user.name shouldNotBe null
+}
+```
+
 We can also use the `asClue` extension function to turn any object into the clue message.
 
-For example:
+```kotlin
+{ "Name should be present (user_id=${user.id})" }.asClue {
+user.name shouldNotBe null
+}
+```
+
+The message will be computed only in case the test fails, so it is safe to use it with expensive operations.
+
+:::tip
+Test failures include a failed assertion, test name, clues, and stacktrace.
+Consider using them in such a way, so they answer both what has failed, and why it failed.
+It will make the tests easier to maintain, especially when it comes to reverse-engineering the intention of the test author.
+:::
+
+:::tip
+Every time you see a code comment above an assertion, consider using `asClue`, or `withClue` instead.
+The comments are not visible in the test failures, especially on CI, while clues will be visible.
+:::
+
+You can use domain objects as clues as well:
 
 ```kotlin
 data class HttpResponse(val status: Int, val body: String)
@@ -77,3 +109,33 @@ HttpResponse(status=404, body=the content)
 Expected :200
 Actual   :404
 ```
+
+:::note
+Kotest considers all `() -> Any?` clues as lazy clues, and would compute them and use `.toString()` on the resulting value
+instead of calling `.toString()` on the function itself.
+In most cases, it should do exactly what you need, however, if clue object implements `() -> Any?`, and you want
+using `clue.toString()`, then consider wrapping the clue manually as `{ clue.toString() }.asClue { ... }`.
+:::
+
+## Nested clues
+
+Clues can be nested, and they all will be visible in the failed assertion messages:
+
+```kotlin
+{ "Verifying user_id=${user.name}" }.asClue {
+  "email_confirmed should be false since we've just created the user" {
+    user.emailConfirmed shouldBe false
+  }
+  "login" {
+    user.login shouldBe "sksamuel"
+  }
+}
+```
+
+The failure might look like
+
+```
+Verifying user_id=42
+email_confirmed should be false since we've just created the user
+<true> should equal <false>
+```


Test patch:
diff --git a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/ClueTest.kt b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/ClueTest.kt
index 21ca5eb835d..ce3a2984238 100644
--- a/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/ClueTest.kt
+++ b/kotest-assertions/kotest-assertions-core/src/jvmTest/kotlin/com/sksamuel/kotest/assertions/ClueTest.kt
@@ -2,12 +2,12 @@ package com.sksamuel.kotest.assertions
 
 import io.kotest.assertions.asClue
 import io.kotest.assertions.assertSoftly
+import io.kotest.assertions.fail
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.assertions.withClue
 import io.kotest.core.spec.style.FreeSpec
 import io.kotest.matchers.Matcher
 import io.kotest.matchers.MatcherResult
-import io.kotest.matchers.nulls.shouldBeNull
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.string.shouldContain
 import io.kotest.matchers.string.shouldStartWith
@@ -61,26 +61,71 @@ class ClueTest : FreeSpec({
          shouldThrow<AssertionError> { "7" shouldBe "8" }.message shouldBe "expected:<\"8\"> but was:<\"7\">"
       }
 
-      "should only invoke the lazy if an assertion fails" {
-         val expected = UUID.randomUUID()
-         var state: String? = null
-         val clue: Lazy<UUID> = lazy {
-            expected.also { state = it.toString() }
+      "should not invoke the lazy clue if an assertion succeeds" {
+         @Suppress("DEPRECATION")
+         withClue(lazy { fail("lazy clue must not be called in case assertion succeeds") }) {
+            1 + 1 shouldBe 2
          }
+      }
 
-         state.shouldBeNull()
+      "should invoke the lazy clue if an assertion fails" {
+         var counter = 1
+         @Suppress("DEPRECATION")
+         withClue(lazy {
+            counter -= 1
+            if (counter == 0) {
+               fail("lazy clue must be called only once")
+            }
+            "extra lazy message"
+         }) {
+            shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith "extra lazy message"
+         }
+      }
 
-         withClue(clue) {
+      "should not invoke { .. } clue if an assertion succeeds" {
+         withClue({ fail("{ .. } clue must not be called in case assertion succeeds") }) {
             1 + 1 shouldBe 2
          }
+      }
 
-         state.shouldBeNull()
+      "should invoke { .. } clue if an assertion fails" {
+         var counter = 1
+         @Suppress("DEPRECATION")
+         withClue({
+            counter -= 1
+            if (counter == 0) {
+               fail("{ .. } clue must be called only once")
+            }
+            "extra lazy message"
+         }) {
+            shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith "extra lazy message"
+         }
+      }
 
-         withClue(clue) {
-            shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith expected.toString()
+      "{ \"message\" }.asClue { ... } should work" {
+         { "{...}.asClue message" }.asClue {
+            shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith "{...}.asClue message"
          }
+      }
+
+      "clues can nest" {
+         "first level clue 1".asClue {
+            "second level clue 2".asClue {
+               shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith "first level clue 1\nsecond level clue 2"
+            }
+         }
+      }
 
-         state shouldBe expected.toString()
+      "top-level clue should not stick when nesting" {
+         for (i in 1..2) {
+            { "first level clue $i" }.asClue {
+               for (j in 1..2) {
+                  { "second level clue $j" }.asClue {
+                     shouldThrow<AssertionError> { "1" shouldBe "2" }.message shouldStartWith "first level clue $i\nsecond level clue $j"
+                  }
+               }
+            }
+         }
       }
 
       "clue can be nullable" {
diff --git a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/clues.kt b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/clues.kt
index 70cc97b5bc8..e05f2d5c744 100644
--- a/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/clues.kt
+++ b/kotest-assertions/kotest-assertions-shared/src/commonMain/kotlin/io/kotest/assertions/clues.kt
@@ -20,11 +20,11 @@ inline fun <R> withClue(clue: Any?, thunk: () -> R): R {
  * @param thunk the code with assertions to be executed
  * @return the return value of the supplied [thunk]
  */
-inline fun <R> withClue(clue: Lazy<Any?>, thunk: () -> R): R {
+inline fun <R> withClue(crossinline clue: () -> Any?, thunk: () -> R): R {
    try {
-      errorCollector.pushClue { clue.value.toString() }
+      errorCollector.pushClue { clue.invoke().toString() }
       return thunk()
-   // this is a special control exception used by coroutines
+      // this is a special control exception used by coroutines
    } catch (t: TimeoutCancellationException) {
       throw Exceptions.createAssertionError(clueContextAsString() + (t.message ?: ""), t)
    } finally {
@@ -32,14 +32,39 @@ inline fun <R> withClue(clue: Lazy<Any?>, thunk: () -> R): R {
    }
 }
 
+/**
+ * Similar to [withClue] but accepts a lazy in the case that a clue is expensive or is only valid when an assertion fails.
+ * Can be nested, the error message will contain all available clues.
+ *
+ * @param thunk the code with assertions to be executed
+ * @return the return value of the supplied [thunk]
+ */
+@Deprecated(
+   message = "use withClue(lambda, lambda) instead",
+   replaceWith = ReplaceWith("withClue({ clue.value }, thunk)"),
+   level = DeprecationLevel.WARNING
+)
+inline fun <R> withClue(clue: Lazy<Any?>, thunk: () -> R): R =
+   clue.asClue { thunk() }
+
 /**
  * Similar to `withClue`, but will add `this` as a clue to the assertion error message in case an assertion fails.
  * Can be nested, the error message will contain all available clues.
+ * `Lazy` and `Function0` are treated as lazy clues, so they will be evaluated only if an assertion fails.
  *
  * @param block the code with assertions to be executed
  * @return the return value of the supplied [block]
  */
-inline fun <T : Any?, R> T.asClue(block: (T) -> R): R = withClue(lazy { this.toString() }) { block(this) }
+@Suppress("USELESS_CAST")
+inline fun <T : Any?, R> T.asClue(block: (T) -> R): R =
+   withClue(
+      // The cast is needed to avoid calling withClue(Any)
+      when (this) {
+         is Lazy<*> -> ({ value })
+         is Function0<*> -> ({ invoke() })
+         else -> ({ this })
+      } as () -> Any?,
+   ) { block(this) }
 
 inline fun <T : Any?> Iterable<T>.forEachAsClue(action: (T) -> Unit) = forEach { element ->
    element.asClue {