From 6a48f909e6972142607a4709e99b2edd96f7b8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Feb 2026 18:35:30 +0100 Subject: [PATCH 1/3] feat(suite): Decorate exceptions thrown in prepared {} or shared {} with an AssertionError --- suite/src/commonMain/kotlin/Prepared.kt | 14 ++++-- suite/src/commonMain/kotlin/Shared.kt | 11 ++++- suite/src/commonTest/kotlin/FixtureTest.kt | 52 ++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 suite/src/commonTest/kotlin/FixtureTest.kt diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 1e5b5cd..d194ce1 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2025, OpenSavvy and contributors. + * Copyright (c) 2023-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package opensavvy.prepared.suite import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import opensavvy.prepared.suite.display.Display import kotlin.coroutines.CoroutineContext @@ -91,9 +92,14 @@ class Prepared internal constructor( internal suspend fun executeIn(scope: TestDsl): T = scope.environment.cache.cache(this) { withContext(CoroutineName("Preparing $name")) { - val (result, elapsedTime) = measureTimedValue { scope.block() } - println("» Prepared ‘$name’: ${display.display(result)}, took $elapsedTime") - result + try { + val (result, elapsedTime) = measureTimedValue { scope.block() } + println("» Prepared ‘$name’: ${display.display(result)}, took $elapsedTime") + result + } catch (e: Exception) { + ensureActive() + throw AssertionError("An exception was thrown while computing the prepared value ‘$name’", e) + } } } as T diff --git a/suite/src/commonMain/kotlin/Shared.kt b/suite/src/commonMain/kotlin/Shared.kt index 6da1812..1c04552 100644 --- a/suite/src/commonMain/kotlin/Shared.kt +++ b/suite/src/commonMain/kotlin/Shared.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2025, OpenSavvy and contributors. + * Copyright (c) 2023-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package opensavvy.prepared.suite +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -94,7 +96,12 @@ class Shared internal constructor( lock.withLock { if (result is Option.Empty) { elapsedTime = measureTime { - result = Option.Present(block()) + try { + result = Option.Present(block()) + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + throw AssertionError("An exception was thrown while computing the shared value ‘$name’", e) + } } fromHere = true } else { diff --git a/suite/src/commonTest/kotlin/FixtureTest.kt b/suite/src/commonTest/kotlin/FixtureTest.kt new file mode 100644 index 0000000..d713591 --- /dev/null +++ b/suite/src/commonTest/kotlin/FixtureTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * 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 opensavvy.prepared.suite + +import opensavvy.prepared.runner.testballoon.preparedSuite +import opensavvy.prepared.suite.assertions.checkThrows + +val FixtureTest by preparedSuite { + + fun Throwable.rootCause(): Throwable = + this.cause?.rootCause() ?: this + + test("Exception in prepared value") { + val willThrow by prepared { + throw IllegalStateException("foo") + } + + val e = checkThrows { + willThrow() + } + check(e.message == "An exception was thrown while computing the prepared value ‘willThrow’") + val cause = e.rootCause() + check(cause is IllegalStateException && cause.message == "foo") + } + + test("Exception in shared value") { + val willThrow by shared { + throw IllegalStateException("foo") + } + + val e = checkThrows { + willThrow() + } + check(e.message == "An exception was thrown while computing the shared value ‘willThrow’") + val cause = e.rootCause() + check(cause is IllegalStateException && cause.message == "foo") + } +} -- 2.51.2 From cfd7cdd4cc83a33ae80c9f3b718742beeed4e466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Feb 2026 19:45:07 +0100 Subject: [PATCH 2/3] feat(suite): Guarantee that prepared {} always returns the same value within a test, even if it's an exception --- suite/src/commonMain/kotlin/Prepared.kt | 14 +++++++++----- suite/src/commonTest/kotlin/FixtureTest.kt | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index d194ce1..12e1fa4 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -89,19 +89,23 @@ class Prepared internal constructor( ) { @Suppress("UNCHECKED_CAST") - internal suspend fun executeIn(scope: TestDsl): T = - scope.environment.cache.cache(this) { + internal suspend fun executeIn(scope: TestDsl): T { + val stored = scope.environment.cache.cache(this) { withContext(CoroutineName("Preparing $name")) { try { val (result, elapsedTime) = measureTimedValue { scope.block() } println("» Prepared ‘$name’: ${display.display(result)}, took $elapsedTime") - result + Result.success(result) } catch (e: Exception) { ensureActive() - throw AssertionError("An exception was thrown while computing the prepared value ‘$name’", e) + println("» Prepared ‘$name’: Failed with $e") + Result.failure(AssertionError("An exception was thrown while computing the prepared value ‘$name’", e)) } } - } as T + } as Result + + return stored.getOrThrow() + } // impl note: // this class *must not* have an equals method diff --git a/suite/src/commonTest/kotlin/FixtureTest.kt b/suite/src/commonTest/kotlin/FixtureTest.kt index d713591..3ff2651 100644 --- a/suite/src/commonTest/kotlin/FixtureTest.kt +++ b/suite/src/commonTest/kotlin/FixtureTest.kt @@ -37,6 +37,22 @@ val FixtureTest by preparedSuite { check(cause is IllegalStateException && cause.message == "foo") } + test("A failed prepared value should always return the same exception instance within the same test") { + val willThrow by prepared { + throw IllegalStateException("foo") + } + + val a = checkThrows { + willThrow() + } + + val b = checkThrows { + willThrow() + } + + check(a === b) + } + test("Exception in shared value") { val willThrow by shared { throw IllegalStateException("foo") -- 2.51.2 From f9525ed5cb1364601e3bc92d6ae29c3c09ff609a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Feb 2026 20:29:31 +0100 Subject: [PATCH 3/3] feat(suite): Guarantee that shared {} always returns the same value, even if it's an exception --- suite/src/commonMain/kotlin/Shared.kt | 47 ++++++++++++++++------ suite/src/commonTest/kotlin/FixtureTest.kt | 16 ++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/suite/src/commonMain/kotlin/Shared.kt b/suite/src/commonMain/kotlin/Shared.kt index 1c04552..deb97d7 100644 --- a/suite/src/commonMain/kotlin/Shared.kt +++ b/suite/src/commonMain/kotlin/Shared.kt @@ -85,7 +85,15 @@ class Shared internal constructor( ) { private val lock = Mutex() - private var result: Option = Option.Empty + + /** + * Three-state result. Implemented as Any? to avoid wrapper objects. + * + * - [Empty]: this shared value has not been initialized yet. + * - [Failed]: this shared value has failed during initialization. Contains the failure. + * - Anything else: this shared value has been initialized successfully. Contains an instance of [T]. Maybe be `null`. + */ + private var result: Any? = Empty /** * Computes the shared value, or returns the cached value if it has already been computed. @@ -94,13 +102,13 @@ class Shared internal constructor( var fromHere: Boolean var elapsedTime: Duration? = null lock.withLock { - if (result is Option.Empty) { + if (result === Empty) { elapsedTime = measureTime { try { - result = Option.Present(block()) + result = block() } catch (e: Exception) { currentCoroutineContext().ensureActive() - throw AssertionError("An exception was thrown while computing the shared value ‘$name’", e) + result = Failed(AssertionError("An exception was thrown while computing the shared value ‘$name’", e)) } } fromHere = true @@ -110,18 +118,31 @@ class Shared internal constructor( } val stored = result - check(stored is Option.Present) { "The stored result is $stored, even though we just passed the block that is expected to initialize it, that should be impossible" } - println("» Shared ‘${name}’: ${display.display(stored.value)} " + if (fromHere) "(initialized by this test in $elapsedTime)" else "(reusing an already initialized value)") - return stored.value - } + check(stored !== Empty) { "The stored result is $stored, even though we just passed the block that is expected to initialize it, that should be impossible" } + println(buildString { + append("» Shared ‘${name}’: ") - // Implementation detail to avoid null merging, - // because we need to store a T?, but T may itself be null - private sealed class Option { - data object Empty : Option() - data class Present(val value: T) : Option() + if (stored is Failed) + append("Failed with ${stored.failure}") + else + @Suppress("UNCHECKED_CAST") // See 'result' + append(display.display(stored as T)) + + if (fromHere) + append(" (initialized by this test in $elapsedTime)") + else + append(" (reusing an already initialized value)") + }) + + if (stored is Failed) throw stored.failure + + @Suppress("UNCHECKED_CAST") // See 'result' + return stored as T } + private object Empty // Necessary to ensure we don't confuse 'not initialized' with 'successfully initialized with a value of null' + private class Failed(val failure: Throwable) + override fun toString() = "\uD83C\uDF10 $name" } diff --git a/suite/src/commonTest/kotlin/FixtureTest.kt b/suite/src/commonTest/kotlin/FixtureTest.kt index 3ff2651..7b2bc03 100644 --- a/suite/src/commonTest/kotlin/FixtureTest.kt +++ b/suite/src/commonTest/kotlin/FixtureTest.kt @@ -65,4 +65,20 @@ val FixtureTest by preparedSuite { val cause = e.rootCause() check(cause is IllegalStateException && cause.message == "foo") } + + test("A failed shared value should always return the same exception instance") { + val willThrow by shared { + throw IllegalStateException("foo") + } + + val a = checkThrows { + willThrow() + } + + val b = checkThrows { + willThrow() + } + + check(a === b) + } } -- 2.51.2