diff --git a/compat/compat-arrow/src/commonMain/kotlin/Marker.kt b/compat/compat-arrow/src/commonMain/kotlin/Marker.kt new file mode 100644 index 0000000..971b9f3 --- /dev/null +++ b/compat/compat-arrow/src/commonMain/kotlin/Marker.kt @@ -0,0 +1,17 @@ +/* + * 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.compat.arrow diff --git a/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt b/compat/compat-arrow/src/commonMain/kotlin/core/EnsureRaises.kt similarity index 100% rename from compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt rename to compat/compat-arrow/src/commonMain/kotlin/core/EnsureRaises.kt diff --git a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt b/compat/compat-arrow/src/commonMain/kotlin/core/FailOnRaise.kt similarity index 100% rename from compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt rename to compat/compat-arrow/src/commonMain/kotlin/core/FailOnRaise.kt diff --git a/compat/compat-arrow/src/commonTest/kotlin/EnsureRaisesTest.kt b/compat/compat-arrow/src/commonTest/kotlin/EnsureRaisesTest.kt index bc7974e..1555eea 100644 --- a/compat/compat-arrow/src/commonTest/kotlin/EnsureRaisesTest.kt +++ b/compat/compat-arrow/src/commonTest/kotlin/EnsureRaisesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-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. @@ -14,8 +14,11 @@ * limitations under the License. */ -package opensavvy.prepared.compat.arrow.core +package opensavvy.prepared.compat.arrow +import opensavvy.prepared.compat.arrow.core.assertRaises +import opensavvy.prepared.compat.arrow.core.assertRaisesWith +import opensavvy.prepared.compat.arrow.core.checkRaises import opensavvy.prepared.runner.testballoon.preparedSuite import opensavvy.prepared.suite.assertions.checkThrows import opensavvy.prepared.suite.assertions.matches diff --git a/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt b/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt index 8e3e7ae..df67b76 100644 --- a/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt +++ b/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt @@ -1,6 +1,23 @@ -package opensavvy.prepared.compat.arrow.core +/* + * 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.compat.arrow import arrow.core.raise.ExperimentalTraceApi +import opensavvy.prepared.compat.arrow.core.failOnRaise import opensavvy.prepared.runner.testballoon.preparedSuite @Suppress("unused") -- 2.51.2 From 568ced80fbf4c606e55a588875b95f3726bee7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Mar 2026 22:31:28 +0100 Subject: [PATCH 2/4] feat(compat-arrow): Add compatibility with ResourceScope --- compat/compat-arrow/build.gradle.kts | 1 + .../commonMain/kotlin/coroutines/Resource.kt | 214 ++++++++++++++++++ .../src/commonTest/kotlin/ResourceTest.kt | 76 +++++++ gradle/libs.versions.toml | 1 + 4 files changed, 292 insertions(+) create mode 100644 compat/compat-arrow/src/commonMain/kotlin/coroutines/Resource.kt create mode 100644 compat/compat-arrow/src/commonTest/kotlin/ResourceTest.kt diff --git a/compat/compat-arrow/build.gradle.kts b/compat/compat-arrow/build.gradle.kts index 05b505b..a3be604 100644 --- a/compat/compat-arrow/build.gradle.kts +++ b/compat/compat-arrow/build.gradle.kts @@ -56,6 +56,7 @@ kotlin { dependencies { api(projects.suite) api(libs.arrow.core) + api(libs.arrow.coroutines) } } diff --git a/compat/compat-arrow/src/commonMain/kotlin/coroutines/Resource.kt b/compat/compat-arrow/src/commonMain/kotlin/coroutines/Resource.kt new file mode 100644 index 0000000..e887628 --- /dev/null +++ b/compat/compat-arrow/src/commonMain/kotlin/coroutines/Resource.kt @@ -0,0 +1,214 @@ +/* + * 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.compat.arrow.coroutines + +import arrow.fx.coroutines.ExitCase +import arrow.fx.coroutines.ExitCase.Companion.ExitCase +import arrow.fx.coroutines.Resource +import arrow.fx.coroutines.ResourceDSL +import arrow.fx.coroutines.ResourceScope +import kotlinx.coroutines.Dispatchers +import opensavvy.prepared.suite.* + +/** + * A scope to combine Arrow's [ResourceScope] with Prepared's [TestDsl]. + * + * This scope is used within tests that declare resources. + * + * Resources declared in this scope are released at the end of the test. + * + * Instances of this type can be obtained: + * - As a test fixture with [preparedResource] + * - Directly, with [resourceScope] (useful for passing the test lifecycle to a component) + * + * Additionally: + * - The [install] functions allows binding a resource directly within a test. + * - The [asPrepared] function converts a [Resource] into a [Prepared] value, Prepared's equivalent concept. + */ +@ResourceDSL +interface TestResourceScope : ResourceScope, TestDsl + +/** + * Declares a [prepared value][Prepared] that contains Arrow Resources. + * + * The resources are released at the end of the test. + * + * ### Example + * + * ```kotlin + * val userProcessor: Resource = resource({ + * UserProcessor().also { it.start() } + * }) { p, _ -> p.shutdown() } + * + * val dataSource: Resource = resource({ + * DataSource().also { it.connect() } + * }) { ds, exitCase -> + * println("Releasing $ds with exit: $exitCase") + * withContext(Dispatchers.IO) { ds.close() } + * } + * + * // Note the 'by'! + * val service by preparedResource { + * Service(dataSource.bind(), userProcessor.bind()) + * } + * + * test("Some kind of test") { + * service().createEntity("…") + * } + * ``` + * + * ### External resources + * + * - [Arrow Resource documentation](https://arrow-kt.io/learn/coroutines/resource-safety/) + * + * @see TestResourceScope Overview of Prepared and Arrow's compatibility. + * @see asPrepared Convert a [Resource] into a [Prepared] value. + * @see install Install a [Resource] directly within a test. + */ +fun preparedResource(block: suspend TestResourceScope.() -> T): PreparedProvider = prepared { + resourceScope().block() +} + +/** + * Converts an Arrow [Resource] into a [Prepared] value. + * + * Prepared's prepared values are similar in concept to resources: + * - They both are lazy (declaring them and executing them happens in two different steps) + * - They both contain cleanup logic + * + * The resulting prepared value executes the clean-up logic at the end of the test. + * + * ### Example + * + * ```kotlin + * val userProcessor: Resource = resource({ + * UserProcessor().also { it.start() } + * }) { p, _ -> p.shutdown() } + * + * val dataSource: Resource = resource({ + * DataSource().also { it.connect() } + * }) { ds, exitCase -> + * println("Releasing $ds with exit: $exitCase") + * withContext(Dispatchers.IO) { ds.close() } + * } + * + * val service: Resource = resource { + * Service(dataSource.bind(), userProcessor.bind()) + * } + * + * // Note the 'by'! + * val prepareService by service.asPrepared() + * + * test("Test the creation") { + * check(prepareService().create("…") != null) + * } + * ``` + * + * At the end of the test, `service`, `userProcessor` and `dataSource` are cleaned-up (in this order). + * + * ### External resources + * + * - [Arrow Resource documentation](https://arrow-kt.io/learn/coroutines/resource-safety/) + * + * @see TestResourceScope Overview of Prepared and Arrow's compatibility. + * @see preparedResource Access a [ResourceScope] to declare multiple resources as a single [Prepared] value. + * @see install Install a [Resource] directly within a test. + */ +fun Resource.asPrepared() = preparedResource { + this@asPrepared.bind() +} + +/** + * Allows accessing the test lifecycle as an Arrow's [ResourceScope]. + * + * Resources installed in the returned scope are released at the end of the test. + * + * ### External resources + * + * - [Arrow Resource documentation](https://arrow-kt.io/learn/coroutines/resource-safety/) + * + * @see TestResourceScope Overview of Prepared and Arrow's compatibility. + * @see preparedResource Install multiple resources into a [Prepared] value. + * @see asPrepared Convert a [Resource] into a [Prepared] value. + * @see install Install a [Resource] directly within a test. + */ +fun TestDsl.resourceScope(): TestResourceScope = + TestResourceScopeImpl(this) + +/** + * Installs an [acquire] action and its matching [release] action. + * + * This is a convenience equivalent of [ResourceScope.install] directly on the test. + * The behavior is identical to calling [ResourceScope.install] on the test's [resourceScope]. + * + * ### External resources + * + * - [Arrow Resource documentation](https://arrow-kt.io/learn/coroutines/resource-safety/) + * + * @param acquire The acquire action, executed immediately. + * @param release The release action, executed at the end of the test. + * @see TestResourceScope Overview of Prepared and Arrow's compatibility. + * @see preparedResource Install multiple resources into a [Prepared] value. + * @see asPrepared Convert a [Resource] into a [Prepared] value. + */ +suspend fun TestDsl.install( + acquire: suspend TestDsl.() -> A, + release: suspend TestDsl.(A, ExitCase) -> Unit, +): A = + resourceScope().install( + acquire = { acquire() }, + release = { a, case -> release(a, case) }, + ) + +/** + * Installs a [Resource] into the test's lifecycle scope. + * + * The resource is immediately installed. + * It is released at the end of the test. + * + * This method has identical behavior to [ResourceScope.bind] on the test's [resourceScope]. + * It cannot be called `.bind()` because this would require two receivers, and context parameters are not yet stable. + * + * ### External resources + * + * - [Arrow Resource documentation](https://arrow-kt.io/learn/coroutines/resource-safety/) + * + * @see TestResourceScope Overview of Prepared and Arrow's compatibility. + * @see preparedResource Install multiple resources into a [Prepared] value. + * @see asPrepared Convert a [Resource] into a [Prepared] value. + */ +suspend fun TestDsl.install( + resource: Resource, +): A = with(resourceScope()) { + resource.bind() +} + +private class TestResourceScopeImpl( + private val test: TestDsl, +) : TestResourceScope, ResourceScope, TestDsl by test { + override fun onRelease(release: suspend (ExitCase) -> Unit): Unit = with(test) { + launch(Dispatchers.Unconfined) { + cleanUp("Release resource on success", onFailure = false) { + release(ExitCase.Completed) + } + + cleanUp("Release resource on failure", onSuccess = false) { + release(ExitCase(RuntimeException("The real exception which caused the test to fail is unknown"))) + } + } + } +} diff --git a/compat/compat-arrow/src/commonTest/kotlin/ResourceTest.kt b/compat/compat-arrow/src/commonTest/kotlin/ResourceTest.kt new file mode 100644 index 0000000..6abc744 --- /dev/null +++ b/compat/compat-arrow/src/commonTest/kotlin/ResourceTest.kt @@ -0,0 +1,76 @@ +/* + * 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.compat.arrow + +import arrow.fx.coroutines.resource +import kotlinx.coroutines.delay +import opensavvy.prepared.compat.arrow.coroutines.asPrepared +import opensavvy.prepared.compat.arrow.coroutines.install +import opensavvy.prepared.compat.arrow.coroutines.preparedResource +import opensavvy.prepared.runner.testballoon.preparedSuite +import kotlin.time.Duration.Companion.milliseconds + +val ResourceTest by preparedSuite { + + test("Install during test") { + var acquired = false + var released = false + var testEnded = false + + install( + acquire = { acquired = true }, + release = { _, e -> + released = true + check(testEnded) { "The resource should only be released when the test ends, after all assertions" } + }, + ) + + check(acquired) { "The resource should be acquired immediately" } + check(!released) { "The resource should only be released when the test ends, after all assertions" } + testEnded = true + } + + val integerResource = resource({ + delay(100.milliseconds) + 42 + }) { _, _ -> } + + test("Install an existing resource") { + val resource = install(integerResource) + + check(resource == 42) + } + + val prepareIntegerResource by integerResource.asPrepared() + + test("Convert an existing resource into a Prepared value") { + val resource = prepareIntegerResource() + + check(resource == 42) + } + + val prepareResources by preparedResource { + integerResource.bind() + } + + test("Convert an existing resource into a Prepared value") { + val resource = prepareResources() + + check(resource == 42) + } + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88be71e..8eb34db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ kotlinx-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } gradle-testkit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = "gradle-testkit" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } +arrow-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } parameterize = { module = "com.benwoodworth.parameterize:parameterize-core", version.ref = "parameterize" } ktor-testHost = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } kotest-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } -- 2.51.2 From b6aeaac96c4b129c9b5cf82ab23c20914184374e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Mar 2026 22:42:22 +0100 Subject: [PATCH 3/4] docs(compat-arrow): Include resources in the module-level documentation --- compat/compat-arrow/README.md | 39 +++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/compat/compat-arrow/README.md b/compat/compat-arrow/README.md index f62b930..318c908 100644 --- a/compat/compat-arrow/README.md +++ b/compat/compat-arrow/README.md @@ -1,10 +1,12 @@ # Module Compatibility with Arrow -Helpers to fail tests when a function raises. +Helpers to fail tests when a function raises and to use resources. -### Example +# Package opensavvy.prepared.compat.arrow.core + +Helpers to integrate Arrow's Raise DSL into tests. Let's assume we want to test a function which raises when it receives a negative number: @@ -43,3 +45,36 @@ test("√-1 raises") { } } ``` + +# Package opensavvy.prepared.compat.arrow.coroutines + +Helpers to use Arrow's Resource DSL in tests. + +Let's assume that we want to test a service we own that is already encapsulated in a resource within your production code: + +```kotlin +val userProcessor: Resource = resource({ + UserProcessor().also { it.start() } +}) { p, _ -> p.shutdown() } + +val dataSource: Resource = resource({ + DataSource().also { it.connect() } +}) { ds, exitCase -> + println("Releasing $ds with exit: $exitCase") + withContext(Dispatchers.IO) { ds.close() } +} + +val service: Resource = resource { + Service(dataSource.bind(), userProcessor.bind()) +} +``` + +We can use [asPrepared][opensavvy.prepared.compat.arrow.coroutines.asPrepared] to convert that resource into Prepared's native [prepared values][opensavvy.prepared.suite.Prepared]: + +```kotlin +val preparedService by service.asPrepared() + +test("create() should not return null") { + check(preparedService().create() != null) +} +``` -- 2.51.2 From 414e2b870acf70cdba2ee54d039ad19e0411e72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 7 Mar 2026 23:11:29 +0100 Subject: [PATCH 4/4] docs(website): Create the Arrow Resource compatibility page --- .../docs/features/compat-arrow-resources.md | 101 ++++++++++++++++++ docs/website/mkdocs.yml | 7 +- 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 docs/website/docs/features/compat-arrow-resources.md diff --git a/docs/website/docs/features/compat-arrow-resources.md b/docs/website/docs/features/compat-arrow-resources.md new file mode 100644 index 0000000..da7cbff --- /dev/null +++ b/docs/website/docs/features/compat-arrow-resources.md @@ -0,0 +1,101 @@ +# Use Arrow resources in your Kotlin tests + +[Arrow resources](https://arrow-kt.io/learn/coroutines/resource-safety/) are a powerful way to encapsulate the declaration of entities along with the logic required to safely close them. + +Resources can be viewed as an extension of the `AutoCloseable` interface, into a complete composable DSL. + +!!! info "Configuration" +Add a dependency on `dev.opensavvy.prepared:compat-arrow` to use the features on this page. + + See the [reference](https://prepared.opensavvy.dev/api-docs/compat/compat-arrow/index.html). + +!!! info +The examples on this page use the [Power Assert assertion library](../tutorials/index.md#assertion-libraries). + +Arrow resources are conceptually similar to [prepared values](prepared-values.md): + +- Both types represent a value computed in a different step than its declaration. Resources are declared then bound within a `ResourceScope`; prepared values are declared then invoked within a test. +- Both types include information on how to release them. Resources have a `release` action; prepared values can register a [finalizer](finalizers.md). + +The Prepared library provides multiple ways to bind Arrow resources to tests. In each case, the resource is initialized once for each test (different tests get different instances), and the release action runs at the end of the test. + +!!! note +Note that although Arrow resources' `release` actions gets an `ExitCase`, Prepared currently only distinguishes between success and failure, and doesn't provide the exact exceptions when a failure happens. If this would be useful to you, [please tell us](https://gitlab.com/opensavvy/groundwork/prepared/-/issues/92). + +## Using a resource as a prepared value + +We will use the example from the [Resource documentation](https://apidocs.arrow-kt.io/arrow-fx-coroutines/arrow.fx.coroutines/-resource/index.html): + +```kotlin +val userProcessor: Resource = resource({ + UserProcessor().also { it.start() } +}) { p, _ -> p.shutdown() } + +val dataSource: Resource = resource({ + DataSource().also { it.connect() } +}) { ds, exitCase -> + println("Releasing $ds with exit: $exitCase") + withContext(Dispatchers.IO) { ds.close() } +} + +val service: Resource = resource { + Service(dataSource.bind(), userProcessor.bind()) +} +``` + +If we have the code above in our production module, there are multiple ways to use it in a test. +The first and simplest way is to use the `asPrepared` conversion function: + +```kotlin +val prepareService by service.asPrepared() + +test("create() does not return null") { + checkNotNull(prepareService().create("…")) +} +``` + +If you only had `userProcessor` and `dataSource` in your production code, you could directly create the `service` as a prepared value: + +```kotlin +val service by preparedResource { + Service(dataSource.bind(), userProcessor.bind()) +} +``` + +The `preparedResource` behaves identically to the [built-in `prepared` function](prepared-values.md), but also provides the capabilities of Arrow's `ResourceScope`. + +## Using a resource in a test + +Wrapping a resource in a [prepared value](prepared-values.md) is a good pattern, because resources and prepared values are conceptually similar, so we can use them in tests similarly to how we use them in production. + +If you want to use a resource in a specific test, you can directly install it: + +```kotlin +test("create() does not return null") { + val s = install(service) + + checkNotNull(s.create("…")) +} +``` + +You can also use `install` to declare the acquire and release actions separately: + +```kotlin +test("create() does not return null") { + val db = install({ + DataSource().also { it.connect() } + }) { ds.close() } + + // … +} +``` + +## Injecting the test's lifecycle into the production code + +If your production system needs to know the lifecycle of its caller, you can use `resourceScope()` within a test or within a [prepared value](prepared-values.md): + +```kotlin +test("Launch the system") { + YourApp(resourceScope()).launch() +} +``` diff --git a/docs/website/mkdocs.yml b/docs/website/mkdocs.yml index 814cc07..4cade1b 100644 --- a/docs/website/mkdocs.yml +++ b/docs/website/mkdocs.yml @@ -127,9 +127,10 @@ nav: - features/files.md - Integrations: - - features/compat-ktor.md - - features/compat-arrow.md - - features/compat-gradle.md + - Ktor: features/compat-ktor.md + - Arrow Typed Errors: features/compat-arrow.md + - Arrow Resources: features/compat-arrow-resources.md + - Gradle: features/compat-gradle.md # - Best practices: # - practices/index.md