From d4fc3a844066ae64362bfaa285e69bdbcf00258e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Sep 2023 12:36:12 +0200 Subject: [PATCH 01/62] build: Initial commit --- .gitlab-ci.yml | 50 ------------------------ .idea/.name | 2 +- .idea/runConfigurations/Run.xml | 24 ------------ README.md | 32 ++++++++++++--- app/Dockerfile | 16 -------- app/build.gradle.kts | 29 -------------- app/src/commonMain/kotlin/Main.kt | 7 ---- core/src/commonMain/kotlin/HelloWorld.kt | 3 -- settings.gradle.kts | 4 +- 9 files changed, 29 insertions(+), 138 deletions(-) delete mode 100644 .idea/runConfigurations/Run.xml delete mode 100644 app/Dockerfile delete mode 100644 app/build.gradle.kts delete mode 100644 app/src/commonMain/kotlin/Main.kt delete mode 100644 core/src/commonMain/kotlin/HelloWorld.kt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a562f88..bfdb424 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -73,56 +73,6 @@ publish-maven:central: when: manual - if: $CI_COMMIT_TAG -# endregion -# region Build & containerize - -app:build: - extends: [ .os.gradle ] - needs: [ os.version ] - stage: build - - script: - - > - ./gradlew app:distTar - -PappVersion=$project_version - - mv app/build/distributions/app-*.tar app.tar - - artifacts: - paths: - - app.tar - - interruptible: true - -app:container: - extends: [ .os.docker.build ] - needs: - - job: app:build - artifacts: true - stage: build - - variables: - dockerfile: app/Dockerfile - docker_context: app - image: app - - interruptible: true - -app:container:publish: - extends: [ .os.docker.rename ] - needs: [ app:container, os.version ] - stage: deploy - - variables: - image: app - - rules: - - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH - - if: $CI_COMMIT_TAG - variables: - new_version: $project_version - - interruptible: false - # endregion # region Automatically mirror the previous layer of the playground diff --git a/.idea/.name b/.idea/.name index accd5c2..c565f42 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -Playground \ No newline at end of file +Prepared diff --git a/.idea/runConfigurations/Run.xml b/.idea/runConfigurations/Run.xml deleted file mode 100644 index 4fe8d2e..0000000 --- a/.idea/runConfigurations/Run.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - true - true - false - false - - - \ No newline at end of file diff --git a/README.md b/README.md index 16cca47..4cff698 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,32 @@ -# Playground: Gradle +# OpenSavvy Prepared - -This a template to easily bootstrap your projects. +> This project is experimental. -> This project is part of the OpenSavvy Playground. -> To learn more, visit [this page](docs/playground/README.md). +Testing libraries are composed of three different components: +- The **assertions** check that values are what we expect, +- The **structure** is the way tests are declared and discovered, +- The **runner** is the program that executes the tests. + +OpenSavvy Prepared is a **structure** library: it concentrates on the way tests are declared. +It is possible to use other libraries (e.g. [Kotlin Test](https://kotlinlang.org/api/latest/kotlin.test/), [Kotest](https://kotest.io/), [Strikt](https://strikt.io/)…) to declare assertions. + +[_TOC_] + +## Features + +- Lazy fixtures: Fixtures are executed lazily as they are needed, once per test +- Explicit fixtures +- Time is fixed during test execution +- Easy parametrization +- Coroutine-aware +- Multiplatform + +## Project structure + +- `suite`: Utilities to declare tests and describe how they should run +- `framework`: Default configuration of all modules for ease of use +- `runners`: Compatibility layer for test runners +- `compat`: Compatibility layer for other libraries ## License diff --git a/app/Dockerfile b/app/Dockerfile deleted file mode 100644 index 3eb4167..0000000 --- a/app/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM alpine:latest as builder - -COPY app.tar /home/server/app.tar -WORKDIR /home/server -RUN tar -xf app.tar -RUN mkdir -p extracted -RUN mv app-*/* extracted - -FROM alpine:latest - -RUN apk add --no-cache openjdk17-jre-headless -COPY --from=builder /home/server/extracted /opt/app - -WORKDIR /opt/app -EXPOSE 9000 -ENTRYPOINT [ "/opt/app/bin/app" ] diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index f446ed5..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,29 +0,0 @@ -import java.awt.Color.red - -plugins { - id("conventions.base") - id("conventions.kotlin") - application -} - -kotlin { - jvm { - withJava() // required by the application plugin - } - - val commonMain by sourceSets.getting { - dependencies { - implementation(projects.core) - } - } - - val commonTest by sourceSets.getting { - dependencies { - implementation(libs.kotlin.test) - } - } -} - -application { - mainClass.set("opensavvy.playground.app.MainKt") -} diff --git a/app/src/commonMain/kotlin/Main.kt b/app/src/commonMain/kotlin/Main.kt deleted file mode 100644 index 596815a..0000000 --- a/app/src/commonMain/kotlin/Main.kt +++ /dev/null @@ -1,7 +0,0 @@ -package opensavvy.playground.app - -import opensavvy.playground.core.message - -fun main() { - println(message) -} diff --git a/core/src/commonMain/kotlin/HelloWorld.kt b/core/src/commonMain/kotlin/HelloWorld.kt deleted file mode 100644 index b87d0d9..0000000 --- a/core/src/commonMain/kotlin/HelloWorld.kt +++ /dev/null @@ -1,3 +0,0 @@ -package opensavvy.playground.core - -const val message = "Hello World!" diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d14854..0bb2722 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,7 +7,7 @@ * in the user manual at https://docs.gradle.org/8.1.1/userguide/multi_project_builds.html */ -rootProject.name = "Playground" +rootProject.name = "Prepared" pluginManagement { repositories { @@ -19,8 +19,6 @@ pluginManagement { } include( - "app", - "core", ) buildCache { -- 2.51.2 From 284673eb6b455ff6f99fc45879d377bc54d871a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Sep 2023 12:47:41 +0200 Subject: [PATCH 02/62] feat(suite): Basic structure --- app/src/commonTest/kotlin/Test.kt | 12 ------ build.gradle.kts | 4 ++ core/build.gradle.kts | 9 ----- framework/build.gradle.kts | 20 ++++++++++ gradle/libs.versions.toml | 3 ++ settings.gradle.kts | 2 + suite/build.gradle.kts | 27 +++++++++++++ suite/src/commonMain/kotlin/Suite.kt | 58 ++++++++++++++++++++++++++++ 8 files changed, 114 insertions(+), 21 deletions(-) delete mode 100644 app/src/commonTest/kotlin/Test.kt delete mode 100644 core/build.gradle.kts create mode 100644 framework/build.gradle.kts create mode 100644 suite/build.gradle.kts create mode 100644 suite/src/commonMain/kotlin/Suite.kt diff --git a/app/src/commonTest/kotlin/Test.kt b/app/src/commonTest/kotlin/Test.kt deleted file mode 100644 index 2597e32..0000000 --- a/app/src/commonTest/kotlin/Test.kt +++ /dev/null @@ -1,12 +0,0 @@ -package opensavvy.playground - -import kotlin.test.Test -import kotlin.test.assertTrue - -class Test { - - @Test - fun helloWorld() { - assertTrue(true) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 9bec650..c0895d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,3 +12,7 @@ plugins { // In these cases, we explicitly tell Gradle not to apply them. alias(libs.plugins.kotlin) apply false } + +repositories { + mavenCentral() +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts deleted file mode 100644 index d847522..0000000 --- a/core/build.gradle.kts +++ /dev/null @@ -1,9 +0,0 @@ -plugins { - id("conventions.base") - id("conventions.kotlin") - id("conventions.library") -} - -kotlin { - jvm() -} diff --git a/framework/build.gradle.kts b/framework/build.gradle.kts new file mode 100644 index 0000000..a653914 --- /dev/null +++ b/framework/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + js { + nodejs() + browser() + } + linuxX64() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fff259c..0f90330 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,14 @@ [versions] kotlin = "1.9.0" +kotlinx-coroutines = "1.7.3" [plugins] kotlin = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } # To use existing plugins in convention plugins, we must also declare them as libraries gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0bb2722..45b7b9a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,8 @@ pluginManagement { } include( + "suite", + "framework", ) buildCache { diff --git a/suite/build.gradle.kts b/suite/build.gradle.kts new file mode 100644 index 0000000..2c39e2d --- /dev/null +++ b/suite/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + js { + nodejs() + browser() + } + linuxX64() + + val commonMain by sourceSets.getting { + dependencies { + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.coroutines.test) + } + } + + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + } + } +} diff --git a/suite/src/commonMain/kotlin/Suite.kt b/suite/src/commonMain/kotlin/Suite.kt new file mode 100644 index 0000000..889b94b --- /dev/null +++ b/suite/src/commonMain/kotlin/Suite.kt @@ -0,0 +1,58 @@ +package opensavvy.prepared.suite + +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +@DslMarker +annotation class PreparedDsl + +@PreparedDsl +interface PreparedScope + +/** + * A group of tests. + * + * ### Example + * + * ```kotlin + * suite("An example") { + * test("A test") { + * println("Execution") + * } + * + * suite("A group of tests") { + * test("First test") { + * println("Execution") + * } + * + * text("Second test") { + * println("Execution") + * } + * } + * } + * ``` + */ +interface Suite : PreparedScope { + + /** + * Creates a child suite named [name] of the current suite. + */ + @PreparedDsl + fun suite( + name: String, + block: Suite.() -> Unit, + ) + + /** + * Declares a test named [name] as part of the current suite. + */ + @PreparedDsl + fun test( + name: String, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend Test.() -> Unit, + ) + +} + +interface Test : PreparedScope -- 2.51.2 From cbc7e31c3ab2b096ebcb8c408c2abae2817ce9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 3 Sep 2023 15:08:14 +0200 Subject: [PATCH 03/62] feat(kotlin-test): Implement a runner based on kotlin-test --- gradle/libs.versions.toml | 5 +- runners/runner-kotlin-test/build.gradle.kts | 52 +++++++++++++++++++ .../src/commonMain/kotlin/TestExecutor.kt | 36 +++++++++++++ .../src/commonTest/kotlin/ExecuteTest.kt | 23 ++++++++ .../src/commonTest/kotlin/VanillaTest.kt | 12 +++++ .../src/jsMain/kotlin/JsTestExecutor.kt | 44 ++++++++++++++++ .../src/jvmMain/kotlin/JvmTestExecutor.kt | 42 +++++++++++++++ settings.gradle.kts | 2 + 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 runners/runner-kotlin-test/build.gradle.kts create mode 100644 runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt create mode 100644 runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt create mode 100644 runners/runner-kotlin-test/src/commonTest/kotlin/VanillaTest.kt create mode 100644 runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt create mode 100644 runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f90330..2696542 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,10 @@ kotlinx-coroutines = "1.7.3" kotlin = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } [libraries] -kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } +kotlin-test-annotations = { module = "org.jetbrains.kotlin:kotlin-test-annotations-common", version.ref = "kotlin" } +kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" } +kotlin-test-js = { module = "org.jetbrains.kotlin:kotlin-test-js", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } diff --git a/runners/runner-kotlin-test/build.gradle.kts b/runners/runner-kotlin-test/build.gradle.kts new file mode 100644 index 0000000..d2def9b --- /dev/null +++ b/runners/runner-kotlin-test/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + js { + nodejs() + browser { + testTask { + useMocha { + timeout = "1 minute" + } + } + } + } + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + + implementation(libs.kotlin.test.common) + implementation(libs.kotlin.test.annotations) + } + } + + val jvmMain by sourceSets.getting { + dependencies { + implementation(libs.kotlin.test.junit5) + } + } + + val jsMain by sourceSets.getting { + dependencies { + implementation(libs.kotlin.test.js) + } + } +} + +tasks.withType(Test::class) { + testLogging { + events("skipped", "failed", "passed") + } +} diff --git a/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt new file mode 100644 index 0000000..8adefd0 --- /dev/null +++ b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt @@ -0,0 +1,36 @@ +package opensavvy.prepared.runner.kotlin + +import opensavvy.prepared.suite.Suite + +/** + * Entrypoint to declare a [Suite] executed with [kotlin-test](https://kotlinlang.org/api/latest/kotlin.test/). + * + * Because `kotlin-test` doesn't provide a way to dynamically instantiate tests, we have to cheat. + * This class abstracts away our hacks to make it work. + * Please vote on [KT-46899](https://youtrack.jetbrains.com/issue/KT-46899). + * + * Because of these hacks, implementing this class has strange requirements: + * - an implementation class's name must contain `Test` + * + * ### Example + * + * ```kotlin + * class ExecuteTest : TestExecutor() { + * override fun Suite.register() { + * test("This is a test") { + * println("Hello world!") + * } + * } + * } + * ``` + */ +expect abstract class TestExecutor() { + + /** + * Declares a [Suite] which will be run with `kotlin-test`. + * + * For more information, see the [class-level documentation][TestExecutor]. + */ + abstract fun Suite.register() + +} diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt new file mode 100644 index 0000000..23e42f0 --- /dev/null +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -0,0 +1,23 @@ +package opensavvy.prepared.runner.kotlin + +import opensavvy.prepared.suite.Suite + +@Suppress("unused") +class ExecuteTest : TestExecutor() { + override fun Suite.register() { + test("A simple test") { + println("This test is declared with the Prepared syntax") + } + + suite("Group of tests") { + test("Test 1") { + println("It executes") + println("Other line") + } + + test("Test 2") { + println("It also executes") + } + } + } +} diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/VanillaTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/VanillaTest.kt new file mode 100644 index 0000000..d89baee --- /dev/null +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/VanillaTest.kt @@ -0,0 +1,12 @@ +package opensavvy.prepared.runner.kotlin + +import kotlin.test.Test + +class VanillaTest { + + @Test + fun vanilla() { + println("This is a regular kotlin-test test") + } + +} diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt new file mode 100644 index 0000000..19d2518 --- /dev/null +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -0,0 +1,44 @@ +package opensavvy.prepared.runner.kotlin + +import kotlinx.coroutines.test.runTest +import opensavvy.prepared.suite.Suite +import opensavvy.prepared.suite.Test +import kotlin.coroutines.CoroutineContext + +// access the internals of kotlin-test, let's hope they don't change in the future :) +// see https://github.com/JetBrains/kotlin/blob/master/libraries/kotlin.test/js/src/main/kotlin/kotlin/test/TestApi.kt +@JsModule("kotlin-test") +@JsNonModule +private external val kTest: dynamic + +actual abstract class TestExecutor { + + actual abstract fun Suite.register() + + // this test shows up as an empty test that always succeeds in reports, + // but we need it for the class to be discovered + @kotlin.test.Test + fun registerTests() { + kTest.kotlin.test.suite("Class ${this::class.simpleName}", false) { + JsSuite.register() + } + } +} + +private object JsSuite : Suite { + override fun suite(name: String, block: Suite.() -> Unit) { + println("Registering suite '$name'…") + kTest.kotlin.test.suite(name, false) { + this.block() + } + } + + override fun test(name: String, context: CoroutineContext, block: suspend Test.() -> Unit) { + println("Registering test '$name'…") + kTest.kotlin.test.test(name, false) { + runTest(context) { block(JsTest) } + } + } +} + +private object JsTest : Test diff --git a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt new file mode 100644 index 0000000..8efe719 --- /dev/null +++ b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt @@ -0,0 +1,42 @@ +package opensavvy.prepared.runner.kotlin + +import kotlinx.coroutines.test.runTest +import opensavvy.prepared.suite.Suite +import opensavvy.prepared.suite.Test +import org.junit.jupiter.api.DynamicContainer +import org.junit.jupiter.api.DynamicNode +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import java.util.stream.Stream +import kotlin.coroutines.CoroutineContext + +actual abstract class TestExecutor { + + actual abstract fun Suite.register() + + @TestFactory + fun suite(): Stream { + val suite = JvmSuite().apply { register() } + + return suite.nodes.stream() + } + +} + +private class JvmSuite : Suite { + val nodes = ArrayList() + + override fun suite(name: String, block: Suite.() -> Unit) { + val child = JvmSuite().apply(block) + + nodes += DynamicContainer.dynamicContainer(name, child.nodes) + } + + override fun test(name: String, context: CoroutineContext, block: suspend Test.() -> Unit) { + nodes += DynamicTest.dynamicTest(name) { + runTest(context) { block(JvmTest) } + } + } +} + +private object JvmTest : Test diff --git a/settings.gradle.kts b/settings.gradle.kts index 45b7b9a..03ace53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,8 @@ pluginManagement { include( "suite", "framework", + + "runners:runner-kotlin-test", ) buildCache { -- 2.51.2 From c141881f3046b57acc7bc2a5f4f23d9396df697d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 6 Sep 2023 15:10:01 +0200 Subject: [PATCH 04/62] docs: Fix the table of contents in the README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cff698..c262ad6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Testing libraries are composed of three different components: OpenSavvy Prepared is a **structure** library: it concentrates on the way tests are declared. It is possible to use other libraries (e.g. [Kotlin Test](https://kotlinlang.org/api/latest/kotlin.test/), [Kotest](https://kotest.io/), [Strikt](https://strikt.io/)…) to declare assertions. -[_TOC_] +[TOC] ## Features -- 2.51.2 From 05360514ae5e9babd198e9933ae77e9f018d48eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 6 Sep 2023 15:11:05 +0200 Subject: [PATCH 05/62] build(idea): IDEA doesn't like the end-of-line EoL in the .name file --- .idea/.name | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.idea/.name b/.idea/.name index c565f42..cfaa02c 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -Prepared +Prepared \ No newline at end of file -- 2.51.2 From 205cf0bf01a39f491cd242dfb67bc1b81778df15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 6 Sep 2023 15:14:51 +0200 Subject: [PATCH 06/62] feat(framework): Avoid having an empty module --- framework/src/commonMain/kotlin/Marker.kt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 framework/src/commonMain/kotlin/Marker.kt diff --git a/framework/src/commonMain/kotlin/Marker.kt b/framework/src/commonMain/kotlin/Marker.kt new file mode 100644 index 0000000..a0859b5 --- /dev/null +++ b/framework/src/commonMain/kotlin/Marker.kt @@ -0,0 +1,6 @@ +package opensavvy.prepared.framework + +// Kotlin/Native doesn't like modules with 0 lines of source code. +// This is just here so the file isn't empty. +@Suppress("unused") +private typealias Marker = Nothing -- 2.51.2 From 1078b64b3d7c0fa14c5b6332d1110ece95ee1520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 13 Sep 2023 19:22:16 +0200 Subject: [PATCH 07/62] refactor(suite): Rename the DSL entities --- .../src/commonMain/kotlin/TestExecutor.kt | 8 ++++---- .../src/commonTest/kotlin/ExecuteTest.kt | 4 ++-- .../src/jsMain/kotlin/JsTestExecutor.kt | 18 ++++++++--------- .../src/jvmMain/kotlin/JvmTestExecutor.kt | 20 +++++++++---------- .../kotlin/{Suite.kt => SuiteDsl.kt} | 18 ++++++++--------- 5 files changed, 34 insertions(+), 34 deletions(-) rename suite/src/commonMain/kotlin/{Suite.kt => SuiteDsl.kt} (75%) diff --git a/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt index 8adefd0..dbbbe73 100644 --- a/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt +++ b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt @@ -1,9 +1,9 @@ package opensavvy.prepared.runner.kotlin -import opensavvy.prepared.suite.Suite +import opensavvy.prepared.suite.SuiteDsl /** - * Entrypoint to declare a [Suite] executed with [kotlin-test](https://kotlinlang.org/api/latest/kotlin.test/). + * Entrypoint to declare a [SuiteDsl] executed with [kotlin-test](https://kotlinlang.org/api/latest/kotlin.test/). * * Because `kotlin-test` doesn't provide a way to dynamically instantiate tests, we have to cheat. * This class abstracts away our hacks to make it work. @@ -27,10 +27,10 @@ import opensavvy.prepared.suite.Suite expect abstract class TestExecutor() { /** - * Declares a [Suite] which will be run with `kotlin-test`. + * Declares a [SuiteDsl] which will be run with `kotlin-test`. * * For more information, see the [class-level documentation][TestExecutor]. */ - abstract fun Suite.register() + abstract fun SuiteDsl.register() } diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt index 23e42f0..a814705 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -1,10 +1,10 @@ package opensavvy.prepared.runner.kotlin -import opensavvy.prepared.suite.Suite +import opensavvy.prepared.suite.SuiteDsl @Suppress("unused") class ExecuteTest : TestExecutor() { - override fun Suite.register() { + override fun SuiteDsl.register() { test("A simple test") { println("This test is declared with the Prepared syntax") } diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt index 19d2518..ab9dd88 100644 --- a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -1,8 +1,8 @@ package opensavvy.prepared.runner.kotlin import kotlinx.coroutines.test.runTest -import opensavvy.prepared.suite.Suite -import opensavvy.prepared.suite.Test +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.TestDsl import kotlin.coroutines.CoroutineContext // access the internals of kotlin-test, let's hope they don't change in the future :) @@ -13,32 +13,32 @@ private external val kTest: dynamic actual abstract class TestExecutor { - actual abstract fun Suite.register() + actual abstract fun SuiteDsl.register() // this test shows up as an empty test that always succeeds in reports, // but we need it for the class to be discovered @kotlin.test.Test fun registerTests() { kTest.kotlin.test.suite("Class ${this::class.simpleName}", false) { - JsSuite.register() + JsSuiteDsl.register() } } } -private object JsSuite : Suite { - override fun suite(name: String, block: Suite.() -> Unit) { +private object JsSuiteDsl : SuiteDsl { + override fun suite(name: String, block: SuiteDsl.() -> Unit) { println("Registering suite '$name'…") kTest.kotlin.test.suite(name, false) { this.block() } } - override fun test(name: String, context: CoroutineContext, block: suspend Test.() -> Unit) { + override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { println("Registering test '$name'…") kTest.kotlin.test.test(name, false) { - runTest(context) { block(JsTest) } + runTest(context) { block(JsTestDsl) } } } } -private object JsTest : Test +private object JsTestDsl : TestDsl diff --git a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt index 8efe719..5747206 100644 --- a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt @@ -1,8 +1,8 @@ package opensavvy.prepared.runner.kotlin import kotlinx.coroutines.test.runTest -import opensavvy.prepared.suite.Suite -import opensavvy.prepared.suite.Test +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.TestDsl import org.junit.jupiter.api.DynamicContainer import org.junit.jupiter.api.DynamicNode import org.junit.jupiter.api.DynamicTest @@ -12,31 +12,31 @@ import kotlin.coroutines.CoroutineContext actual abstract class TestExecutor { - actual abstract fun Suite.register() + actual abstract fun SuiteDsl.register() @TestFactory fun suite(): Stream { - val suite = JvmSuite().apply { register() } + val suite = JvmSuiteDsl().apply { register() } return suite.nodes.stream() } } -private class JvmSuite : Suite { +private class JvmSuiteDsl : SuiteDsl { val nodes = ArrayList() - override fun suite(name: String, block: Suite.() -> Unit) { - val child = JvmSuite().apply(block) + override fun suite(name: String, block: SuiteDsl.() -> Unit) { + val child = JvmSuiteDsl().apply(block) nodes += DynamicContainer.dynamicContainer(name, child.nodes) } - override fun test(name: String, context: CoroutineContext, block: suspend Test.() -> Unit) { + override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { nodes += DynamicTest.dynamicTest(name) { - runTest(context) { block(JvmTest) } + runTest(context) { block(JvmTestDsl) } } } } -private object JvmTest : Test +private object JvmTestDsl : TestDsl diff --git a/suite/src/commonMain/kotlin/Suite.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt similarity index 75% rename from suite/src/commonMain/kotlin/Suite.kt rename to suite/src/commonMain/kotlin/SuiteDsl.kt index 889b94b..5e5ca9d 100644 --- a/suite/src/commonMain/kotlin/Suite.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -4,10 +4,10 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @DslMarker -annotation class PreparedDsl +annotation class PreparedDslMarker -@PreparedDsl -interface PreparedScope +@PreparedDslMarker +interface PreparedDsl /** * A group of tests. @@ -32,27 +32,27 @@ interface PreparedScope * } * ``` */ -interface Suite : PreparedScope { +interface SuiteDsl : PreparedDsl { /** * Creates a child suite named [name] of the current suite. */ - @PreparedDsl + @PreparedDslMarker fun suite( name: String, - block: Suite.() -> Unit, + block: SuiteDsl.() -> Unit, ) /** * Declares a test named [name] as part of the current suite. */ - @PreparedDsl + @PreparedDslMarker fun test( name: String, context: CoroutineContext = EmptyCoroutineContext, - block: suspend Test.() -> Unit, + block: suspend TestDsl.() -> Unit, ) } -interface Test : PreparedScope +interface TestDsl : PreparedDsl -- 2.51.2 From 6e39602839f0fea7114f078dc4c78b5853551b82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 13 Sep 2023 20:13:41 +0200 Subject: [PATCH 08/62] feat(suite): Add test-level caching and finalizers --- .../src/commonTest/kotlin/ExecuteTest.kt | 5 +++ .../src/jsMain/kotlin/JsTestExecutor.kt | 6 +-- .../src/jvmMain/kotlin/JvmTestExecutor.kt | 6 +-- suite/src/commonMain/kotlin/Cache.kt | 18 +++++++++ suite/src/commonMain/kotlin/Finalizers.kt | 37 +++++++++++++++++++ suite/src/commonMain/kotlin/RunTest.kt | 30 +++++++++++++++ suite/src/commonMain/kotlin/SuiteDsl.kt | 37 ++++++++++++++++++- .../src/commonMain/kotlin/TestEnvironment.kt | 21 +++++++++++ 8 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 suite/src/commonMain/kotlin/Cache.kt create mode 100644 suite/src/commonMain/kotlin/Finalizers.kt create mode 100644 suite/src/commonMain/kotlin/RunTest.kt create mode 100644 suite/src/commonMain/kotlin/TestEnvironment.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt index a814705..2c7802a 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -1,6 +1,7 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.cleanUp @Suppress("unused") class ExecuteTest : TestExecutor() { @@ -11,6 +12,10 @@ class ExecuteTest : TestExecutor() { suite("Group of tests") { test("Test 1") { + cleanUp("Stop the database") { + println("Done") + } + println("It executes") println("Other line") } diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt index ab9dd88..70eb9b5 100644 --- a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -1,8 +1,8 @@ package opensavvy.prepared.runner.kotlin -import kotlinx.coroutines.test.runTest import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.runTestDsl import kotlin.coroutines.CoroutineContext // access the internals of kotlin-test, let's hope they don't change in the future :) @@ -36,9 +36,7 @@ private object JsSuiteDsl : SuiteDsl { override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { println("Registering test '$name'…") kTest.kotlin.test.test(name, false) { - runTest(context) { block(JsTestDsl) } + runTestDsl(name, context, block) } } } - -private object JsTestDsl : TestDsl diff --git a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt index 5747206..ddca053 100644 --- a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt @@ -1,8 +1,8 @@ package opensavvy.prepared.runner.kotlin -import kotlinx.coroutines.test.runTest import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.runTestDsl import org.junit.jupiter.api.DynamicContainer import org.junit.jupiter.api.DynamicNode import org.junit.jupiter.api.DynamicTest @@ -34,9 +34,7 @@ private class JvmSuiteDsl : SuiteDsl { override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { nodes += DynamicTest.dynamicTest(name) { - runTest(context) { block(JvmTestDsl) } + runTestDsl(name, context, block) } } } - -private object JvmTestDsl : TestDsl diff --git a/suite/src/commonMain/kotlin/Cache.kt b/suite/src/commonMain/kotlin/Cache.kt new file mode 100644 index 0000000..1d80260 --- /dev/null +++ b/suite/src/commonMain/kotlin/Cache.kt @@ -0,0 +1,18 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class Cache { + private val lock = Mutex() + private val cache = HashMap() + + suspend fun cache(key: Any, compute: suspend () -> Any) = + lock.withLock(key) { cache[key] } ?: run { + val result = compute() + + lock.withLock(key) { cache[key] = result } + + result + } +} diff --git a/suite/src/commonMain/kotlin/Finalizers.kt b/suite/src/commonMain/kotlin/Finalizers.kt new file mode 100644 index 0000000..4b048b6 --- /dev/null +++ b/suite/src/commonMain/kotlin/Finalizers.kt @@ -0,0 +1,37 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class Finalizers { + private val lock = Mutex() + private val finalizers = ArrayList() + + suspend fun register(name: String, block: suspend TestDsl.() -> Unit) { + lock.withLock("register") { finalizers.add(Finalizer(name, block)) } + } + + suspend fun TestDsl.executeAllFinalizers() { + finalizers.asReversed().forEach { + println("» Running finalizer '${it.name}'") + it.block(this) + } + } + + private class Finalizer( + val name: String, + val block: suspend TestDsl.() -> Unit, + ) +} + +/** + * Registers a [block] named [name] to run at the end of the test. + * + * The block will run even if the test fails. + * + * Finalizers are ran in inverse order as their registration order. + */ +@PreparedDslMarker +suspend fun TestDsl.cleanUp(name: String, block: suspend TestDsl.() -> Unit) { + environment.finalizers.register(name, block) +} diff --git a/suite/src/commonMain/kotlin/RunTest.kt b/suite/src/commonMain/kotlin/RunTest.kt new file mode 100644 index 0000000..e678bb9 --- /dev/null +++ b/suite/src/commonMain/kotlin/RunTest.kt @@ -0,0 +1,30 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.CoroutineContext + +private class TestDslImpl( + override val environment: TestEnvironment, +) : TestDsl + +/** + * Low-level primitive to execute a test declared as a [TestDsl]. + * + * Regular users of the library should never need to call this function. + * It is only provided because it is required for people who implement their own test runner. + */ +fun runTestDsl(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit): TestResult { + return runTest(CoroutineName("Test ‘$name’") + context) { + val test = TestDslImpl( + environment = TestEnvironment(name, this), + ) + + try { + test.block() + } finally { + with(test.environment.finalizers) { test.executeAllFinalizers() } + } + } +} diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index 5e5ca9d..53c59fe 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -55,4 +55,39 @@ interface SuiteDsl : PreparedDsl { } -interface TestDsl : PreparedDsl +/** + * A test declaration. + * + * This interface is most often used as a test declaration: `suspend TestDsl.() -> Unit`. + * + * ### Design notes + * + * It is our goal to keep this interface as lightweight as possible, because any field we add here risks being shadowed + * by local variables in the tests. + * + * For example, if we were to add a member called `foo`, then this code… + * ```kotlin + * test("Test") { + * val foo = … + * } + * ``` + * …shadows the member 'foo'. + * + * Instead, we add all fields to [TestEnvironment], and create extension functions which expose the most important functionality. + * + * ### Note to runner implementors + * + * If you are implementing your own test runner, you will need to provide an instance of this interface. + * Because it encapsulates the whole test machinery, we recommend using [runTestDsl] instead of making your own + * implementation. + * + * @see cleanUp Register a finalizer which is executed at the end of the test + */ +interface TestDsl : PreparedDsl { + + /** + * Metadata about the running test. + */ + val environment: TestEnvironment + +} diff --git a/suite/src/commonMain/kotlin/TestEnvironment.kt b/suite/src/commonMain/kotlin/TestEnvironment.kt new file mode 100644 index 0000000..9ffdfb3 --- /dev/null +++ b/suite/src/commonMain/kotlin/TestEnvironment.kt @@ -0,0 +1,21 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.test.TestScope + +/** + * Common utilities and features required for testing. + */ +class TestEnvironment internal constructor( + /** + * The name of the test that is currently running. + */ + val testName: String, + /** + * The [TestScope] this test runs in, used for time control and concurrent work. + */ + val coroutineScope: TestScope, +) { + + internal val cache = Cache() + internal val finalizers = Finalizers() +} -- 2.51.2 From bcf18cb9e2024d6c47f61805447264179e003b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 13 Sep 2023 21:35:00 +0200 Subject: [PATCH 09/62] feat(suite): Add prepared values --- .../src/commonTest/kotlin/ExecuteTest.kt | 15 ++ suite/src/commonMain/kotlin/Prepared.kt | 149 ++++++++++++++++++ suite/src/commonMain/kotlin/SuiteDsl.kt | 6 + 3 files changed, 170 insertions(+) create mode 100644 suite/src/commonMain/kotlin/Prepared.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt index 2c7802a..1aee6c9 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -2,6 +2,8 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.cleanUp +import opensavvy.prepared.suite.prepared +import kotlin.random.Random @Suppress("unused") class ExecuteTest : TestExecutor() { @@ -10,8 +12,20 @@ class ExecuteTest : TestExecutor() { println("This test is declared with the Prepared syntax") } + val factor = prepared("A randomized factor") { + Random.nextInt() + } + + val integer by prepared { + cleanUp("Cleaning the prepared integer") { println("Cleaning up the integer…") } + + Random.nextInt() * factor() + } + suite("Group of tests") { test("Test 1") { + integer() + cleanUp("Stop the database") { println("Done") } @@ -21,6 +35,7 @@ class ExecuteTest : TestExecutor() { } test("Test 2") { + integer() println("It also executes") } } diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt new file mode 100644 index 0000000..fb05075 --- /dev/null +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -0,0 +1,149 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KProperty + +/** + * Represents a value that is lazily generated during test execution. + * + * ### Usage + * + * This helper allows to declare values that are needed by multiple tests, whilst ensuring each test gets its own + * distinct instance. Within a given test, however, the value is always the same: + * ```kotlin + * suite("Random integers") { + * val randomInteger by prepared { Random.nextInt() } + * + * test("First test") { + * println(randomInteger()) // some integer + * println(randomInteger()) // the same integer + * } + * + * test("Second test") { + * println(randomInteger()) // another integer + * } + * } + * ``` + * + * Prepared values are constructed lazily when they are accessed within a test. + * Because of this, they have access to the test's [TestDsl] and can `suspend`. + * + * ### Comparison with other frameworks + * + * Test frameworks usually provide a construct like `@BeforeTest` or similar. + * These constructs allow to declare instantiation code that is run before tests, however: + * - they are implicitly used by tests: in a large test file, it is difficult to know which ones may impact the test (whereas, prepared values must always be referred to in the test), + * - they have implicit ordering relationships: some `@BeforeTest` may use the result from previous ones, which makes it hard to know if removing one will impact test results (whereas, prepared values explicitly depend on each other), + * - they require the need of some kind of `lateinit` variable to store their state (whereas, prepared values' state is available in all test scopes), + * - they are not compatible with coroutines. + * + * Test frameworks also tend to provide a construct like `@AfterTest`, but again, this is covered by [cleanUp]. + * Prepared values can use [cleanUp] as well: + * ```kotlin + * val database by prepared(Dispatchers.IO) { + * val db = Database.connect() + * + * cleanUp("Disconnect from the database") { + * db.close() + * } + * + * db + * } + * + * test("Test") { + * // if a prepared value is accessed in a test, it is automatically cleaned at the end of the test + * database().listTables() + * } + * ``` + * + * Values are instantiated using the [prepared] helper. + */ +class Prepared internal constructor( + private val name: String, + private val block: suspend TestDsl.() -> T, +) { + + @Suppress("UNCHECKED_CAST") + internal suspend fun executeIn(scope: TestDsl): T = + scope.environment.cache.cache(this) { + withContext(CoroutineName("Preparing $name")) { + println("» Preparing ‘$name’") + scope.block() + } + } as T + + // impl note: + // this class *must not* have an equals method + // it must always be compared by reference, or the cache risks confusing multiple values +} + +/** + * See [prepared]. + */ +class PreparedDelegate( + private val value: Prepared, +) { + operator fun getValue(thisRef: Any?, property: KProperty<*>) = value +} + +/** + * See [prepared]. + */ +class PreparedProvider( + private val block: suspend TestDsl.() -> T, +) { + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = PreparedDelegate( + Prepared( + name = property.name, + block = block, + ) + ) +} + +/** + * Declares a lazily-prepared value which will be constructed by calling [block] during test execution. + * + * The prepared value returned by this function is automatically named after the variable it is stored in. + * + * For more information, see [Prepared]. + * + * ### Example + * + * ```kotlin + * val randomInteger by prepared { Random.nextInt() } + * ``` + */ +@PreparedDslMarker +fun prepared( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend TestDsl.() -> T, +) = PreparedProvider { + withContext(context) { + block() + } +} + +/** + * Declares a lazily-prepared value called [name] which will be constructed by calling [block] during test execution. + * + * For more information, see [Prepared]. + * + * ### Example + * + * ```kotlin + * val randomInteger = prepared("A randomized integer") { Random.nextInt() } + * ``` + */ +@PreparedDslMarker +fun prepared( + name: String, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend TestDsl.() -> T, +) = Prepared(name) { + withContext(context) { + block() + } +} diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index 53c59fe..c4cde96 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -90,4 +90,10 @@ interface TestDsl : PreparedDsl { */ val environment: TestEnvironment + /** + * Realizes a [Prepared] value in the context of this test. + */ + suspend operator fun Prepared.invoke(): T = + executeIn(this@TestDsl) + } -- 2.51.2 From d073a14aadcfc8f246a42109199f402cba7f91a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 20 Sep 2023 15:52:56 +0200 Subject: [PATCH 10/62] feat(suite): Asynchronous tasks control --- .../src/commonTest/kotlin/AsyncTest.kt | 24 ++++++++ suite/src/commonMain/kotlin/Async.kt | 60 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 runners/runner-kotlin-test/src/commonTest/kotlin/AsyncTest.kt create mode 100644 suite/src/commonMain/kotlin/Async.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/AsyncTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/AsyncTest.kt new file mode 100644 index 0000000..b6670e4 --- /dev/null +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/AsyncTest.kt @@ -0,0 +1,24 @@ +package opensavvy.prepared.runner.kotlin + +import kotlinx.coroutines.delay +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.launch +import opensavvy.prepared.suite.launchInBackground + +class AsyncTest : TestExecutor() { + override fun SuiteDsl.register() { + test("Start a job and wait for it to finish") { + launch { + delay(1000) + println("Done") + } + } + + test("Start a job, but do not wait for it") { + launchInBackground { + delay(1000) + error("Not printed") + } + } + } +} diff --git a/suite/src/commonMain/kotlin/Async.kt b/suite/src/commonMain/kotlin/Async.kt new file mode 100644 index 0000000..9e0ca20 --- /dev/null +++ b/suite/src/commonMain/kotlin/Async.kt @@ -0,0 +1,60 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.* +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * [CoroutineScope] for tasks started by this test. + * + * The test will only finish when all tasks started in this scope are finished. + * + * Tasks started in this scope respect the controlled [time]. + */ +val TestDsl.foregroundScope: CoroutineScope + get() = environment.coroutineScope + +/** + * [CoroutineScope] for services started by this test. + * + * The test finishes when all tasks started in [foregroundScope] are finished. + * If there are still tasks running in [backgroundScope], they are [cancelled][Job.cancel]. + * + * This is useful to execute background services which are not part of the system-under-test, yet are expected to be running + * by the system-under-test. + * + * Tasks started in this scope respect the controlled [time]. + */ +val TestDsl.backgroundScope: CoroutineScope + get() = environment.coroutineScope.backgroundScope + +/** + * Starts a task in the [foregroundScope]. The test will wait for this task before finishing. + * + * By default, tasks started are run sequentially. + * To execute tasks in parallel, explicitly use a [CoroutineDispatcher]. + * + * The task will respect the controlled [time]. + */ +fun TestDsl.launch( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, +) = foregroundScope.launch(context, start, block) + +/** + * Starts a task in the [backgroundScope] scope. The test will **not** wait for this task before finishing. + * + * This is useful to start background services which are not part of the system-under-test, yet are expected to be running + * by the system-under-test. + * + * By default, tasks started are run sequentially. + * To execute tasks in parallel, explicitly use a [CoroutineDispatcher]. + * + * The task will respect the controlled [time]. + */ +fun TestDsl.launchInBackground( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, +) = backgroundScope.launch(context, start, block) -- 2.51.2 From 6560d888e2a68a32be56891925c0e54e0b4cd42d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 20 Sep 2023 16:41:41 +0200 Subject: [PATCH 11/62] feat(suite): Time control --- .../src/commonTest/kotlin/TimeTest.kt | 65 ++++++ suite/src/commonMain/kotlin/SuiteDsl.kt | 2 + suite/src/commonMain/kotlin/Time.kt | 213 ++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt create mode 100644 suite/src/commonMain/kotlin/Time.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt new file mode 100644 index 0000000..393f473 --- /dev/null +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt @@ -0,0 +1,65 @@ +package opensavvy.prepared.runner.kotlin + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import opensavvy.prepared.suite.* +import kotlin.test.assertEquals +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) +class TimeTest : TestExecutor() { + override fun SuiteDsl.register() { + test("Elapsed time") { + val start = time.source.markNow() + delay(1000) + val end = start.elapsedNow() + + assertEquals(1000, end.inWholeMilliseconds) + } + + test("Current time with subtasks") { + var executed = false + + launchInBackground { + delay(1000) + executed = true + } + + launchInBackground { + delay(1001) + error("Never executed!") + } + + assertEquals(false, executed) + + time.advanceByMillis(500) + time.runCurrent() + assertEquals(false, executed) + + time.advanceByMillis(500) + time.runCurrent() + assertEquals(true, executed) + } + + test("Advance until idle") { + launch { + delay(1000) + println("After 1 second") + + launch { + delay(3000) + println("After 4 seconds") + } + } + + launch { + delay(2000) + println("After 2 seconds") + } + + time.advanceUntilIdle() + assertEquals(4000, time.nowMillis) + } + } +} diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index c4cde96..d899b9a 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -60,6 +60,8 @@ interface SuiteDsl : PreparedDsl { * * This interface is most often used as a test declaration: `suspend TestDsl.() -> Unit`. * + * Tests allow to control the time. For more information, read [time]. + * * ### Design notes * * It is our goal to keep this interface as lightweight as possible, because any field we add here risks being shadowed diff --git a/suite/src/commonMain/kotlin/Time.kt b/suite/src/commonMain/kotlin/Time.kt new file mode 100644 index 0000000..d949370 --- /dev/null +++ b/suite/src/commonMain/kotlin/Time.kt @@ -0,0 +1,213 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.testTimeSource +import kotlinx.coroutines.withTimeout +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlin.time.TimeSource + +/** + * Time control helper. See [time]. + */ +class Time private constructor( + /** + * Access a time source which can be used to measure elapsed time, as controlled with [time]. + * + * ### Example + * + * ```kotlin + * test("Measure time") { + * val start = time.source.markNow() + * // … + * val duration = start.elapsedNow() + * } + * ``` + */ + val source: TimeSource.WithComparableMarks, + + /** + * Accessor for the underlying [TestCoroutineScheduler], which controls the current time. + */ + val scheduler: TestCoroutineScheduler, +) { + + @ExperimentalCoroutinesApi + @ExperimentalTime + constructor(environment: TestEnvironment) : this(environment.coroutineScope.testTimeSource, environment.coroutineScope.testScheduler) + +} + +/** + * Time control center. + * + * ## Why? + * + * Often, we need to create algorithms that behave differently when executed at different times. + * Using the system clock makes code much harder to test, or problems to reproduce. + * Instead, it is recommended that algorithms take as input a "time generator", so fake implementations can be injected for testing. + * - When we want to measure elapsed time, we should inject a [TimeSource] (see [Time.source]). + * - When we want to access the current time, we should inject some kind of Clock (e.g. the one provided by `java.time`, or the one provided by `KotlinX.Datetime`, see the various extensions on [Time]). + * + * When executing the system under test, we need to provide such objects to the algorithm. + * This attribute, [time], is the control center for generating such values and for controlling their outputs. + * + * ## Delay-skipping + * + * Inside tests, calls to [delay] are skipped in a way that keeps the order of events. + * This makes tests much faster to execute without compromising on testing algorithms that need to wait for an event. + * + * This also applies to other time-related coroutine control functions, like [withTimeout]. + * + * This allows to trivially implement algorithms which require skipping a large amount of time: + * ```kotlin + * test("Data is expired after 6 months") { + * val data = createSomeData() + * assertFalse(data.isExpired()) + * + * delay((6 * 30).days) + * + * assertTrue(data.isExpired()) + * } + * ``` + * Assuming all services use either the test clock or the test time source, the entire system will think 6 months have passed, + * and all started tasks will have run the same number of times, and in the same order, as if 6 months had actually passed. + * + * To learn more about delay skipping, see the KotlinX.Coroutines' documentation: [runTest]. + * + * > The delay-skipping behavior is controlled by [Time.scheduler]. + * > If you want to create your own coroutines, remember to add the scheduler to their [CoroutineContext], or they will delay for real. + * + * ## Time control + * + * Inside tests, a virtual time is available, that describes how much delay has been skipped. + * A test always starts at the epoch. + * [delay] allows us to move time forwards, executing all tasks as their execution date is reached. + * We can also control the time directly. + * + * - [Time.nowMillis]: Access the current time in milliseconds (see the various `now*` accessors). + * - [Time.advanceByMillis], [Time.advanceBy]: Advance the current time without executing the awaiting tasks. + * - [Time.advanceUntilIdle]: Advance the current time, executing all tasks in order, until all tasks have been executed. + * - [Time.runCurrent]: Run all tasks enqueued for the current time. + * + * ## Example + * + * This example checks that an event was recorded at the expected time: + * ```kotlin + * test("The event should be recorded at the current time") { + * val start = time.nowMillis + * val result = foo() + * val end = time.nowMillis + * + * assert(result.timestamp > start) + * assert(result.timestamp < end) + * } + * ``` + * + * @see Time.source Measure elapsed time + * @see Time.nowMillis Current time, in milliseconds + */ +@ExperimentalCoroutinesApi +@ExperimentalTime +val TestDsl.time + get() = Time(environment) + +/** + * Accesses the current time inside the test, in milliseconds. + */ +@ExperimentalCoroutinesApi +val Time.nowMillis: Long + get() = scheduler.currentTime + +/** + * Advances the current time by [delay]. + */ +@ExperimentalCoroutinesApi +fun Time.advanceByMillis(delay: Long) { + scheduler.advanceTimeBy(delay) +} + +/** + * Advances the current time by [delay]. + * + * ### Example + * + * ```kotlin + * test("Hello world") { + * val start = time.source.markNow() + * time.advanceBy(2.minutes) + * val elapsed = start.elapsedNow() + * + * assertEquals(2.minutes, elapsed) + * } + * ``` + */ +@ExperimentalCoroutinesApi +fun Time.advanceBy(delay: Duration) { + scheduler.advanceTimeBy(delay) +} + +/** + * Runs all enqueued tasks in the specified order, advancing the virtual time as needed until there are no more scheduled tasks. + * + * This is similar to `delay(Long.MAX_VALUE)`, except it leaves the virtual time on whenever the last task was scheduled. + * + * ### Example + * + * ```kotlin + * test("Execute subtasks") { + * launch { + * delay(1000) + * println("A") + * + * launch { + * delay(3000) + * println("B") + * } + * } + * + * launch { + * delay(2000) + * println("C") + * } + * + * time.advanceUntilIdle() + * assertEquals(4000, time.nowMillis) + * } + * ``` + */ +@ExperimentalCoroutinesApi +fun Time.advanceUntilIdle() { + scheduler.advanceUntilIdle() +} + +/** + * Runs all enqueued tasks at this moment in the virtual time. + * + * ### Example + * + * ```kotlin + * test("Execute task") { + * launchInBackground { + * delay(1000) + * println("Hello world!") + * } + * + * launchInBackground { + * delay(2000) + * println("Will never be printed") + * } + * + * time.advanceByMillis(1000) + * time.runCurrent() // prints "Hello world!" + * } + * ``` + */ +@ExperimentalCoroutinesApi +fun Time.runCurrent() { + scheduler.runCurrent() +} -- 2.51.2 From fa1eb61a7fc057019acc8c6ccb1138c0f727af0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 20 Sep 2023 16:43:31 +0200 Subject: [PATCH 12/62] feat(suite): Time control compatibility for java.time --- suite/src/jvmMain/kotlin/JavaTime.kt | 57 ++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 suite/src/jvmMain/kotlin/JavaTime.kt diff --git a/suite/src/jvmMain/kotlin/JavaTime.kt b/suite/src/jvmMain/kotlin/JavaTime.kt new file mode 100644 index 0000000..6f9703e --- /dev/null +++ b/suite/src/jvmMain/kotlin/JavaTime.kt @@ -0,0 +1,57 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestCoroutineScheduler +import java.time.Clock +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@ExperimentalCoroutinesApi +private class JavaClock(private val scheduler: TestCoroutineScheduler, private val timeZone: ZoneId) : Clock() { + override fun instant(): Instant = + Instant.ofEpochMilli(scheduler.currentTime) + + override fun withZone(zone: ZoneId): Clock = + JavaClock(scheduler, zone) + + override fun getZone(): ZoneId = + timeZone +} + +/** + * Creates a [Clock] that follows the virtual time in this test. + */ +@ExperimentalCoroutinesApi +val Time.clockJava: Clock + get() = JavaClock(scheduler, ZoneOffset.UTC) + +/** + * Accesses the current virtual time within this test, as an [Instant]. + */ +@ExperimentalCoroutinesApi +val Time.nowJava: Instant + get() = clockJava.instant() + +/** + * Advances the virtual time until it reaches [instant]. + * + * It is not possible to set the time to a date in the past. + */ +@ExperimentalCoroutinesApi +fun Time.set(instant: Instant) { + val diff = instant.toEpochMilli() - nowMillis + require(diff >= 0) { "Cannot advance to $instant, which is in the past of the current virtual time, $nowJava" } + advanceByMillis(diff) +} + +/** + * Delays until the virtual time reaches [instant], executing all enqueued tasks in order. + */ +@ExperimentalCoroutinesApi +suspend fun Time.delayUntil(instant: Instant) { + val diff = instant.toEpochMilli() - nowMillis + require(diff >= 0) { "Cannot delay until $instant, which is in the past of the current virtual time, $nowJava" } + delay(diff) +} -- 2.51.2 From 83ac80a600f17e0c4aa1849c099a6e38bf891d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 20 Sep 2023 16:52:20 +0200 Subject: [PATCH 13/62] feat(compat): Time control compatibility for KotlinX.Datetime --- .../compat-kotlinx-datetime/build.gradle.kts | 27 ++++++++ .../src/commonMain/kotlin/KotlinTime.kt | 62 +++++++++++++++++++ gradle/libs.versions.toml | 2 + runners/runner-kotlin-test/build.gradle.kts | 6 ++ .../src/commonTest/kotlin/TimeTest.kt | 10 +++ settings.gradle.kts | 2 + 6 files changed, 109 insertions(+) create mode 100644 compat/compat-kotlinx-datetime/build.gradle.kts create mode 100644 compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt diff --git a/compat/compat-kotlinx-datetime/build.gradle.kts b/compat/compat-kotlinx-datetime/build.gradle.kts new file mode 100644 index 0000000..dc003c0 --- /dev/null +++ b/compat/compat-kotlinx-datetime/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + js { + nodejs() + browser() + } + linuxX64() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + api(libs.kotlinx.datetime) + } + } + + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + } + } +} diff --git a/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt new file mode 100644 index 0000000..5b8f56a --- /dev/null +++ b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt @@ -0,0 +1,62 @@ +package opensavvy.prepared.compat.kotlinx.datetime + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import opensavvy.prepared.suite.Time +import opensavvy.prepared.suite.advanceByMillis +import opensavvy.prepared.suite.nowMillis + +@ExperimentalCoroutinesApi +private class KotlinClock(private val scheduler: TestCoroutineScheduler) : Clock { + override fun now(): Instant = + Instant.fromEpochMilliseconds(scheduler.currentTime) +} + +/** + * Creates a [Clock] that follows the virtual time in this test. + */ +@ExperimentalCoroutinesApi +val Time.clock: Clock + get() = KotlinClock(scheduler) + +/** + * Accesses the current virtual time within this test, as an [Instant]. + */ +@ExperimentalCoroutinesApi +val Time.now: Instant + get() = clock.now() + +/** + * Advances the virtual time until it reaches [instant]. + * + * It is not possible to set the time to a date in the past. + */ +@ExperimentalCoroutinesApi +fun Time.set(instant: Instant) { + val diff = instant.toEpochMilliseconds() - nowMillis + require(diff >= 0) { "Cannot advance to $instant, which is in the past of the current virtual time, $now" } + advanceByMillis(diff) +} + +/** + * Advances the virtual time until it reaches [isoString], formatted as an ISO 8601 timestamp. + * + * It is not possible to set the time to a date in the past. + */ +@ExperimentalCoroutinesApi +fun Time.set(isoString: String) { + set(Instant.parse(isoString)) +} + +/** + * Delays until the virtual time reaches [instant], executing all enqueued tasks in order. + */ +@ExperimentalCoroutinesApi +suspend fun Time.delayUntil(instant: Instant) { + val diff = instant.toEpochMilliseconds() - nowMillis + require(diff >= 0) { "Cannot delay until $instant, which is in the past of the current virtual time, $now" } + delay(diff) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2696542..bbd8eee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "1.9.0" kotlinx-coroutines = "1.7.3" +kotlinx-datetime = "0.4.1" [plugins] kotlin = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -12,6 +13,7 @@ kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", versi kotlin-test-js = { module = "org.jetbrains.kotlin:kotlin-test-js", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } # To use existing plugins in convention plugins, we must also declare them as libraries gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/runners/runner-kotlin-test/build.gradle.kts b/runners/runner-kotlin-test/build.gradle.kts index d2def9b..955cc99 100644 --- a/runners/runner-kotlin-test/build.gradle.kts +++ b/runners/runner-kotlin-test/build.gradle.kts @@ -32,6 +32,12 @@ kotlin { } } + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.compat.compatKotlinxDatetime) + } + } + val jvmMain by sourceSets.getting { dependencies { implementation(libs.kotlin.test.junit5) diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt index 393f473..1ab313c 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/TimeTest.kt @@ -3,6 +3,8 @@ package opensavvy.prepared.runner.kotlin import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import opensavvy.prepared.compat.kotlinx.datetime.now +import opensavvy.prepared.compat.kotlinx.datetime.set import opensavvy.prepared.suite.* import kotlin.test.assertEquals import kotlin.time.ExperimentalTime @@ -61,5 +63,13 @@ class TimeTest : TestExecutor() { time.advanceUntilIdle() assertEquals(4000, time.nowMillis) } + + test("Set the time") { + time.set("2023-09-20T15:58:17.151Z") + + delay(1000) + + assertEquals("2023-09-20T15:58:18.151Z", time.now.toString()) + } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 03ace53..be36c51 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,8 @@ include( "suite", "framework", + "compat:compat-kotlinx-datetime", + "runners:runner-kotlin-test", ) -- 2.51.2 From a1ee53b9680dd4c7497976dbcea010c1867af6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 13 Sep 2023 18:24:40 +0200 Subject: [PATCH 14/62] feat(kotest): Create the Kotest runner module --- gradle/libs.versions.toml | 4 ++ runners/runner-kotest/build.gradle.kts | 46 +++++++++++++++++++ .../src/commonMain/kotlin/Marker.kt | 3 ++ .../commonTest/kotlin/KotestVanillaTest.kt | 10 ++++ settings.gradle.kts | 1 + 5 files changed, 64 insertions(+) create mode 100644 runners/runner-kotest/build.gradle.kts create mode 100644 runners/runner-kotest/src/commonMain/kotlin/Marker.kt create mode 100644 runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bbd8eee..f6f8ec8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,12 @@ [versions] kotlin = "1.9.0" +kotest = "5.7.1" kotlinx-coroutines = "1.7.3" kotlinx-datetime = "0.4.1" [plugins] kotlin = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } [libraries] kotlin-test-common = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } @@ -14,6 +16,8 @@ kotlin-test-js = { module = "org.jetbrains.kotlin:kotlin-test-js", version.ref = kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotest-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } # To use existing plugins in convention plugins, we must also declare them as libraries gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/runners/runner-kotest/build.gradle.kts b/runners/runner-kotest/build.gradle.kts new file mode 100644 index 0000000..7e52699 --- /dev/null +++ b/runners/runner-kotest/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") + alias(libs.plugins.kotest) +} + +kotlin { + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + js(IR) { + browser { + testTask { + useMocha { + timeout = "1 minute" + } + } + } + } + linuxX64() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + + implementation(libs.kotest.engine) + } + } + + val jvmMain by sourceSets.getting { + dependencies { + implementation(libs.kotest.runner.junit5) + } + } +} + +tasks.withType(Test::class) { + testLogging { + events("skipped", "failed", "passed") + } +} diff --git a/runners/runner-kotest/src/commonMain/kotlin/Marker.kt b/runners/runner-kotest/src/commonMain/kotlin/Marker.kt new file mode 100644 index 0000000..1b573ac --- /dev/null +++ b/runners/runner-kotest/src/commonMain/kotlin/Marker.kt @@ -0,0 +1,3 @@ +package opensavvy.prepared.runner.kotest + +class Marker diff --git a/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt b/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt new file mode 100644 index 0000000..38a43e2 --- /dev/null +++ b/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt @@ -0,0 +1,10 @@ +package opensavvy.prepared.runner.kotest + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class KotestVanillaTest : StringSpec({ + "hello world" { + "hello".length shouldBe 5 + } +}) diff --git a/settings.gradle.kts b/settings.gradle.kts index be36c51..8a17b29 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ include( "compat:compat-kotlinx-datetime", "runners:runner-kotlin-test", + "runners:runner-kotest", ) buildCache { -- 2.51.2 From eb22962269b53d90185d199ee74dd97d5582a930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 21 Sep 2023 22:07:35 +0200 Subject: [PATCH 15/62] feat(kotest): Implement a converter to execute Prepared tests with Kotest --- .../src/commonMain/kotlin/Marker.kt | 3 - .../src/commonMain/kotlin/PreparedSuite.kt | 64 +++++++++++++++++++ .../src/commonTest/kotlin/KotestTest.kt | 36 +++++++++++ .../commonTest/kotlin/KotestVanillaTest.kt | 10 --- 4 files changed, 100 insertions(+), 13 deletions(-) delete mode 100644 runners/runner-kotest/src/commonMain/kotlin/Marker.kt create mode 100644 runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt create mode 100644 runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt delete mode 100644 runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt diff --git a/runners/runner-kotest/src/commonMain/kotlin/Marker.kt b/runners/runner-kotest/src/commonMain/kotlin/Marker.kt deleted file mode 100644 index 1b573ac..0000000 --- a/runners/runner-kotest/src/commonMain/kotlin/Marker.kt +++ /dev/null @@ -1,3 +0,0 @@ -package opensavvy.prepared.runner.kotest - -class Marker diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt new file mode 100644 index 0000000..b5a9288 --- /dev/null +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt @@ -0,0 +1,64 @@ +package opensavvy.prepared.runner.kotest + +import io.kotest.core.names.TestName +import io.kotest.core.spec.KotestTestScope +import io.kotest.core.spec.style.StringSpec +import io.kotest.core.spec.style.scopes.RootScope +import io.kotest.core.spec.style.scopes.addTest +import io.kotest.core.test.TestType +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.runTestDsl +import kotlin.coroutines.CoroutineContext + +/** + * Executes a Prepared [SuiteDsl] in a Kotest suite. + * + * Since Kotest cannot represent nested non-suspending test suites, all nested suites are declared at the top-level + * of the provided [RootScope]. The name of the suite is appended at the start of the tests, so the suite structure is not lost. + * + * ### Example + * + * This example uses [StringSpec], but tests can be registered using any spec. + * + * ```kotlin + * class MyTests : StringSpec({ + * "A regular Kotest test" { + * // … + * } + * + * preparedSuite { + * test("A regular Prepared test") { + * // … + * } + * + * suite("A regular Prepared test suite") { + * // … + * } + * } + * }) + * ``` + */ +@KotestTestScope +fun RootScope.preparedSuite(block: SuiteDsl.() -> Unit) { + NonNestedSuite(this).block() +} + +private class NonNestedSuite(private val root: RootScope, private val prefix: String? = null) : SuiteDsl { + override fun suite(name: String, block: SuiteDsl.() -> Unit) { + NonNestedSuite(root, prefix child name).block() + } + + override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { + root.addTest(testName = TestName(name = prefix child name), disabled = false, type = TestType.Test, config = null) { + runTestDsl(name, context, block) + } + } +} + +/** + * Appends [name] at the end of `this`, handling the case where `this` is `null`. + */ +private infix fun String?.child(name: String) = + if (this != null) "$this • $name" + else name diff --git a/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt new file mode 100644 index 0000000..8901e0d --- /dev/null +++ b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt @@ -0,0 +1,36 @@ +package opensavvy.prepared.runner.kotest + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay + +class KotestTest : StringSpec({ + // Vanilla Kotest declarations… + "Kotest" { + println("Done") + } + + "Hello world from Kotest" { + "hello".length shouldBe 5 + } + + // Prepared integration… + preparedSuite { + suite("Prepared") { + suite("Nested") { + test("Prepared") { + delay(1000) + println("Done") + } + + test("Other") { + println("Nothing to do") + } + } + } + + test("Hello world from Prepared") { + "hello".length shouldBe 5 + } + } +}) diff --git a/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt b/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt deleted file mode 100644 index 38a43e2..0000000 --- a/runners/runner-kotest/src/commonTest/kotlin/KotestVanillaTest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package opensavvy.prepared.runner.kotest - -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.shouldBe - -class KotestVanillaTest : StringSpec({ - "hello world" { - "hello".length shouldBe 5 - } -}) -- 2.51.2 From 60146e8ea9f2dd35d9ba72dbfb9cb022e9941ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 24 Sep 2023 15:58:36 +0200 Subject: [PATCH 16/62] build(gradle): Rename the group coordinates --- gradle/conventions/base/src/main/kotlin/base.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/conventions/base/src/main/kotlin/base.gradle.kts b/gradle/conventions/base/src/main/kotlin/base.gradle.kts index 3ca9d87..9a128e4 100644 --- a/gradle/conventions/base/src/main/kotlin/base.gradle.kts +++ b/gradle/conventions/base/src/main/kotlin/base.gradle.kts @@ -6,5 +6,5 @@ plugins { val appVersion: String? by project -group = "opensavvy.playground" +group = "dev.opensavvy.prepared" version = appVersion ?: "0.0.0-DEV" -- 2.51.2 From e0950b05fb0cb38c48b638427e7df76793ba5add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 24 Sep 2023 17:09:06 +0200 Subject: [PATCH 17/62] build(gradle): Configure each project's display name and description --- compat/compat-kotlinx-datetime/build.gradle.kts | 6 ++++++ framework/build.gradle.kts | 6 ++++++ runners/runner-kotest/build.gradle.kts | 6 ++++++ runners/runner-kotlin-test/build.gradle.kts | 6 ++++++ suite/build.gradle.kts | 6 ++++++ 5 files changed, 30 insertions(+) diff --git a/compat/compat-kotlinx-datetime/build.gradle.kts b/compat/compat-kotlinx-datetime/build.gradle.kts index dc003c0..adab5af 100644 --- a/compat/compat-kotlinx-datetime/build.gradle.kts +++ b/compat/compat-kotlinx-datetime/build.gradle.kts @@ -25,3 +25,9 @@ kotlin { } } } + +library { + name.set("Time control in Prepared tests using KotlinX.Datetime") + description.set("Control the passing of time in Prepared tests using objects and methods from KotlinX.Datetime, including Clock and Instant") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-kotlinx-datetime/index.html") +} diff --git a/framework/build.gradle.kts b/framework/build.gradle.kts index a653914..194d52e 100644 --- a/framework/build.gradle.kts +++ b/framework/build.gradle.kts @@ -18,3 +18,9 @@ kotlin { } } } + +library { + name.set("Out-of-the-box Prepared experience (experimental)") + description.set("Collection of libraries to make Prepared as useful as possible, with as little setup as possible.") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/framework/index.html") +} diff --git a/runners/runner-kotest/build.gradle.kts b/runners/runner-kotest/build.gradle.kts index 7e52699..66e0339 100644 --- a/runners/runner-kotest/build.gradle.kts +++ b/runners/runner-kotest/build.gradle.kts @@ -44,3 +44,9 @@ tasks.withType(Test::class) { events("skipped", "failed", "passed") } } + +library { + name.set("Kotest support for Prepared") + description.set("Execute Prepared test suites in projects that use Kotest") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotest/index.html") +} diff --git a/runners/runner-kotlin-test/build.gradle.kts b/runners/runner-kotlin-test/build.gradle.kts index 955cc99..1d53f42 100644 --- a/runners/runner-kotlin-test/build.gradle.kts +++ b/runners/runner-kotlin-test/build.gradle.kts @@ -56,3 +56,9 @@ tasks.withType(Test::class) { events("skipped", "failed", "passed") } } + +library { + name.set("Kotlin-test support for Prepared") + description.set("Execute Prepared test suites in projects that use kotlin-test") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotlin-test/index.html") +} diff --git a/suite/build.gradle.kts b/suite/build.gradle.kts index 2c39e2d..e4d4f87 100644 --- a/suite/build.gradle.kts +++ b/suite/build.gradle.kts @@ -25,3 +25,9 @@ kotlin { } } } + +library { + name.set("Prepared test suite declarations: suite DSL, time control, lazy fixtures, coroutine support…") + description.set("Prepared is a magicless test framework for Kotlin Multiplatform") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/suite/index.html") +} -- 2.51.2 From a1bd99ff70d9f3a351d74bc345be644ac08e2fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 30 Sep 2023 16:35:35 +0200 Subject: [PATCH 18/62] docs: License under Apache 2.0 --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +- 2 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..de99a60 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 OpenSavvy + + 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. diff --git a/README.md b/README.md index c714bf5..ec35ddd 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,7 @@ It is possible to use other libraries (e.g. [Kotlin Test](https://kotlinlang.org ## License - - +This project is licensed under the [Apache 2.0 license](LICENSE). ## Contributing -- 2.51.2 From a83928bb27453e6ae1d0add6fbbd29a3f811c2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 14:16:50 +0200 Subject: [PATCH 19/62] build(kotest): The Kotest runner exposes the Kotest engine as part of its API --- runners/runner-kotest/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runners/runner-kotest/build.gradle.kts b/runners/runner-kotest/build.gradle.kts index 66e0339..75a7a17 100644 --- a/runners/runner-kotest/build.gradle.kts +++ b/runners/runner-kotest/build.gradle.kts @@ -28,13 +28,13 @@ kotlin { dependencies { api(projects.suite) - implementation(libs.kotest.engine) + api(libs.kotest.engine) } } val jvmMain by sourceSets.getting { dependencies { - implementation(libs.kotest.runner.junit5) + api(libs.kotest.runner.junit5) } } } -- 2.51.2 From 13d2264d5ae721258645dcb6b48f085fe950f3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 15:27:49 +0200 Subject: [PATCH 20/62] feat(suite): Improve logging for prepared values and finalizers --- suite/src/commonMain/kotlin/Finalizers.kt | 2 +- suite/src/commonMain/kotlin/Prepared.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/suite/src/commonMain/kotlin/Finalizers.kt b/suite/src/commonMain/kotlin/Finalizers.kt index 4b048b6..fdc35ac 100644 --- a/suite/src/commonMain/kotlin/Finalizers.kt +++ b/suite/src/commonMain/kotlin/Finalizers.kt @@ -13,7 +13,7 @@ internal class Finalizers { suspend fun TestDsl.executeAllFinalizers() { finalizers.asReversed().forEach { - println("» Running finalizer '${it.name}'") + println("» Finalizing '${it.name}'") it.block(this) } } diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index fb05075..c1a922c 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -70,8 +70,9 @@ class Prepared internal constructor( internal suspend fun executeIn(scope: TestDsl): T = scope.environment.cache.cache(this) { withContext(CoroutineName("Preparing $name")) { - println("» Preparing ‘$name’") - scope.block() + val result = scope.block() + println("» Prepared ‘$name’: $result") + result } } as T -- 2.51.2 From 0f6b2b0a162899de8f3fd8ed2faa13747a8d7eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 19:06:05 +0200 Subject: [PATCH 21/62] docs(suite): Explain how to use PreparedProvider --- suite/src/commonMain/kotlin/Prepared.kt | 43 +++++++++++++++++++++---- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index fb05075..d5b7567 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -90,17 +90,46 @@ class PreparedDelegate( } /** - * See [prepared]. + * A [Prepared] is a lazily-created value that is bound to a test, such that multiple reads provide the same value. + * + * A [PreparedProvider] represents the operation that would be executed to generate a prepared value, but isn't + * bound yet. This means [PreparedProvider] is a sort of factory for [Prepared] instances: the same provider can + * build multiple prepared instances, which run the same operation when executed, but are cached independently. + * + * The simplest way to bind a provider to a single value is through delegation: + * ```kotlin + * val prepareRandomInt = prepared { Random.nextInt() } + * val first by prepareRandomInt + * val second by prepareRandomInt + * + * test("A test") { + * // Inside this test, 'first' is a specific int, and 'second' is another specific int + * } + * ``` + * + * Instead of binding a provider to a variable, it is also possible to explicitly bind it with [named]. */ class PreparedProvider( private val block: suspend TestDsl.() -> T, ) { - operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = PreparedDelegate( - Prepared( - name = property.name, - block = block, - ) - ) + /** + * Provides a [Prepared] instance bound to the given [name]. + */ + @Suppress("MemberVisibilityCanBePrivate") + fun named(name: String) = + Prepared(name = name, block = block) + + /** + * Provides a [Prepared] instance bound to the given [property]. + * + * ### Example + * + * ```kotlin + * val randomInteger by prepared { Random.nextInt() } + * ``` + */ + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = + PreparedDelegate(named(property.name)) } /** -- 2.51.2 From ebeea9e2474a912284cac1c6dcac24a47bcbf7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 19:14:44 +0200 Subject: [PATCH 22/62] feat(suite): Immediately bind PreparedProvider instances to a test --- suite/src/commonMain/kotlin/Prepared.kt | 6 ++++++ suite/src/commonMain/kotlin/SuiteDsl.kt | 26 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index d5b7567..486c8e3 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -96,6 +96,8 @@ class PreparedDelegate( * bound yet. This means [PreparedProvider] is a sort of factory for [Prepared] instances: the same provider can * build multiple prepared instances, which run the same operation when executed, but are cached independently. * + * ### Bind a value + * * The simplest way to bind a provider to a single value is through delegation: * ```kotlin * val prepareRandomInt = prepared { Random.nextInt() } @@ -108,6 +110,10 @@ class PreparedDelegate( * ``` * * Instead of binding a provider to a variable, it is also possible to explicitly bind it with [named]. + * + * ### Use without binding + * + * It is also possible to use a provider to generate values without binding them to a [Prepared] instance; see [TestDsl.immediate]. */ class PreparedProvider( private val block: suspend TestDsl.() -> T, diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index d899b9a..50ae199 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -2,6 +2,8 @@ package opensavvy.prepared.suite import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext +import kotlin.random.Random +import kotlin.random.nextUInt @DslMarker annotation class PreparedDslMarker @@ -98,4 +100,28 @@ interface TestDsl : PreparedDsl { suspend operator fun Prepared.invoke(): T = executeIn(this@TestDsl) + /** + * Realizes a [Prepared] value from the provided [PreparedProvider]. + * + * Because the prepared value is created and used immediately, it cannot be saved to be reused—this means that it + * won't have the reuse behavior of [Prepared]; that is: + * + * ```kotlin + * val prepareRandomInt = prepared { Random.nextInt() } + * val first by prepared // bind to a Prepared instance + * + * test("An example") { + * assertEquals(first(), first()) // it is bound, so it always gives the same value + * + * assertNotEquals(prepareRandomInt.immediate(), prepareRandomInt.immediate()) // it is unbound, so each call gives a new value + * } + * ``` + * + * This function is mostly useful because test fixtures are often provided as [PreparedProvider] instance to + * benefit from the other features of this library. + * Sometimes, however, we just need a single value at a single point in time, which is why this function exists. + */ + suspend fun PreparedProvider.immediate(name: String = "Immediate value #${Random.nextUInt()}"): T = + named(name)() + } -- 2.51.2 From 37bb32790a5077da0e3318124ee58fcc8f40ed22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 9 Oct 2023 23:32:26 +0200 Subject: [PATCH 23/62] docs: Rename the modules --- compat/compat-kotlinx-datetime/build.gradle.kts | 2 +- framework/build.gradle.kts | 2 +- runners/runner-kotest/build.gradle.kts | 2 +- runners/runner-kotlin-test/build.gradle.kts | 2 +- suite/build.gradle.kts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/compat/compat-kotlinx-datetime/build.gradle.kts b/compat/compat-kotlinx-datetime/build.gradle.kts index adab5af..e3ebd95 100644 --- a/compat/compat-kotlinx-datetime/build.gradle.kts +++ b/compat/compat-kotlinx-datetime/build.gradle.kts @@ -27,7 +27,7 @@ kotlin { } library { - name.set("Time control in Prepared tests using KotlinX.Datetime") + name.set("Compatibility with KotlinX.Datetime") description.set("Control the passing of time in Prepared tests using objects and methods from KotlinX.Datetime, including Clock and Instant") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-kotlinx-datetime/index.html") } diff --git a/framework/build.gradle.kts b/framework/build.gradle.kts index 194d52e..d16aeeb 100644 --- a/framework/build.gradle.kts +++ b/framework/build.gradle.kts @@ -20,7 +20,7 @@ kotlin { } library { - name.set("Out-of-the-box Prepared experience (experimental)") + name.set("Prepared Framework") description.set("Collection of libraries to make Prepared as useful as possible, with as little setup as possible.") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/framework/index.html") } diff --git a/runners/runner-kotest/build.gradle.kts b/runners/runner-kotest/build.gradle.kts index 75a7a17..12821e7 100644 --- a/runners/runner-kotest/build.gradle.kts +++ b/runners/runner-kotest/build.gradle.kts @@ -46,7 +46,7 @@ tasks.withType(Test::class) { } library { - name.set("Kotest support for Prepared") + name.set("Execute with Kotest") description.set("Execute Prepared test suites in projects that use Kotest") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotest/index.html") } diff --git a/runners/runner-kotlin-test/build.gradle.kts b/runners/runner-kotlin-test/build.gradle.kts index 1d53f42..ef8d17d 100644 --- a/runners/runner-kotlin-test/build.gradle.kts +++ b/runners/runner-kotlin-test/build.gradle.kts @@ -58,7 +58,7 @@ tasks.withType(Test::class) { } library { - name.set("Kotlin-test support for Prepared") + name.set("Execute with Kotlin-test") description.set("Execute Prepared test suites in projects that use kotlin-test") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotlin-test/index.html") } diff --git a/suite/build.gradle.kts b/suite/build.gradle.kts index e4d4f87..672ccd3 100644 --- a/suite/build.gradle.kts +++ b/suite/build.gradle.kts @@ -27,7 +27,7 @@ kotlin { } library { - name.set("Prepared test suite declarations: suite DSL, time control, lazy fixtures, coroutine support…") - description.set("Prepared is a magicless test framework for Kotlin Multiplatform") + name.set("Suite") + description.set("Magicless test framework for Kotlin Multiplatform") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/suite/index.html") } -- 2.51.2 From 48f75d384c62ac6de4cdd63b1b383c095ea8a243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 10 Oct 2023 00:28:56 +0200 Subject: [PATCH 24/62] docs(suite): Improve the documentation --- suite/README.jvm.md | 5 ++ suite/README.md | 100 ++++++++++++++++++++++ suite/src/commonMain/kotlin/Finalizers.kt | 42 +++++++++ 3 files changed, 147 insertions(+) create mode 100644 suite/README.jvm.md create mode 100644 suite/README.md diff --git a/suite/README.jvm.md b/suite/README.jvm.md new file mode 100644 index 0000000..792be2a --- /dev/null +++ b/suite/README.jvm.md @@ -0,0 +1,5 @@ +# Module Suite + +The JVM module has all the features of the common module, plus: + +- Time control with `java.time` classes (see [time.clockJava][opensavvy.prepared.suite.clockJava], [time.nowJava][opensavvy.prepared.suite.nowJava]). diff --git a/suite/README.md b/suite/README.md new file mode 100644 index 0000000..715405b --- /dev/null +++ b/suite/README.md @@ -0,0 +1,100 @@ +# Module Suite + +Magicless test framework for Kotlin Multiplatform. + + + +This module defines the constructs of the Prepared test framework, namely ways to declare tests, control the time, and declare test dependencies. + +Prepared is only about the way tests are declared and structured: you can continue using your favorite assertion library. +We also provide compatibility modules for popular test runners, so you can benefit from Prepared without rewriting your existing tests. + +## Declare tests + +Tests are declared in hierarchical suites: + +```kotlin +suite("Feature name") { + suite("Feature use-case") { + test("Test case 1") { + // test code… + } + + test("Test case 2") { + // test code… + } + } +} +``` + +This has two main consequences: + +- A test suite for an interface or abstract class can be reused between implementations by extracting it to an extension function on [SuiteDsl][opensavvy.prepared.suite.SuiteDsl], +- Tests can be declared programmatically, using any kind of loop. + +To learn more, see [SuiteDsl][opensavvy.prepared.suite.SuiteDsl] and [TestDsl][opensavvy.prepared.suite.TestDsl]. + +## Coroutine-aware + +Prepared tests are coroutine-aware, which provides a few interesting features: + +- [Start asynchronous jobs that the test waits for][opensavvy.prepared.suite.launch], +- [Start background jobs that are automatically stopped at the end of the test][opensavvy.prepared.suite.launchInBackground], +- [Declare a cleanup job that is executed when a test ends][opensavvy.prepared.suite.cleanUp]. + +## Prepared values + +Most testing frameworks define ways to declare “code that needs to be executed before the test happens”. This may be anything from connecting to a database, to setting up test fixtures. However, doing this: + +- makes tests harder to read, as they may have implicit dependencies on some previously-running code, +- introduces shared state between states, which may affect the execution results. + +Instead, we declare Prepared values: lazy operations that are explicitly invoked in tests. Their values are only computed in the context of a test, and each test gets its own execution: + +```kotlin +suite("Feature name") { + val prepareDatabase by prepared { FakeDatabase.connect() } + + test("Test case 1") { + val database = prepareDatabase() + + database.foo() + } + + test("Test case 2") { + val database = prepareDatabase() + + database.bar() + } + + test("Test case 3") { + somethingElse() + } +} +``` + +In the above example, the first and second test both get their own fake database. The third test doesn't refer to the database, so it is not instantiated. + +Prepared values have a few other properties: + +- Because they are executed in the context of the test that uses them, they inherit all its environment: they can `suspend`, start background jobs, register finalizers, etc. +- The [prepared][opensavvy.prepared.suite.prepared] builder can be called anywhere, including at the file top-level, which is convenient for declaring test fixtures that are used in many places. +- Prepared values are transitive: prepared values can themselves refer to other prepared values, etc. +- A single prepared value can be referenced multiple times in the same test and will always return the same value. For example, a prepared value that gives a random integer will always give the same one in a given test. + +To learn more, see [prepared][opensavvy.prepared.suite.prepared]. + +## Control the time + +We often write algorithms that depend on the current time. Sometimes, we discover bugs that only happen at rare times, for example during daylight savings transitions, when the year changes, or in leap years. + +To avoid these issues, it is recommended to inject some kind of clock into all algorithms that depend on the current time, or that need to measure how time passes. + +Inside Prepared tests, the [time][opensavvy.prepared.suite.time] accessor exposes methods to control how time passes: + +- Control tasks started in the future, +- Skip delays between tasks, +- Set the current time. + +To learn more, see [time][opensavvy.prepared.suite.time]. +Compatibility with other time management libraries (e.g. KotlinX.Datetime) are provided as optional dependencies. diff --git a/suite/src/commonMain/kotlin/Finalizers.kt b/suite/src/commonMain/kotlin/Finalizers.kt index fdc35ac..013a318 100644 --- a/suite/src/commonMain/kotlin/Finalizers.kt +++ b/suite/src/commonMain/kotlin/Finalizers.kt @@ -30,6 +30,48 @@ internal class Finalizers { * The block will run even if the test fails. * * Finalizers are ran in inverse order as their registration order. + * + * ### Example + * + * ```kotlin + * val prepareDatabase by prepared { FakeDatabase() } + * + * test("Create a user") { + * val database = prepareDatabase() + * + * val user = database.createUser() + * + * cleanUp("Delete user $user") { + * database.deleteUser(user) + * } + * } + * ``` + * + * ### Declaration from within prepared values + * + * This function can be called from within a [prepared] value, in which case it will run when the test that initialized that prepared value finishes: + * ```kotlin + * val prepareDatabase by prepared { + * FakeDatabase() + * .also { cleanUp("Disconnect from the database") { it.disconnect() } } + * } + * + * val prepareUser by prepared { + * val database = prepareDatabase() + * database.createUser() + * .also { cleanUp("Delete user $it") { database.deleteUser(it) } } + * } + * + * test("Rename a user") { + * val user = prepareUser() + * + * user.rename("New name") + * + * // will automatically run: + * // 1. Delete user … + * // 2. Disconnect from the database + * } + * ``` */ @PreparedDslMarker suspend fun TestDsl.cleanUp(name: String, block: suspend TestDsl.() -> Unit) { -- 2.51.2 From 3dc0e0df6d426f64feed1c3a7ed367ca47876da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Oct 2023 17:47:31 +0200 Subject: [PATCH 25/62] build(compat-filesystem): Create the compat-filesystem project --- build.gradle.kts | 1 + compat/compat-filesystem/README.jvm.md | 5 +++++ compat/compat-filesystem/build.gradle.kts | 27 +++++++++++++++++++++++ settings.gradle.kts | 1 + suite/README.md | 2 +- 5 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 compat/compat-filesystem/README.jvm.md create mode 100644 compat/compat-filesystem/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 5dc0e23..197cd06 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { dokkatoo(projects.runners.runnerKotlinTest) dokkatoo(projects.framework) dokkatoo(projects.compat.compatKotlinxDatetime) + dokkatoo(projects.compat.compatFilesystem) // This is required at the moment, see https://github.com/adamko-dev/dokkatoo/issues/14 dokkatooPluginHtml( diff --git a/compat/compat-filesystem/README.jvm.md b/compat/compat-filesystem/README.jvm.md new file mode 100644 index 0000000..0134c08 --- /dev/null +++ b/compat/compat-filesystem/README.jvm.md @@ -0,0 +1,5 @@ +# Module Filesystem access + +Create and read data from the filesystem. + + diff --git a/compat/compat-filesystem/build.gradle.kts b/compat/compat-filesystem/build.gradle.kts new file mode 100644 index 0000000..d20e78d --- /dev/null +++ b/compat/compat-filesystem/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + } + } + + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + } + } +} + +library { + name.set("Filesystem access") + description.set("Create and check files from the filesystem") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-filesystem/index.html") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 51857cd..7e85f5f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include( "framework", "compat:compat-kotlinx-datetime", + "compat:compat-filesystem", "runners:runner-kotlin-test", "runners:runner-kotest", diff --git a/suite/README.md b/suite/README.md index 715405b..aa2cbec 100644 --- a/suite/README.md +++ b/suite/README.md @@ -1,6 +1,6 @@ # Module Suite -Magicless test framework for Kotlin Multiplatform. +Declare coroutine-aware tests programmatically with time control and lazy values. -- 2.51.2 From cddd778146e47744798bb9c2a28c06722665f7d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Oct 2023 18:06:31 +0200 Subject: [PATCH 26/62] feat(compat-filesystem): Create random directories and files --- .../src/jvmMain/kotlin/RandomFiles.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 compat/compat-filesystem/src/jvmMain/kotlin/RandomFiles.kt diff --git a/compat/compat-filesystem/src/jvmMain/kotlin/RandomFiles.kt b/compat/compat-filesystem/src/jvmMain/kotlin/RandomFiles.kt new file mode 100644 index 0000000..02266fa --- /dev/null +++ b/compat/compat-filesystem/src/jvmMain/kotlin/RandomFiles.kt @@ -0,0 +1,48 @@ +package opensavvy.prepared.compat.filesystem + +import opensavvy.prepared.suite.PreparedProvider +import opensavvy.prepared.suite.cleanUp +import opensavvy.prepared.suite.prepared +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.absolutePathString +import kotlin.io.path.deleteIfExists +import kotlin.io.path.deleteRecursively + +/** + * Creates a random directory, which is automatically deleted at the end of the test. + * + * ### Example + * + * ```kotlin + * val buildDirectory by createRandomDirectory() + * + * test("A test") { + * buildDirectory() // do something with it + * } + * ``` + */ +@ExperimentalPathApi +fun createRandomDirectory(prefix: String = ""): PreparedProvider = prepared { + Files.createTempDirectory(prefix) + .also { cleanUp("Delete the directory ${it.absolutePathString()}") { it.deleteRecursively() } } +} + +/** + * Creates a random file, which is automatically deleted at the end of the test. + * + * ### Example + * + * ```kotlin + * val logs by createRandomFile() + * + * test("A test") { + * logs().writeText("…") + * } + * ``` + */ +fun createRandomFile(prefix: String = "", suffix: String = ""): PreparedProvider = prepared { + Files.createTempFile(prefix, suffix) + .also { cleanUp("Delete the file ${it.absolutePathString()}") { it.deleteIfExists() } } +} -- 2.51.2 From 63f52eb1c102140153adf720629428dabde41c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Oct 2023 18:31:29 +0200 Subject: [PATCH 27/62] feat(suite): Expose the name of a prepared value --- suite/src/commonMain/kotlin/Prepared.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 1db0591..2249a13 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -62,7 +62,7 @@ import kotlin.reflect.KProperty * Values are instantiated using the [prepared] helper. */ class Prepared internal constructor( - private val name: String, + val name: String, private val block: suspend TestDsl.() -> T, ) { -- 2.51.2 From 6922e5c11695d5e9c4e47e9183e17808d64fcc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Oct 2023 18:33:15 +0200 Subject: [PATCH 28/62] feat(suite): Implement map for prepared values and providers --- suite/src/commonMain/kotlin/Prepared.kt | 2 +- suite/src/commonMain/kotlin/PreparedExt.kt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 suite/src/commonMain/kotlin/PreparedExt.kt diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 2249a13..7521ef2 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -117,7 +117,7 @@ class PreparedDelegate( * It is also possible to use a provider to generate values without binding them to a [Prepared] instance; see [TestDsl.immediate]. */ class PreparedProvider( - private val block: suspend TestDsl.() -> T, + internal val block: suspend TestDsl.() -> T, ) { /** * Provides a [Prepared] instance bound to the given [name]. diff --git a/suite/src/commonMain/kotlin/PreparedExt.kt b/suite/src/commonMain/kotlin/PreparedExt.kt new file mode 100644 index 0000000..ffe3914 --- /dev/null +++ b/suite/src/commonMain/kotlin/PreparedExt.kt @@ -0,0 +1,15 @@ +package opensavvy.prepared.suite + +/** + * Creates a new [Prepared] which is the result of calling [block] on the input prepared value. + * + * @param name The name of the resulting prepared value. + */ +fun Prepared.map(name: String, block: (I) -> O): Prepared = + Prepared(name) { block(this@map()) } + +/** + * Creates a new [PreparedProvider] which is the result of calling [block] on the input prepared provider. + */ +fun PreparedProvider.map(block: (I) -> O): PreparedProvider = + PreparedProvider { block(this@map.block(this)) } -- 2.51.2 From 7e73af99e9208ae0f21c92dbb03edceb1ba0b897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 14 Oct 2023 18:35:25 +0200 Subject: [PATCH 29/62] feat(compat-filesystem): Convenience accessors for directories --- .../src/jvmMain/kotlin/Accessors.kt | 48 +++++++++++++++++++ .../src/jvmTest/kotlin/RandomFilesTest.kt | 15 ++++++ 2 files changed, 63 insertions(+) create mode 100644 compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt create mode 100644 compat/compat-filesystem/src/jvmTest/kotlin/RandomFilesTest.kt diff --git a/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt b/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt new file mode 100644 index 0000000..7ca7662 --- /dev/null +++ b/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt @@ -0,0 +1,48 @@ +package opensavvy.prepared.compat.filesystem + +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.PreparedProvider +import opensavvy.prepared.suite.map +import java.io.File +import java.nio.file.Path + +// region java.io + +/** + * Accesses a file named [child] in the provided directory. + */ +operator fun File.div(child: String): File = File(this, child) + +/** + * Accesses a file named [child] in the provided directory. + */ +@JvmName("childFile") +operator fun Prepared.div(child: String): Prepared = map("$name / $child") { it / child } + +/** + * Accesses a file named [child] in the provided directory. + */ +@JvmName("childFile") +operator fun PreparedProvider.div(child: String): PreparedProvider = map { it / child } + +// endregion +// region java.nio + +/** + * Accesses a file named [child] in the provided directory. + */ +operator fun Path.div(child: String): Path = resolve(child) + +/** + * Accesses a file named [child] in the provided directory. + */ +@JvmName("childPath") +operator fun Prepared.div(child: String): Prepared = map("$name / $child") { it / child } + +/** + * Accesses a file named [child] in the provided directory. + */ +@JvmName("childPath") +operator fun PreparedProvider.div(child: String): PreparedProvider = map { it / child } + +// endregion diff --git a/compat/compat-filesystem/src/jvmTest/kotlin/RandomFilesTest.kt b/compat/compat-filesystem/src/jvmTest/kotlin/RandomFilesTest.kt new file mode 100644 index 0000000..8a62241 --- /dev/null +++ b/compat/compat-filesystem/src/jvmTest/kotlin/RandomFilesTest.kt @@ -0,0 +1,15 @@ +package opensavvy.prepared.compat.filesystem + +import opensavvy.prepared.suite.SuiteDsl +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.writeText + +@OptIn(ExperimentalPathApi::class) +fun SuiteDsl.testRandomFiles() = suite("Random files") { + val directory by createRandomDirectory() + val readme = directory / "README.md" + + test("First test") { + readme().writeText("This is a test") + } +} -- 2.51.2 From 90b99cfab52092328a16ef9aad714245df555b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Oct 2023 23:04:49 +0200 Subject: [PATCH 30/62] feat(suite): Reproducible randomness --- .../src/commonTest/kotlin/RandomTest.kt | 17 + suite/src/commonMain/kotlin/Random.kt | 312 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 runners/runner-kotlin-test/src/commonTest/kotlin/RandomTest.kt create mode 100644 suite/src/commonMain/kotlin/Random.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/RandomTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/RandomTest.kt new file mode 100644 index 0000000..c48f709 --- /dev/null +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/RandomTest.kt @@ -0,0 +1,17 @@ +package opensavvy.prepared.runner.kotlin + +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.randomInt +import kotlin.test.assertNotEquals + +class RandomTest : TestExecutor() { + + override fun SuiteDsl.register() { + val int1 by randomInt() + val int2 by randomInt() + + test("A test with two random values") { + assertNotEquals(int1(), int2()) + } + } +} diff --git a/suite/src/commonMain/kotlin/Random.kt b/suite/src/commonMain/kotlin/Random.kt new file mode 100644 index 0000000..9ccd900 --- /dev/null +++ b/suite/src/commonMain/kotlin/Random.kt @@ -0,0 +1,312 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.random.Random.Default.nextBits +import kotlin.random.Random.Default.nextDouble +import kotlin.random.Random.Default.nextFloat +import kotlin.random.Random.Default.nextInt +import kotlin.random.Random.Default.nextLong +import kotlin.random.Random as KotlinRandom + +private class ConfigurableRandom { + + private lateinit var source: KotlinRandom + private val lock = Mutex() + + private var seed: Long? = null + private var explicitlyChosen: Boolean = false + + suspend fun setSeed(seed: Long) = lock.withLock("setSeed($seed)") { + check(!this::source.isInitialized) { "The seed has already been configured, giving the random source $source. It is not allowed to configure the seed multiple times in a single test." } + this.seed = seed + this.explicitlyChosen = true + source = KotlinRandom(seed) + } + + suspend fun use(block: (KotlinRandom) -> T): T = lock.withLock("use") { + if (!this::source.isInitialized) { + val seed = nextLong() + this.seed = seed + source = KotlinRandom(seed) + } + + block(source) + } + + suspend fun getUnsafe(): KotlinRandom { + use { /* force initialization */ } + return source + } + + override fun toString() = "Random generator" + when (explicitlyChosen) { + true -> " with the explicitly selected seed $seed" + false -> " with seed $seed. To reproduce this execution, add 'random.setSeed($seed)' at the start of the test, before any random generation." + } +} + +// Ensure there is exactly one instance per test +private val randomSource by prepared { ConfigurableRandom() } + +/** + * Random control helper. See [random][TestDsl.random]. + */ +class Random internal constructor(private val dsl: TestDsl) { + + /** + * Initializes the underlying [Random][KotlinRandom] implementation with [seed]. + * + * Example: + * ```kotlin + * val int by randomInt() + * + * test("This is a test") { + * setSeed(123456789L) + * + * // Even if prepared values are declared outside the test, + * // as long as they are accessed after the seed is set, + * // they respect the configured seed. + * println("Generated number: ${int()}") + * } + * ``` + * + * This function is meant to easily allow reproducing a test failure that only arrives in rare cases + * by simply adding it at the start of the test with the seed of the failed execution. + * + * This function can only be called before the first random value is generated for the current test, + * otherwise it throws [IllegalStateException]. + */ + suspend fun setSeed(seed: Long) = with(dsl) { + randomSource().setSeed(seed) + } + + /** + * Gives uncontrolled access to the underlying [Random][KotlinRandom]. + * + * **Warning.** + * The Kotlin standard library's random generator is not thread-safe. + * This class wraps it with the necessary synchronization mechanisms. + * By using [accessUnsafe], you are bypassing them. + * Incorrect usage of this function may break the random generation and reproducibility guarantees + * of this class. + * + * In most cases, [use] is probably sufficient. + */ + suspend fun accessUnsafe(): KotlinRandom = with(dsl) { + return randomSource().getUnsafe() + } + + /** + * Provides [block] with the underlying [Random][KotlinRandom] source. + * + * **Warning.** + * The Kotlin standard library's random generator is not thread-safe. + * This class ensures only a single thread may call this function at once. + * It is therefore unsafe to access the provided generator after the call to this function has ended. + */ + suspend fun use(block: (KotlinRandom) -> T): T = with(dsl) { + randomSource().use(block) + } +} + +/** + * Random generator control center. + * + * ## Why? + * + * We often need randomness in tests, for example to generate test data, or for property testing. + * However, randomness hurts reproducibility. + * + * When a random value is generated using this helper, the random generator's seed is printed to the test's standard + * output. If we want to reproduce a previous test execution (e.g. rerun a failed CI test locally), + * we can add a call to [setSeed][Random.setSeed] at the start of the test. + * + * ## Thread-safety + * + * The [Random][KotlinRandom] class from the Kotlin standard library is not thread-safe. + * Since Prepared tests are encouraged to be asynchronous, this would risk making reproducibility impossible. + * + * For this reason, Prepared exposes the traditional [nextInt][Random.nextInt]-style functions, which follow the same + * signature as the [Random][KotlinRandom] class, but hidden behind a lock. + * + * ```kotlin + * test("A test that uses random values") { + * val int = random.nextInt() + * + * println("Generated integer: $int") + * } + * ``` + * + * If you really need to access the underlying [Random][KotlinRandom] class, see [Random.accessUnsafe]. + * + * ## Prepared values + * + * When writing tests with Prepared, we often prefer to declare values used in the tests before the test declaration + * itself, using [prepared] values. However, the functions mentioned above are only available on this helper, which + * only exists in tests. + * + * To simplify this pattern, we also expose [randomInt] and similar functions which expose [prepared providers][PreparedProvider]. + * + * ```kotlin + * val int by randomInt() + * + * test("A test that uses random values") { + * println("Generated integer: ${int()}") + * } + * ``` + * + * As an added benefit, since prepared values log their actual result, all random values generated this way log themselves. + */ +val TestDsl.random: Random + get() = Random(this) + +// region Direct accessors for kotlin.random.Random + +/** + * Generates random bits. + * @see KotlinRandom.nextBits Standard library. + * @see randomBits Prepared value equivalent. + */ +suspend fun Random.nextBits(bitCount: Int) = + use { it.nextBits(bitCount) } + +/** + * Generates a random integer. + * @see KotlinRandom.nextInt Standard library. + * @see randomInt Prepared value equivalent. + */ +suspend fun Random.nextInt() = + use { it.nextInt() } + +/** + * Generates a random integer. + * @see KotlinRandom.nextInt Standard library. + * @see randomInt Prepared value equivalent. + */ +suspend fun Random.nextInt(from: Int, until: Int) = + use { it.nextInt(from, until) } + +/** + * Generates a random integer. + * @see KotlinRandom.nextLong Standard library. + * @see randomLong Prepared value equivalent. + */ +suspend fun Random.nextLong() = + use { it.nextLong() } + +/** + * Generates a random integer. + * @see KotlinRandom.nextLong Standard library. + * @see randomLong Prepared value equivalent. + */ +suspend fun Random.nextLong(from: Long, until: Long) = + use { it.nextLong(from, until) } + +/** + * Generates a random boolean. + * @see KotlinRandom.nextBoolean Standard library. + * @see randomBoolean Prepared value equivalent. + */ +suspend fun Random.nextBoolean() = + use { it.nextBoolean() } + +/** + * Generates a random double. + * @see KotlinRandom.nextDouble Standard library. + * @see randomDouble Prepared value equivalent. + */ +suspend fun Random.nextDouble() = + use { it.nextDouble() } + +/** + * Generates a random double. + * @see KotlinRandom.nextDouble Standard library. + * @see randomDouble Prepared value equivalent. + */ +suspend fun Random.nextDouble(from: Double, until: Double) = + use { it.nextDouble(from, until) } + +/** + * Generates a random float. + * @see KotlinRandom.nextFloat Standard library. + * @see randomFloat Prepared value equivalent. + */ +suspend fun Random.nextFloat() = + use { it.nextFloat() } + +// endregion +// region Prepared values accessors for kotlin.random.Random + +/** + * Provider for random bits. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextBits Standard library. + * @see nextBits Direct value equivalent. + */ +fun randomBits(bitCount: Int) = prepared { nextBits(bitCount) } + +/** + * Provider for a random integer. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextInt Standard library. + * @see nextInt Direct value equivalent. + */ +fun randomInt() = prepared { nextInt() } + +/** + * Provider for a random integer. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextInt Standard library. + * @see nextInt Direct value equivalent. + */ +fun randomInt(from: Int, until: Int) = prepared { nextInt(from, until) } + +/** + * Provider for a random integer. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextLong Standard library. + * @see nextLong Direct value equivalent. + */ +fun randomLong() = prepared { nextLong() } + +/** + * Provider for a random integer. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextLong Standard library. + * @see nextLong Direct value equivalent. + */ +fun randomLong(from: Long, until: Long) = prepared { nextLong(from, until) } + +/** + * Provider for a random boolean. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextBoolean Standard library. + * @see nextBoolean Direct value equivalent. + */ +fun randomBoolean() = prepared { nextInt() } + +/** + * Provider for a random double. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextDouble Standard library. + * @see nextDouble Direct value equivalent. + */ +fun randomDouble() = prepared { nextDouble() } + +/** + * Provider for a random double. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextDouble Standard library. + * @see nextDouble Direct value equivalent. + */ +fun randomDouble(from: Double, until: Double) = prepared { nextDouble(from, until) } + +/** + * Provider for a random float. + * @see TestDsl.random Learn more about random values and reproducibility. + * @see KotlinRandom.nextFloat Standard library. + * @see nextFloat Direct value equivalent. + */ +fun randomFloat() = prepared { nextFloat() } + +// endregion -- 2.51.2 From 51013647715a1b8909270c262a046d5fe25906ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Oct 2023 23:20:08 +0200 Subject: [PATCH 31/62] docs(suite): Stop recommending using random prepared values, since we have a better solution --- suite/src/commonMain/kotlin/Prepared.kt | 8 ++++---- suite/src/commonMain/kotlin/SuiteDsl.kt | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 7521ef2..e382254 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -101,7 +101,7 @@ class PreparedDelegate( * * The simplest way to bind a provider to a single value is through delegation: * ```kotlin - * val prepareRandomInt = prepared { Random.nextInt() } + * val prepareRandomInt = prepared { random.nextInt() } * val first by prepareRandomInt * val second by prepareRandomInt * @@ -132,7 +132,7 @@ class PreparedProvider( * ### Example * * ```kotlin - * val randomInteger by prepared { Random.nextInt() } + * val randomEmail by prepared { "my-account-${random.nextInt()}@mail.com" } * ``` */ operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = @@ -149,7 +149,7 @@ class PreparedProvider( * ### Example * * ```kotlin - * val randomInteger by prepared { Random.nextInt() } + * val randomEmail by prepared { "my-account-${random.nextInt()}@mail.com" } * ``` */ @PreparedDslMarker @@ -170,7 +170,7 @@ fun prepared( * ### Example * * ```kotlin - * val randomInteger = prepared("A randomized integer") { Random.nextInt() } + * val randomEmail = prepared("A randomized email address") { "my-account-${random.nextInt()}@mail.com" } * ``` */ @PreparedDslMarker diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index 50ae199..89cca3c 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -107,8 +107,8 @@ interface TestDsl : PreparedDsl { * won't have the reuse behavior of [Prepared]; that is: * * ```kotlin - * val prepareRandomInt = prepared { Random.nextInt() } - * val first by prepared // bind to a Prepared instance + * val prepareRandomInt = prepared { random.nextInt() } + * val first by prepareRandomInt // bind to a Prepared instance * * test("An example") { * assertEquals(first(), first()) // it is bound, so it always gives the same value -- 2.51.2 From d592ae07dfe7d8afb2b9226f61db9595985afc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Oct 2023 23:22:18 +0200 Subject: [PATCH 32/62] feat(suite): Warn when using kotlin.random.Random in a test --- suite/src/commonMain/kotlin/Random.kt | 14 ++++++++++++++ suite/src/commonMain/kotlin/SuiteDsl.kt | 4 +--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/suite/src/commonMain/kotlin/Random.kt b/suite/src/commonMain/kotlin/Random.kt index 9ccd900..27366ec 100644 --- a/suite/src/commonMain/kotlin/Random.kt +++ b/suite/src/commonMain/kotlin/Random.kt @@ -2,6 +2,7 @@ package opensavvy.prepared.suite import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.jvm.JvmName import kotlin.random.Random.Default.nextBits import kotlin.random.Random.Default.nextDouble import kotlin.random.Random.Default.nextFloat @@ -310,3 +311,16 @@ fun randomDouble(from: Double, until: Double) = prepared { nextDouble(from, unti fun randomFloat() = prepared { nextFloat() } // endregion +// region Warn on usages of kotlin.random.Random in tests + +private const val DEPRECATION_MESSAGE_WRONG_RANDOM = "Using Random in a Prepared test is most likely a mistake. Either opt-in to using the Prepared reproducible opt-in generators (via the 'random' accessor in a test) or explicitly use kotlin.random.Random via qualified name." + +/** + * See [random]. + */ +@Deprecated(DEPRECATION_MESSAGE_WRONG_RANDOM, ReplaceWith("random"), DeprecationLevel.WARNING) +@get:JvmName("getKotlinRandom") +val TestDsl.Random: Random + get() = random + +// endregion diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index 89cca3c..a8277c1 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -2,8 +2,6 @@ package opensavvy.prepared.suite import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext -import kotlin.random.Random -import kotlin.random.nextUInt @DslMarker annotation class PreparedDslMarker @@ -121,7 +119,7 @@ interface TestDsl : PreparedDsl { * benefit from the other features of this library. * Sometimes, however, we just need a single value at a single point in time, which is why this function exists. */ - suspend fun PreparedProvider.immediate(name: String = "Immediate value #${Random.nextUInt()}"): T = + suspend fun PreparedProvider.immediate(name: String = "Immediate value #${randomInt()}"): T = named(name)() } -- 2.51.2 From 14ce376471f5296d6117d8a262adcd2cf145fc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Oct 2023 23:28:39 +0200 Subject: [PATCH 33/62] docs(suite): Mention randomness control in the module header --- suite/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/suite/README.md b/suite/README.md index aa2cbec..3a4569e 100644 --- a/suite/README.md +++ b/suite/README.md @@ -98,3 +98,15 @@ Inside Prepared tests, the [time][opensavvy.prepared.suite.time] accessor expose To learn more, see [time][opensavvy.prepared.suite.time]. Compatibility with other time management libraries (e.g. KotlinX.Datetime) are provided as optional dependencies. + +## Control random values + +Randomness is useful to generate test data, but it makes reproducing test failures harder. + +Prepared exposes utilities to generate reproducible random tests: + +- [random.setSeed][opensavvy.prepared.suite.Random.setSeed] to set the seed for an entire test. +- [random.nextInt][opensavvy.prepared.suite.nextInt] (and similar functions) to generate random values. +- [randomInt][opensavvy.prepared.suite.randomInt] (and similar functions) to generate random prepared values. + +To learn more, see [random][opensavvy.prepared.suite.random]. -- 2.51.2 From d79ef000d99cb09217530261cbc2f27220ce8f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 19 Oct 2023 21:40:30 +0200 Subject: [PATCH 34/62] feat(suite): Allow nullable prepared values --- suite/src/commonMain/kotlin/Cache.kt | 4 ++-- suite/src/commonMain/kotlin/Prepared.kt | 10 +++++----- suite/src/commonMain/kotlin/PreparedExt.kt | 4 ++-- suite/src/commonMain/kotlin/SuiteDsl.kt | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/suite/src/commonMain/kotlin/Cache.kt b/suite/src/commonMain/kotlin/Cache.kt index 1d80260..b6a716c 100644 --- a/suite/src/commonMain/kotlin/Cache.kt +++ b/suite/src/commonMain/kotlin/Cache.kt @@ -5,9 +5,9 @@ import kotlinx.coroutines.sync.withLock internal class Cache { private val lock = Mutex() - private val cache = HashMap() + private val cache = HashMap() - suspend fun cache(key: Any, compute: suspend () -> Any) = + suspend fun cache(key: Any, compute: suspend () -> Any?) = lock.withLock(key) { cache[key] } ?: run { val result = compute() diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index e382254..530e924 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -61,7 +61,7 @@ import kotlin.reflect.KProperty * * Values are instantiated using the [prepared] helper. */ -class Prepared internal constructor( +class Prepared internal constructor( val name: String, private val block: suspend TestDsl.() -> T, ) { @@ -84,7 +84,7 @@ class Prepared internal constructor( /** * See [prepared]. */ -class PreparedDelegate( +class PreparedDelegate( private val value: Prepared, ) { operator fun getValue(thisRef: Any?, property: KProperty<*>) = value @@ -116,7 +116,7 @@ class PreparedDelegate( * * It is also possible to use a provider to generate values without binding them to a [Prepared] instance; see [TestDsl.immediate]. */ -class PreparedProvider( +class PreparedProvider( internal val block: suspend TestDsl.() -> T, ) { /** @@ -153,7 +153,7 @@ class PreparedProvider( * ``` */ @PreparedDslMarker -fun prepared( +fun prepared( context: CoroutineContext = EmptyCoroutineContext, block: suspend TestDsl.() -> T, ) = PreparedProvider { @@ -174,7 +174,7 @@ fun prepared( * ``` */ @PreparedDslMarker -fun prepared( +fun prepared( name: String, context: CoroutineContext = EmptyCoroutineContext, block: suspend TestDsl.() -> T, diff --git a/suite/src/commonMain/kotlin/PreparedExt.kt b/suite/src/commonMain/kotlin/PreparedExt.kt index ffe3914..bca6f8d 100644 --- a/suite/src/commonMain/kotlin/PreparedExt.kt +++ b/suite/src/commonMain/kotlin/PreparedExt.kt @@ -5,11 +5,11 @@ package opensavvy.prepared.suite * * @param name The name of the resulting prepared value. */ -fun Prepared.map(name: String, block: (I) -> O): Prepared = +fun Prepared.map(name: String, block: (I) -> O): Prepared = Prepared(name) { block(this@map()) } /** * Creates a new [PreparedProvider] which is the result of calling [block] on the input prepared provider. */ -fun PreparedProvider.map(block: (I) -> O): PreparedProvider = +fun PreparedProvider.map(block: (I) -> O): PreparedProvider = PreparedProvider { block(this@map.block(this)) } diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index a8277c1..14bbcae 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -95,7 +95,7 @@ interface TestDsl : PreparedDsl { /** * Realizes a [Prepared] value in the context of this test. */ - suspend operator fun Prepared.invoke(): T = + suspend operator fun Prepared.invoke(): T = executeIn(this@TestDsl) /** @@ -119,7 +119,7 @@ interface TestDsl : PreparedDsl { * benefit from the other features of this library. * Sometimes, however, we just need a single value at a single point in time, which is why this function exists. */ - suspend fun PreparedProvider.immediate(name: String = "Immediate value #${randomInt()}"): T = + suspend fun PreparedProvider.immediate(name: String = "Immediate value #${randomInt()}"): T = named(name)() } -- 2.51.2 From 4cbad0b2c1bedbe0fba8f82e5056cd63d56f2a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Oct 2023 20:54:59 +0200 Subject: [PATCH 35/62] feat(compat-gradle): Create the Gradle TestKit compatibility module --- build.gradle.kts | 1 + compat/compat-gradle/README.jvm.md | 5 +++++ compat/compat-gradle/build.gradle.kts | 30 +++++++++++++++++++++++++++ gradle/libs.versions.toml | 2 ++ settings.gradle.kts | 1 + 5 files changed, 39 insertions(+) create mode 100644 compat/compat-gradle/README.jvm.md create mode 100644 compat/compat-gradle/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 197cd06..6a02f1f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { dokkatoo(projects.runners.runnerKotest) dokkatoo(projects.runners.runnerKotlinTest) dokkatoo(projects.framework) + dokkatoo(projects.compat.compatGradle) dokkatoo(projects.compat.compatKotlinxDatetime) dokkatoo(projects.compat.compatFilesystem) diff --git a/compat/compat-gradle/README.jvm.md b/compat/compat-gradle/README.jvm.md new file mode 100644 index 0000000..1b71004 --- /dev/null +++ b/compat/compat-gradle/README.jvm.md @@ -0,0 +1,5 @@ +# Module Compatibility with Gradle TestKit + +Write tests for Gradle plugins easily. + + diff --git a/compat/compat-gradle/build.gradle.kts b/compat/compat-gradle/build.gradle.kts new file mode 100644 index 0000000..94e91ed --- /dev/null +++ b/compat/compat-gradle/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + api(projects.compat.compatFilesystem) + + api(libs.gradle.testkit) + } + } + + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + } + } +} + +library { + name.set("Compatibility with Gradle TestKit") + description.set("Test Gradle plugins using Prepared") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-gradle/index.html") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77a99b2..c8946fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ kotlin = "1.9.10" kotest = "5.7.1" kotlinx-coroutines = "1.7.3" kotlinx-datetime = "0.4.1" +gradle-testkit = "8.4" [plugins] kotest = { id = "io.kotest.multiplatform", version.ref = "kotest" } @@ -20,5 +21,6 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotest-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +gradle-testkit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = "gradle-testkit" } [bundles] diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e85f5f..685400e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include( "framework", "compat:compat-kotlinx-datetime", + "compat:compat-gradle", "compat:compat-filesystem", "runners:runner-kotlin-test", -- 2.51.2 From 0d958ab5884e4af6cc09136652c8c8e05b1060cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Oct 2023 22:20:12 +0200 Subject: [PATCH 36/62] feat(compat-gradle): Create the 'gradle' test extension point --- compat/compat-gradle/README.jvm.md | 39 +++++ .../src/jvmMain/kotlin/Gradle.kt | 117 +++++++++++++ .../compat-gradle/src/jvmMain/kotlin/Paths.kt | 160 ++++++++++++++++++ .../src/jvmMain/kotlin/Project.kt | 36 ++++ 4 files changed, 352 insertions(+) create mode 100644 compat/compat-gradle/src/jvmMain/kotlin/Gradle.kt create mode 100644 compat/compat-gradle/src/jvmMain/kotlin/Paths.kt create mode 100644 compat/compat-gradle/src/jvmMain/kotlin/Project.kt diff --git a/compat/compat-gradle/README.jvm.md b/compat/compat-gradle/README.jvm.md index 1b71004..0cd842c 100644 --- a/compat/compat-gradle/README.jvm.md +++ b/compat/compat-gradle/README.jvm.md @@ -3,3 +3,42 @@ Write tests for Gradle plugins easily. + +This module creates the `gradle` extension point that automatically creates a temporary directory in which the Gradle build is created. + +```kotlin +test("A test that uses Gradle") { + // Create Kotlin or Grovvy DSL files directly… + gradle.settingsKts(""" + include("foo") + """.trimIndent()) + + gradle.rootProject.buildKts(""" + tasks.register("test") { + doLast { + println("Testing the root project") + } + } + """.trimIndent()) + + // …create multi-project builds easily… + gradle.project("foo").buildKts(""" + tasks.register("test") { + doLast { + println("Testing the :foo project") + } + } + """.trimIndent()) + + // …start a Gradle instance in the related project… + val result = gradle.runner() + .withArguments("test") + .build() + + // …assert that the output is as expected. + result.output shouldContain "Testing the root project" + result.output shouldContain "Testing the :foo project" +} +``` + +To learn more, see the [gradle][opensavvy.prepared.compat.gradle.gradle] extension point. diff --git a/compat/compat-gradle/src/jvmMain/kotlin/Gradle.kt b/compat/compat-gradle/src/jvmMain/kotlin/Gradle.kt new file mode 100644 index 0000000..b5bdb28 --- /dev/null +++ b/compat/compat-gradle/src/jvmMain/kotlin/Gradle.kt @@ -0,0 +1,117 @@ +package opensavvy.prepared.compat.gradle + +import opensavvy.prepared.compat.filesystem.createRandomDirectory +import opensavvy.prepared.compat.filesystem.div +import opensavvy.prepared.suite.TestDsl +import org.gradle.testkit.runner.GradleRunner +import kotlin.io.path.ExperimentalPathApi + +@OptIn(ExperimentalPathApi::class) +private val rootProjectDir by createRandomDirectory("gradle-testkit-") + +/** + * Control center for Gradle TestKit. See [gradle]. + */ +class Gradle internal constructor( + internal val dsl: TestDsl, +) { + + /** + * A temporary directory unique for each test, in which the Gradle files are created. + * + * ### Example + * + * Print the directory: + * ```kotlin + * test("In which directory does Gradle execute?") { + * println(gradle.dir()) + * } + * ``` + */ + val dir get() = rootProjectDir + + /** + * Accessor for the files of the root project. + * + * ### Example + * + * Create the root `build.gradle.kts` file: + * ```kotlin + * test("Create the root build.gradle.kts file") { + * gradle.rootProject.buildKts(""" + * println("Configuring the project") + * """.trimIndent() + * } + * ``` + * + * @see project Access another project + * @see runner Execute the test and check the outputs + */ + val rootProject get() = Project(this, dir) + + /** + * Accessor for the files of a project, given its [path]. + * + * ### Example + * + * Create the `modules/foo/build.gradle` file: + * ```kotlin + * test("Configure the :modules:foo project") { + * gradle.project("modules/foo").buildGroovy(""" + * println "Configuring the project!" + * """.trimIndent()) + * } + * ``` + * + * @see rootProject Access the root project + * @see runner Execute the test and check the outputs + */ + fun project(path: String) = Project(this, dir / path) + + /** + * Instantiates a [GradleRunner] in [dir]. + * + * ### Examples + * + * ```kotlin + * test("Create the root build.gradle.kts file") { + * gradle.rootProject.buildKts(""" + * tasks.register("print") { + * doLast { + * println("Configuring the project") + * } + * } + * """.trimIndent() + * + * val result = gradle.runner() + * .withPluginClasspath() + * .withArguments("print") + * .build() + * + * result.output shouldContain "Configuring the project" + * } + * ``` + * + * @see GradleRunner.withPluginClasspath When writing tests for a plugin, automatically adds it to the executed Gradle instance + * @see GradleRunner.withArguments Specify which tasks should be executed + * @see GradleRunner.build Executes the build, expecting a success + * @see GradleRunner.buildAndFail Executes the build, expecting a failure + */ + suspend fun runner(): GradleRunner = with(dsl) { + GradleRunner.create() + .withProjectDir(dir().toFile()) + } +} + +/** + * Control center for [Gradle TestKit](https://docs.gradle.org/current/userguide/test_kit.html). + * + * Each test is assigned a temporary directory in which Gradle can be configured (see [dir][Gradle.dir]). + * Usually, tests will: + * 1. Create the settings file (see [settingsGroovy] or [settingsKts]), + * 2. Create the build script files (see [buildGroovy] or [buildKts]), + * 3. Execute Gradle (see [runner][Gradle.runner]) + * 4. Make assertions on the result. + */ +val TestDsl.gradle: Gradle + get() = Gradle(this) diff --git a/compat/compat-gradle/src/jvmMain/kotlin/Paths.kt b/compat/compat-gradle/src/jvmMain/kotlin/Paths.kt new file mode 100644 index 0000000..e70d2cb --- /dev/null +++ b/compat/compat-gradle/src/jvmMain/kotlin/Paths.kt @@ -0,0 +1,160 @@ +package opensavvy.prepared.compat.gradle + +import opensavvy.prepared.compat.filesystem.div +import org.intellij.lang.annotations.Language +import kotlin.io.path.writeText + +// region Build + +/** + * Accessor for the `settings.gradle` file. + * + * ### Example + * + * ```kotlin + * test("Access the groovy settings file") { + * println(gradle.settingsGroovy()) + * } + * ``` + * + * @see settingsKts Kotlin equivalent + * @see buildGroovy Build script file + */ +val Gradle.settingsGroovy get() = dir / "settings.gradle" + +/** + * Accessor for the `settings.gradle.kts` file. + * + * ### Example + * + * ```kotlin + * test("Access the Kotlin DSL settings file") { + * println(gradle.settingsKts()) + * } + * ``` + * + * @see settingsGroovy Groovy equivalent + * @see buildKts Build script file + */ +val Gradle.settingsKts get() = dir / "settings.gradle.kts" + +/** + * Helper function to write the `settings.gradle` file. + * + * ### Example + * + * ```kotlin + * test("Create a groovy settings file") { + * gradle.settingsGroovy(""" + * println "Loading the settings…" + * """.trimIndent()) + * } + * ``` + * + * @see settingsKts Kotlin equivalent + * @see buildGroovy Build script file + */ +suspend fun Gradle.settingsGroovy(@Language("groovy") text: String) = with(dsl) { + settingsGroovy().writeText(text) +} + +/** + * Helper function to write the `settings.gradle.kts` file. + * + * ### Example + * + * ```kotlin + * test("Create a Kotlin DSL settings file") { + * gradle.settingsKts(""" + * println("Loading the settings…") + * """.trimIndent()) + * } + * ``` + * + * @see settingsGroovy Groovy equivalent + * @see buildKts Build script file + */ +suspend fun Gradle.settingsKts(@Language("kts") text: String) = with(dsl) { + settingsKts().writeText(text) +} + +// endregion +// region Project + +/** + * Accessor for the `build.gradle` file. + * + * ### Example + * + * ```kotlin + * test("Access the groovy build file") { + * println(gradle.project("foo").buildGroovy()) + * } + * ``` + * + * @see buildKts Kotlin equivalent + * @see settingsGroovy Settings file + * @see Gradle.project Select the project + */ +val Project.buildGroovy get() = dir / "build.gradle" + +/** + * Accessor for the `build.gradle.kts` file. + * + * ### Example + * + * ```kotlin + * test("Access the Kotlin build file") { + * println(gradle.project("foo").buildKts()) + * } + * ``` + * + * @see buildGroovy Groovy equivalent + * @see settingsKts Settings file + * @see Gradle.project Select the project + */ +val Project.buildKts get() = dir / "build.gradle.kts" + +/** + * Helper function to write the `build.gradle` file. + * + * ### Example + * + * ```kotlin + * test("Create a groovy build file") { + * gradle.project("foo").buildGroovy(""" + * println "Loading the project :foo…" + * """.trimIndent()) + * } + * ``` + * + * @see buildKts Kotlin equivalent + * @see settingsGroovy Settings file + * @see Gradle.project Select the project + */ +suspend fun Project.buildGroovy(@Language("groovy") text: String) = with(dsl) { + buildGroovy().writeText(text) +} + +/** + * Helper function to write the `build.gradle.kts` file. + * + * ### Example + * + * ```kotlin + * test("Create a Kotlin build file") { + * gradle.project("foo").buildKts(""" + * println("Loading the project :foo…") + * """.trimIndent()) + * } + * ``` + * + * @see buildGroovy Groovy equivalent + * @see settingsKts Settings file + * @see Gradle.project Select the project + */ +suspend fun Project.buildKts(@Language("kts") text: String) = with(dsl) { + buildKts().writeText(text) +} + +// endregion diff --git a/compat/compat-gradle/src/jvmMain/kotlin/Project.kt b/compat/compat-gradle/src/jvmMain/kotlin/Project.kt new file mode 100644 index 0000000..f7513a7 --- /dev/null +++ b/compat/compat-gradle/src/jvmMain/kotlin/Project.kt @@ -0,0 +1,36 @@ +package opensavvy.prepared.compat.gradle + +import opensavvy.prepared.compat.filesystem.div +import opensavvy.prepared.suite.Prepared +import java.nio.file.Path + +/** + * Represents a Gradle project. + * + * To access an instance of this type, use [Gradle.rootProject] or [Gradle.project]. + */ +class Project internal constructor( + private val build: Gradle, + + /** + * The subdirectory of [Gradle.dir] in which this project is located. + * + * ### Example + * + * ```kotlin + * test("Print the project structure") { + * println(gradle.dir()) + * println(gradle.project("foo").dir()) + * } + * ``` + */ + val dir: Prepared, +) { + + internal val dsl get() = build.dsl + + /** + * The `build` directory for this [Project]. + */ + val buildDir get() = dir / "build" +} -- 2.51.2 From 41c9b5ff1c0b20175d36bc6e4c5fc97c69c8bdc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 21 Oct 2023 23:18:17 +0200 Subject: [PATCH 37/62] docs: Add an example in the README --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ec35ddd..2e47e32 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > This project is experimental. -Testing libraries are composed of three different components: +Testing frameworks are composed of three different components: - The **assertions** check that values are what we expect, - The **structure** is the way tests are declared and discovered, - The **runner** is the program that executes the tests. @@ -10,16 +10,49 @@ Testing libraries are composed of three different components: OpenSavvy Prepared is a **structure** library: it concentrates on the way tests are declared. It is possible to use other libraries (e.g. [Kotlin Test](https://kotlinlang.org/api/latest/kotlin.test/), [Kotest](https://kotest.io/), [Strikt](https://strikt.io/)…) to declare assertions. -[TOC] +```kotlin +// Declare tests using a regular Kotlin DSL, no annotations or other magic +fun SuiteDsl.showcase() = suite("Showcase Prepared") { + test("A simple test") { + // Use Kotest or any other assertion library + "Hello world" shouldContain "world" + } -## Features + // Instantiate test data with coroutine-aware builders + val database by prepared { Database.connect() } + val minVersion by prepared { Database.minimalVersion.connect() } + val testDir by createRandomDirectory() -- Lazy fixtures: Fixtures are executed lazily as they are needed, once per test -- Explicit fixtures -- Time is fixed during test execution -- Easy parametrization -- Coroutine-aware -- Multiplatform + // Declare nested test suites + suite("Dump the database") { + // Declare tests programmatically + for (db in listOf(database, minVersion)) { + test("Dump the database ${db.name}") { + // Each test gets its own instance of all prepared values + // (here, each test gets its own output directory) + val outputDir = testDir() + + db().dumpTo(outputDir) + } + } + } + + test("Control the time and randomness") { + time.set("2023-10-21T21:08:29Z") + random.setSeed(123) + + val random = testDir / "random.txt" + val date = testDir / "now.txt" + + // Only values accessed in the test are prepared; + // here, the 'database' and 'minVersion' values are not created + random().writeText(random.nextInt()) + date().writeText(time.now().toString()) + } +} +``` + +To learn more, [read the documentation](https://opensavvy.gitlab.io/prepared/api-docs/suite/index.html). ## Project structure -- 2.51.2 From 0b87cec633fb445092ed440c872b07602e48f876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 14:36:08 +0200 Subject: [PATCH 38/62] build(arrow): Create the Arrow compatibility module --- build.gradle.kts | 1 + compat/compat-arrow/build.gradle.kts | 40 ++++++++++++++++++++++++++++ gradle/libs.versions.toml | 2 ++ settings.gradle.kts | 1 + 4 files changed, 44 insertions(+) create mode 100644 compat/compat-arrow/build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts index 6a02f1f..737b539 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { dokkatoo(projects.compat.compatGradle) dokkatoo(projects.compat.compatKotlinxDatetime) dokkatoo(projects.compat.compatFilesystem) + dokkatoo(projects.compat.compatArrow) // This is required at the moment, see https://github.com/adamko-dev/dokkatoo/issues/14 dokkatooPluginHtml( diff --git a/compat/compat-arrow/build.gradle.kts b/compat/compat-arrow/build.gradle.kts new file mode 100644 index 0000000..921b01d --- /dev/null +++ b/compat/compat-arrow/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + js { + nodejs() + browser() + } + linuxX64() + + val commonMain by sourceSets.getting { + dependencies { + api(projects.suite) + api(libs.arrow.core) + } + } + + val commonTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + implementation(projects.runners.runnerKotest) + } + } +} + +library { + name.set("Functional error management with Arrow") + description.set("Bind Either and Validated to fail test cases, with origin tracing") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-arrow/index.html") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8946fa..13d219c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ [versions] kotlin = "1.9.10" kotest = "5.7.1" +arrow = "1.2.1" kotlinx-coroutines = "1.7.3" kotlinx-datetime = "0.4.1" gradle-testkit = "8.4" @@ -22,5 +23,6 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotest-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } gradle-testkit = { module = "dev.gradleplugins:gradle-test-kit", version.ref = "gradle-testkit" } +arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } [bundles] diff --git a/settings.gradle.kts b/settings.gradle.kts index 685400e..6480a55 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include( "compat:compat-kotlinx-datetime", "compat:compat-gradle", "compat:compat-filesystem", + "compat:compat-arrow", "runners:runner-kotlin-test", "runners:runner-kotest", -- 2.51.2 From 6a7ab2e011c41364d7f56db5ec65027d64d20ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 4 Oct 2023 15:07:01 +0200 Subject: [PATCH 39/62] feat(arrow): Create failOnRaise --- .../src/commonMain/kotlin/FailOnRaise.kt | 19 +++++++++++++++++ .../src/commonTest/kotlin/FailOnRaiseTest.kt | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt create mode 100644 compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt diff --git a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt new file mode 100644 index 0000000..57dd3de --- /dev/null +++ b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt @@ -0,0 +1,19 @@ +package opensavvy.prepared.compat.arrow.core + +import arrow.core.getOrElse +import arrow.core.raise.ExperimentalTraceApi +import arrow.core.raise.Raise +import arrow.core.raise.either +import opensavvy.prepared.suite.PreparedDslMarker +import opensavvy.prepared.suite.TestDsl + +/** + * Fails the test if [block] raises. + */ +@Suppress("UnusedReceiverParameter") // we're keeping the receiver for scoping +@ExperimentalTraceApi +@PreparedDslMarker +inline fun TestDsl.failOnRaise(block: Raise.() -> Success): Success { + return either(block) + .getOrElse { throw AssertionError("Expected the block to execute successfully, but a value was raised: $it") } +} diff --git a/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt b/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt new file mode 100644 index 0000000..01abdfc --- /dev/null +++ b/compat/compat-arrow/src/commonTest/kotlin/FailOnRaiseTest.kt @@ -0,0 +1,21 @@ +package opensavvy.prepared.compat.arrow.core + +import arrow.core.raise.ExperimentalTraceApi +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import opensavvy.prepared.runner.kotest.preparedSuite + +@OptIn(ExperimentalTraceApi::class) +class FailOnRaiseTest : StringSpec({ + preparedSuite { + test("Successful operation") { + failOnRaise { 5 } + } + + test("Failed operation") { + shouldThrow { + failOnRaise { raise(5) } + } + } + } +}) -- 2.51.2 From 8fac45354801a37d6780cf694aab909f4e67553b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 31 Oct 2023 20:52:38 +0100 Subject: [PATCH 40/62] ci(gitlab): Execute the JS tests in CI --- .gitlab-ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index febaf79..18b2cdf 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,20 @@ check: script: - > - ./gradlew check + ./gradlew check -x jsBrowserTest + -PappVersion=$project_version + + interruptible: true + +check-js: + extends: [ .os.gradle ] + image: registry.gitlab.com/opensavvy/pedestal/chromium:latest + needs: [ os.version ] + stage: test + + script: + - > + ./gradlew jsBrowserTest -PappVersion=$project_version interruptible: true -- 2.51.2 From f5508b8567d5002b6b8bc653352d9959c2987ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 1 Nov 2023 18:06:56 +0100 Subject: [PATCH 41/62] feat: Configurable tests --- .../src/commonMain/kotlin/PreparedSuite.kt | 19 ++-- .../src/commonMain/kotlin/TestExecutor.kt | 6 ++ .../src/jsMain/kotlin/JsTestExecutor.kt | 17 ++-- .../src/jvmMain/kotlin/JvmTestExecutor.kt | 17 ++-- suite/src/commonMain/kotlin/RunTest.kt | 3 +- suite/src/commonMain/kotlin/SuiteDsl.kt | 3 + .../commonMain/kotlin/config/TestConfig.kt | 89 +++++++++++++++++++ 7 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 suite/src/commonMain/kotlin/config/TestConfig.kt diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt index b5a9288..3730e43 100644 --- a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt @@ -8,6 +8,8 @@ import io.kotest.core.spec.style.scopes.addTest import io.kotest.core.test.TestType import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl import kotlin.coroutines.CoroutineContext @@ -40,18 +42,21 @@ import kotlin.coroutines.CoroutineContext * ``` */ @KotestTestScope -fun RootScope.preparedSuite(block: SuiteDsl.() -> Unit) { - NonNestedSuite(this).block() +fun RootScope.preparedSuite( + config: TestConfig = TestConfig.Empty, + block: SuiteDsl.() -> Unit, +) { + NonNestedSuite(this, config).block() } -private class NonNestedSuite(private val root: RootScope, private val prefix: String? = null) : SuiteDsl { - override fun suite(name: String, block: SuiteDsl.() -> Unit) { - NonNestedSuite(root, prefix child name).block() +private class NonNestedSuite(private val root: RootScope, private val parentConfig: TestConfig, private val prefix: String? = null) : SuiteDsl { + override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { + NonNestedSuite(root, parentConfig + config, prefix child name).block() } - override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { + override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { root.addTest(testName = TestName(name = prefix child name), disabled = false, type = TestType.Test, config = null) { - runTestDsl(name, context, block) + runTestDsl(name, context, parentConfig + config, block) } } } diff --git a/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt index dbbbe73..7c62e84 100644 --- a/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt +++ b/runners/runner-kotlin-test/src/commonMain/kotlin/TestExecutor.kt @@ -1,6 +1,7 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.config.TestConfig /** * Entrypoint to declare a [SuiteDsl] executed with [kotlin-test](https://kotlinlang.org/api/latest/kotlin.test/). @@ -26,6 +27,11 @@ import opensavvy.prepared.suite.SuiteDsl */ expect abstract class TestExecutor() { + /** + * Default configuration for all tests [registered][register] in this class. + */ + open val config: TestConfig + /** * Declares a [SuiteDsl] which will be run with `kotlin-test`. * diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt index 70eb9b5..e044ac1 100644 --- a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -2,6 +2,8 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl import kotlin.coroutines.CoroutineContext @@ -13,6 +15,9 @@ private external val kTest: dynamic actual abstract class TestExecutor { + actual open val config: TestConfig + get() = TestConfig.Empty + actual abstract fun SuiteDsl.register() // this test shows up as an empty test that always succeeds in reports, @@ -20,23 +25,23 @@ actual abstract class TestExecutor { @kotlin.test.Test fun registerTests() { kTest.kotlin.test.suite("Class ${this::class.simpleName}", false) { - JsSuiteDsl.register() + JsSuiteDsl(config).register() } } } -private object JsSuiteDsl : SuiteDsl { - override fun suite(name: String, block: SuiteDsl.() -> Unit) { +private class JsSuiteDsl(val parentConfig: TestConfig) : SuiteDsl { + override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { println("Registering suite '$name'…") kTest.kotlin.test.suite(name, false) { - this.block() + JsSuiteDsl(parentConfig + config).block() } } - override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { + override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { println("Registering test '$name'…") kTest.kotlin.test.test(name, false) { - runTestDsl(name, context, block) + runTestDsl(name, context, parentConfig + config, block) } } } diff --git a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt index ddca053..1fe66e0 100644 --- a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt @@ -2,6 +2,8 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl import org.junit.jupiter.api.DynamicContainer import org.junit.jupiter.api.DynamicNode @@ -12,29 +14,32 @@ import kotlin.coroutines.CoroutineContext actual abstract class TestExecutor { + actual open val config: TestConfig + get() = TestConfig.Empty + actual abstract fun SuiteDsl.register() @TestFactory fun suite(): Stream { - val suite = JvmSuiteDsl().apply { register() } + val suite = JvmSuiteDsl(config).apply { register() } return suite.nodes.stream() } } -private class JvmSuiteDsl : SuiteDsl { +private class JvmSuiteDsl(val parentConfig: TestConfig) : SuiteDsl { val nodes = ArrayList() - override fun suite(name: String, block: SuiteDsl.() -> Unit) { - val child = JvmSuiteDsl().apply(block) + override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { + val child = JvmSuiteDsl(parentConfig + config).apply(block) nodes += DynamicContainer.dynamicContainer(name, child.nodes) } - override fun test(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit) { + override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { nodes += DynamicTest.dynamicTest(name) { - runTestDsl(name, context, block) + runTestDsl(name, context, parentConfig + config, block) } } } diff --git a/suite/src/commonMain/kotlin/RunTest.kt b/suite/src/commonMain/kotlin/RunTest.kt index e678bb9..49c1393 100644 --- a/suite/src/commonMain/kotlin/RunTest.kt +++ b/suite/src/commonMain/kotlin/RunTest.kt @@ -3,6 +3,7 @@ package opensavvy.prepared.suite import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.test.TestResult import kotlinx.coroutines.test.runTest +import opensavvy.prepared.suite.config.TestConfig import kotlin.coroutines.CoroutineContext private class TestDslImpl( @@ -15,7 +16,7 @@ private class TestDslImpl( * Regular users of the library should never need to call this function. * It is only provided because it is required for people who implement their own test runner. */ -fun runTestDsl(name: String, context: CoroutineContext, block: suspend TestDsl.() -> Unit): TestResult { +fun runTestDsl(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit): TestResult { return runTest(CoroutineName("Test ‘$name’") + context) { val test = TestDslImpl( environment = TestEnvironment(name, this), diff --git a/suite/src/commonMain/kotlin/SuiteDsl.kt b/suite/src/commonMain/kotlin/SuiteDsl.kt index 14bbcae..9210eb1 100644 --- a/suite/src/commonMain/kotlin/SuiteDsl.kt +++ b/suite/src/commonMain/kotlin/SuiteDsl.kt @@ -1,5 +1,6 @@ package opensavvy.prepared.suite +import opensavvy.prepared.suite.config.TestConfig import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -40,6 +41,7 @@ interface SuiteDsl : PreparedDsl { @PreparedDslMarker fun suite( name: String, + config: TestConfig = TestConfig.Empty, block: SuiteDsl.() -> Unit, ) @@ -50,6 +52,7 @@ interface SuiteDsl : PreparedDsl { fun test( name: String, context: CoroutineContext = EmptyCoroutineContext, + config: TestConfig = TestConfig.Empty, block: suspend TestDsl.() -> Unit, ) diff --git a/suite/src/commonMain/kotlin/config/TestConfig.kt b/suite/src/commonMain/kotlin/config/TestConfig.kt new file mode 100644 index 0000000..b92dd51 --- /dev/null +++ b/suite/src/commonMain/kotlin/config/TestConfig.kt @@ -0,0 +1,89 @@ +package opensavvy.prepared.suite.config + +import opensavvy.prepared.suite.config.TestConfig.* + +/** + * Generic configuration description. + * + * Configuration items are subclasses of [Element], each identified by a unique [Key]. + * Complex configurations are created by combining simpler items using [plus]. + * Accessing a specific item is done using [get]. + * + * ### How to create a new configuration option + * + * Create a class that subclasses [Element] with a companion object that subclasses [Key]. + * You can store any data in this class. + * + * ```kotlin + * class YourConfigurationOption : TestConfig.Element { + * override val key get() = Companion + * + * companion object : TestConfig.Key + * } + * ``` + */ +sealed interface TestConfig { + + /** + * Identifier of an [Element]. + */ + interface Key + + /** + * An arbitrary configuration element. + * + * Elements are grouped by their [key]: in a given [TestConfig], at most one element of a given key can be present. + * To access an element in a [TestConfig], see [get]. + * + * Note that keys are not necessarily correlated with element types; an element type could split its instances + * between two different keys, in which case multiple elements of that type could be a part of the same config + * (but they would still differ by key). + */ + interface Element : TestConfig { + val key: Key<*> + } + + /** + * The empty [TestConfig], useful as a default parameter when no particular configuration is required. + */ + object Empty : TestConfig { + override fun toString() = "TestConfig[]" + } +} + +private data class CombinedTestConfig( + val data: Map, TestConfig>, +) : TestConfig { + + override fun toString() = data.values.joinToString(", ", prefix = "TestConfig[", postfix = "]") +} + +/** + * Finds the [Element] identified by [key] in the current [TestConfig]. + * + * It's not possible for multiple elements to share the same key, so this function can never return multiple results. + * However, no elements may be identified by a [key], in which case `null` is returned. + */ +@Suppress("UNCHECKED_CAST") +operator fun TestConfig.get(key: Key): E? = when (this) { + is Empty -> null + is CombinedTestConfig -> data[key] as E? + is Element -> this.takeIf { this.key == key } as E? +} + +/** + * Combines two [TestConfig] instances. + * + * The elements of [other] always override the elements with the same key from the receiver. + */ +operator fun TestConfig.plus(other: TestConfig): TestConfig = when { + this is Empty -> other + other is Empty -> this + else -> CombinedTestConfig(this.asMap() + other.asMap()) +} + +private fun TestConfig.asMap(): Map, TestConfig> = when (this) { + is Empty -> emptyMap() + is Element -> mapOf(key to this) + is CombinedTestConfig -> data +} -- 2.51.2 From a9536914e97dc375406bf2e1d6d319781b52f519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 1 Nov 2023 18:17:31 +0100 Subject: [PATCH 42/62] feat(suite): Allow ignoring tests --- suite/src/commonMain/kotlin/config/Ignored.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 suite/src/commonMain/kotlin/config/Ignored.kt diff --git a/suite/src/commonMain/kotlin/config/Ignored.kt b/suite/src/commonMain/kotlin/config/Ignored.kt new file mode 100644 index 0000000..6406a98 --- /dev/null +++ b/suite/src/commonMain/kotlin/config/Ignored.kt @@ -0,0 +1,25 @@ +package opensavvy.prepared.suite.config + +/** + * Marks a test or an entire suite as disabled. + * + * ### Example + * + * Mark a suite as disabled: + * ```kotlin + * suite(Ignored) { + * // … + * } + * ``` + * + * Mark a test as disabled: + * ```kotlin + * test("Some kind of test", config = Ignored) { + * // … + * } + * ``` + */ +object Ignored : TestConfig.Element, TestConfig.Key { + override val key: TestConfig.Key<*> + get() = this +} -- 2.51.2 From 304ecdf5c6bd825e21418dc6f29c40c40af23177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 1 Nov 2023 18:18:14 +0100 Subject: [PATCH 43/62] feat(runner-kotlin-test): Respect ignored tests --- .../src/commonTest/kotlin/ExecuteTest.kt | 11 +++++++++++ .../src/jsMain/kotlin/JsTestExecutor.kt | 12 ++++++++---- .../src/jvmMain/kotlin/JvmTestExecutor.kt | 13 ++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt index 1aee6c9..905397e 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -2,6 +2,7 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.cleanUp +import opensavvy.prepared.suite.config.Ignored import opensavvy.prepared.suite.prepared import kotlin.random.Random @@ -39,5 +40,15 @@ class ExecuteTest : TestExecutor() { println("It also executes") } } + + suite("Disabled suite", Ignored) { + test("Always fails") { + error("I should have been ignored") + } + } + + test("Disabled test", config = Ignored) { + error("I should have been ignored") + } } } diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt index e044ac1..e2f2681 100644 --- a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -2,7 +2,9 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.Ignored import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.get import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl import kotlin.coroutines.CoroutineContext @@ -33,15 +35,17 @@ actual abstract class TestExecutor { private class JsSuiteDsl(val parentConfig: TestConfig) : SuiteDsl { override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { println("Registering suite '$name'…") - kTest.kotlin.test.suite(name, false) { - JsSuiteDsl(parentConfig + config).block() + val thisConfig = parentConfig + config + kTest.kotlin.test.suite(name, thisConfig[Ignored] != null) { + JsSuiteDsl(thisConfig).block() } } override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { println("Registering test '$name'…") - kTest.kotlin.test.test(name, false) { - runTestDsl(name, context, parentConfig + config, block) + val thisConfig = parentConfig + config + kTest.kotlin.test.test(name, thisConfig[Ignored] != null) { + runTestDsl(name, context, thisConfig, block) } } } diff --git a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt index 1fe66e0..0c2c840 100644 --- a/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jvmMain/kotlin/JvmTestExecutor.kt @@ -2,13 +2,12 @@ package opensavvy.prepared.runner.kotlin import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.Ignored import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.get import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl -import org.junit.jupiter.api.DynamicContainer -import org.junit.jupiter.api.DynamicNode -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.* import java.util.stream.Stream import kotlin.coroutines.CoroutineContext @@ -38,8 +37,12 @@ private class JvmSuiteDsl(val parentConfig: TestConfig) : SuiteDsl { } override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { + val thisConfig = parentConfig + config nodes += DynamicTest.dynamicTest(name) { - runTestDsl(name, context, parentConfig + config, block) + // Immediately fail the test if it is marked as disabled + Assumptions.assumeTrue(thisConfig[Ignored] == null) + + runTestDsl(name, context, thisConfig, block) } } } -- 2.51.2 From 81edac9f3e6c2659c24bbf3c0189d7d2d38120e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 1 Nov 2023 18:26:04 +0100 Subject: [PATCH 44/62] feat(runner-kotest): Respect ignored tests --- .../src/commonMain/kotlin/PreparedSuite.kt | 13 +++++++++++-- .../src/commonTest/kotlin/KotestTest.kt | 11 +++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt index 3730e43..958cb23 100644 --- a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt @@ -6,9 +6,12 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.core.spec.style.scopes.RootScope import io.kotest.core.spec.style.scopes.addTest import io.kotest.core.test.TestType +import io.kotest.core.test.config.UnresolvedTestConfig import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.Ignored import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.get import opensavvy.prepared.suite.config.plus import opensavvy.prepared.suite.runTestDsl import kotlin.coroutines.CoroutineContext @@ -55,8 +58,14 @@ private class NonNestedSuite(private val root: RootScope, private val parentConf } override fun test(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit) { - root.addTest(testName = TestName(name = prefix child name), disabled = false, type = TestType.Test, config = null) { - runTestDsl(name, context, parentConfig + config, block) + val thisConfig = parentConfig + config + + val kotestConfig = UnresolvedTestConfig( + enabled = thisConfig[Ignored] == null, + ) + + root.addTest(testName = TestName(name = prefix child name), disabled = false, type = TestType.Test, config = kotestConfig) { + runTestDsl(name, context, thisConfig, block) } } } diff --git a/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt index 8901e0d..022a3cb 100644 --- a/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt +++ b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt @@ -3,6 +3,7 @@ package opensavvy.prepared.runner.kotest import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay +import opensavvy.prepared.suite.config.Ignored class KotestTest : StringSpec({ // Vanilla Kotest declarations… @@ -32,5 +33,15 @@ class KotestTest : StringSpec({ test("Hello world from Prepared") { "hello".length shouldBe 5 } + + suite("Disabled suite", Ignored) { + test("Always fails") { + error("I should have been ignored") + } + } + + test("Disabled test", config = Ignored) { + error("I should have been ignored") + } } }) -- 2.51.2 From 018a39bb61e0587901d07938482def48d8f6f711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 1 Nov 2023 18:42:16 +0100 Subject: [PATCH 45/62] feat(suite): Configurable coroutine timeout --- suite/src/commonMain/kotlin/RunTest.kt | 3 +- .../kotlin/config/CoroutineTimeout.kt | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 suite/src/commonMain/kotlin/config/CoroutineTimeout.kt diff --git a/suite/src/commonMain/kotlin/RunTest.kt b/suite/src/commonMain/kotlin/RunTest.kt index 49c1393..cfd703b 100644 --- a/suite/src/commonMain/kotlin/RunTest.kt +++ b/suite/src/commonMain/kotlin/RunTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.test.TestResult import kotlinx.coroutines.test.runTest import opensavvy.prepared.suite.config.TestConfig +import opensavvy.prepared.suite.config.effectiveTimeout import kotlin.coroutines.CoroutineContext private class TestDslImpl( @@ -17,7 +18,7 @@ private class TestDslImpl( * It is only provided because it is required for people who implement their own test runner. */ fun runTestDsl(name: String, context: CoroutineContext, config: TestConfig, block: suspend TestDsl.() -> Unit): TestResult { - return runTest(CoroutineName("Test ‘$name’") + context) { + return runTest(CoroutineName("Test ‘$name’") + context, timeout = config.effectiveTimeout()) { val test = TestDslImpl( environment = TestEnvironment(name, this), ) diff --git a/suite/src/commonMain/kotlin/config/CoroutineTimeout.kt b/suite/src/commonMain/kotlin/config/CoroutineTimeout.kt new file mode 100644 index 0000000..6cb7ea9 --- /dev/null +++ b/suite/src/commonMain/kotlin/config/CoroutineTimeout.kt @@ -0,0 +1,35 @@ +package opensavvy.prepared.suite.config + +import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.launch +import opensavvy.prepared.suite.launchInBackground +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Defines for how long nothing is allowed to happen before the test is automatically stopped. + * + * Note that this only applies to nothing happening in the coroutine dispatcher managed by the Coroutines + * system: [launch][TestDsl.launch], [launchInBackground][TestDsl.launchInBackground], etc. + * + * For example, this test never finishes, but will not be stopped by this timeout, because coroutines are still being + * executed: + * ```kotlin + * while(true) { + * delay(1000) + * } + * ``` + */ +data class CoroutineTimeout( + val duration: Duration, +) : TestConfig.Element { + + override val key: TestConfig.Key<*> + get() = Companion + + companion object : TestConfig.Key { + val Default = 10.seconds + } +} + +internal fun TestConfig.effectiveTimeout() = get(CoroutineTimeout)?.duration ?: CoroutineTimeout.Default -- 2.51.2 From 291049440e8ef80367e4a0a8217514996da7d006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 11:54:30 +0100 Subject: [PATCH 46/62] refactor(compat-filesystem): Shorter names for prepared values --- compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt b/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt index 7ca7662..9a533ea 100644 --- a/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt +++ b/compat/compat-filesystem/src/jvmMain/kotlin/Accessors.kt @@ -17,7 +17,7 @@ operator fun File.div(child: String): File = File(this, child) * Accesses a file named [child] in the provided directory. */ @JvmName("childFile") -operator fun Prepared.div(child: String): Prepared = map("$name / $child") { it / child } +operator fun Prepared.div(child: String): Prepared = map("$name/$child") { it / child } /** * Accesses a file named [child] in the provided directory. @@ -37,7 +37,7 @@ operator fun Path.div(child: String): Path = resolve(child) * Accesses a file named [child] in the provided directory. */ @JvmName("childPath") -operator fun Prepared.div(child: String): Prepared = map("$name / $child") { it / child } +operator fun Prepared.div(child: String): Prepared = map("$name/$child") { it / child } /** * Accesses a file named [child] in the provided directory. -- 2.51.2 From 8510f11e13a125cd85d55fe267ef4630c04ccd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 12:08:45 +0100 Subject: [PATCH 47/62] feat(kotlinx.datetime): Add delayUntil(String) --- .../src/commonMain/kotlin/KotlinTime.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt index 5b8f56a..63bcbbb 100644 --- a/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt +++ b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt @@ -60,3 +60,11 @@ suspend fun Time.delayUntil(instant: Instant) { require(diff >= 0) { "Cannot delay until $instant, which is in the past of the current virtual time, $now" } delay(diff) } + +/** + * Delays until the virtual time reaches [isoString], formatted as an ISO 8601 timestamp, executing all enqueued tasks in order. + */ +@ExperimentalCoroutinesApi +suspend fun Time.delayUntil(isoString: String) { + delayUntil(Instant.parse(isoString)) +} -- 2.51.2 From 7c0fdbe934d218a7e8441fd4ba68126c56ed77d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 12:28:31 +0100 Subject: [PATCH 48/62] docs(kotlinx.datetime): Module-level documentation --- compat/compat-kotlinx-datetime/README.md | 57 ++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 compat/compat-kotlinx-datetime/README.md diff --git a/compat/compat-kotlinx-datetime/README.md b/compat/compat-kotlinx-datetime/README.md new file mode 100644 index 0000000..2ad8d57 --- /dev/null +++ b/compat/compat-kotlinx-datetime/README.md @@ -0,0 +1,57 @@ +# Module Compatibility with KotlinX.Datetime + +Control the virtual time during tests using KotlinX.Datetime. + + + +Builds upon the [virtual time control][opensavvy.prepared.suite.time] available out-of-the-box to allow +[instancing clocks][opensavvy.prepared.compat.kotlinx.datetime.clock], [setting the current time][opensavvy.prepared.compat.kotlinx.datetime.set] or [waiting for a given time][opensavvy.prepared.compat.kotlinx.datetime.delayUntil]. + +## Example + +We want to test a fictional `Scheduler` implemented using [KotlinX.Coroutines](https://kotlinlang.org/docs/coroutines-guide.html). +The scheduler has been implemented in a way that allows to inject the clock and the coroutine context. + +First, the scheduler requires access to some kind of database. +We [prepare][opensavvy.prepared.suite.prepared] the connection to avoid copy-pasting it in each test, while still +ensuring each test gets its own instance. + +```kotlin +val prepareDatabase by prepared { + Database.connect() +} +``` + +To allow writing multiple tests using the same scheduler, we also declare it as a prepared value. +We can inject the database using the previous prepared value. +To let the scheduler access the virtual time, we inject the [virtual time clock][opensavvy.prepared.compat.kotlinx.datetime.clock]. +To ensure the scheduler can start coroutines with delay-skipping that the test waits for, we inject the [foreground coroutine scope][opensavvy.prepared.suite.foregroundScope]. + +```kotlin +val prepareScheduler by prepared { + Scheduler( + database = prepareDatabase(), + clock = time.clock, + coroutineContext = foregroundScope, + ) +} +``` + +Now, we can use the helper functions [to set the current time][opensavvy.prepared.compat.kotlinx.datetime.set] +and to [wait until a specific time][opensavvy.prepared.compat.kotlinx.datetime.delayUntil]. + +```kotlin +test("A test that uses the time") { + time.set("2023-11-08T12:00:00Z") + + val scheduler = prepareScheduler() + + var executed = false + scheduler.scheduleAt("2023-11-08T12:05:00Z") { + executed = true + } + + time.delayUntil("2023-11-08T12:06:00Z") + executed shouldBe true +} +``` -- 2.51.2 From 422dbb0816eeec13c7e49ff0e2e5674bfcd56b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 12:36:25 +0100 Subject: [PATCH 49/62] docs(kotlinx.datetime): Add examples --- .../src/commonMain/kotlin/KotlinTime.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt index 63bcbbb..dab19e0 100644 --- a/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt +++ b/compat/compat-kotlinx-datetime/src/commonMain/kotlin/KotlinTime.kt @@ -17,6 +17,17 @@ private class KotlinClock(private val scheduler: TestCoroutineScheduler) : Clock /** * Creates a [Clock] that follows the virtual time in this test. + * + * ### Example + * + * ```kotlin + * test("Pass the time to an external service") { + * val service = SomeExternalService(time.clock) + * } + * ``` + * + * @see now Access the current time + * @see set Set the current time */ @ExperimentalCoroutinesApi val Time.clock: Clock @@ -24,6 +35,16 @@ val Time.clock: Clock /** * Accesses the current virtual time within this test, as an [Instant]. + * + * ### Example + * + * ```kotlin + * test("Access the current time") { + * println(time.now) + * } + * ``` + * + * @see clock Pass a way to access the time to another system */ @ExperimentalCoroutinesApi val Time.now: Instant @@ -45,6 +66,20 @@ fun Time.set(instant: Instant) { * Advances the virtual time until it reaches [isoString], formatted as an ISO 8601 timestamp. * * It is not possible to set the time to a date in the past. + * + * ### Example + * + * ```kotlin + * test("Everything should behave the same on December 31st") { + * time.set("2022-12-31T23:37:00Z") + * + * // … + * } + * ``` + * + * @see now Access the current time + * @see delay Wait for some duration + * @see delayUntil Wait for a specific time */ @ExperimentalCoroutinesApi fun Time.set(isoString: String) { @@ -63,6 +98,9 @@ suspend fun Time.delayUntil(instant: Instant) { /** * Delays until the virtual time reaches [isoString], formatted as an ISO 8601 timestamp, executing all enqueued tasks in order. + * + * @see set Set the current time + * @see now Access the current time */ @ExperimentalCoroutinesApi suspend fun Time.delayUntil(isoString: String) { -- 2.51.2 From ad6408e2eba4fc3a8244372c8ab6185b971849ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 12:57:24 +0100 Subject: [PATCH 50/62] refactor(compat-arrow): Allow using failOnRaise outside the TestDSL --- compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt index 57dd3de..a719ee1 100644 --- a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt +++ b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt @@ -5,15 +5,13 @@ import arrow.core.raise.ExperimentalTraceApi import arrow.core.raise.Raise import arrow.core.raise.either import opensavvy.prepared.suite.PreparedDslMarker -import opensavvy.prepared.suite.TestDsl /** * Fails the test if [block] raises. */ -@Suppress("UnusedReceiverParameter") // we're keeping the receiver for scoping @ExperimentalTraceApi @PreparedDslMarker -inline fun TestDsl.failOnRaise(block: Raise.() -> Success): Success { +inline fun failOnRaise(block: Raise.() -> Success): Success { return either(block) .getOrElse { throw AssertionError("Expected the block to execute successfully, but a value was raised: $it") } } -- 2.51.2 From adc6b32b61b387a323aabb630a980f068eac3099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 12:59:25 +0100 Subject: [PATCH 51/62] feat(compat-arrow): Helpers to ensure tests fail as expected --- .../src/commonMain/kotlin/EnsureRaises.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt diff --git a/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt b/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt new file mode 100644 index 0000000..13f1310 --- /dev/null +++ b/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt @@ -0,0 +1,42 @@ +package opensavvy.prepared.compat.arrow.core + +import arrow.core.raise.ExperimentalTraceApi +import arrow.core.raise.Raise +import arrow.core.raise.either +import opensavvy.prepared.suite.PreparedDslMarker + +/** + * Fails the test if [block] doesn't raise with [expected]. + */ +@ExperimentalTraceApi +@PreparedDslMarker +inline fun assertRaises(expected: Failure, block: Raise.() -> Any?) { + either(block).fold( + ifLeft = { + if (it != expected) + throw AssertionError("Expected to fail with $expected, but failed with $it") + // else: successful case + }, + ifRight = { + throw AssertionError("Expected to fail with $expected, but the operation was successful and returned $it") + }, + ) +} + +/** + * Fails the test if [block] doesn't raise with a value of type [Failure]. + */ +@ExperimentalTraceApi +@PreparedDslMarker +inline fun assertRaisesWith(block: Raise.() -> Any?) { + either(block).fold( + ifLeft = { + if (it !is Failure) + throw AssertionError("Expected to fail with ${Failure::class}, but failed with $it") + // else: successful case + }, + ifRight = { + throw AssertionError("Expected to fail with ${Failure::class}, but the operation was successful and returned $it") + }, + ) +} -- 2.51.2 From e2b8f421e2ad617d3a23473c6a46291597c0c4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 13:01:43 +0100 Subject: [PATCH 52/62] docs(compat-arrow): Add examples --- .../src/commonMain/kotlin/EnsureRaises.kt | 20 +++++++++++++++++++ .../src/commonMain/kotlin/FailOnRaise.kt | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt b/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt index 13f1310..c315047 100644 --- a/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt +++ b/compat/compat-arrow/src/commonMain/kotlin/EnsureRaises.kt @@ -7,6 +7,16 @@ import opensavvy.prepared.suite.PreparedDslMarker /** * Fails the test if [block] doesn't raise with [expected]. + * + * ### Example + * + * ```kotlin + * test("√-1 raises") { + * assertRaises(NegativeSquareRoot) { + * sqrt(-1.0) + * } + * } + * ``` */ @ExperimentalTraceApi @PreparedDslMarker @@ -25,6 +35,16 @@ inline fun assertRaises(expected: Failure, block: Raise.() -> /** * Fails the test if [block] doesn't raise with a value of type [Failure]. + * + * ### Example + * + * ```kotlin + * test("√-1 raises") { + * assertRaisesWith { + * sqrt(-1.0) + * } + * } + * ``` */ @ExperimentalTraceApi @PreparedDslMarker diff --git a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt index a719ee1..5dbe727 100644 --- a/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt +++ b/compat/compat-arrow/src/commonMain/kotlin/FailOnRaise.kt @@ -8,6 +8,16 @@ import opensavvy.prepared.suite.PreparedDslMarker /** * Fails the test if [block] raises. + * + * ### Example + * + * ```kotlin + * test("√4 does not raise") { + * failOnRaise { + * sqrt(4.0) + * } shouldBe 2.0 + * } + * ``` */ @ExperimentalTraceApi @PreparedDslMarker -- 2.51.2 From bfdf8e54be04e911343bea91716938cbaed87816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 13:23:31 +0100 Subject: [PATCH 53/62] docs(compat-arrow): Module-level documentation --- compat/compat-arrow/README.md | 45 ++++++++++++++++++++++++++++ compat/compat-arrow/build.gradle.kts | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 compat/compat-arrow/README.md diff --git a/compat/compat-arrow/README.md b/compat/compat-arrow/README.md new file mode 100644 index 0000000..f62b930 --- /dev/null +++ b/compat/compat-arrow/README.md @@ -0,0 +1,45 @@ +# Module Compatibility with Arrow + +Helpers to fail tests when a function raises. + + + +### Example + +Let's assume we want to test a function which raises when it receives a negative number: + +```kotlin +data object NegativeSquareRoot + +context(Raise) +fun sqrt(value: Double): Double { + ensure(value >= 0) { NegativeSquareRoot } + return kotlin.math.sqrt(value) +} +``` + +We can write a test that ensures the function does not raise, using [failOnRaise][opensavvy.prepared.compat.arrow.core.failOnRaise]: + +```kotlin +test("√4 does not raise") { + failOnRaise { + sqrt(4.0) + } shouldBe 2.0 +} +``` + +We can write a test that ensures the function does raise, using [assertRaises][opensavvy.prepared.compat.arrow.core.assertRaises] or [assertRaisesWith][opensavvy.prepared.compat.arrow.core.assertRaisesWith]: + +```kotlin +test("√-1 raises") { + // assert raises a specific value + assertRaises(NegativeSquareRoot) { + sqrt(-1.0) + } + + // assert raises any value of a specific type + assertRaisesWith { + sqrt(-1.0) + } +} +``` diff --git a/compat/compat-arrow/build.gradle.kts b/compat/compat-arrow/build.gradle.kts index 921b01d..801d718 100644 --- a/compat/compat-arrow/build.gradle.kts +++ b/compat/compat-arrow/build.gradle.kts @@ -34,7 +34,7 @@ kotlin { } library { - name.set("Functional error management with Arrow") + name.set("Compatibility with Arrow") description.set("Bind Either and Validated to fail test cases, with origin tracing") homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-arrow/index.html") } -- 2.51.2 From 0f3b4578c0f8a44544136ec5fc5fdb8c36e75433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 13:54:29 +0100 Subject: [PATCH 54/62] refactor(runner-kotlin-test): Keep the suite hierarchy in the suite name --- .../src/jsMain/kotlin/JsTestExecutor.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt index e2f2681..7f904b1 100644 --- a/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt +++ b/runners/runner-kotlin-test/src/jsMain/kotlin/JsTestExecutor.kt @@ -26,18 +26,20 @@ actual abstract class TestExecutor { // but we need it for the class to be discovered @kotlin.test.Test fun registerTests() { - kTest.kotlin.test.suite("Class ${this::class.simpleName}", false) { - JsSuiteDsl(config).register() + val name = "Class ${this::class.simpleName}" + kTest.kotlin.test.suite(name, false) { + JsSuiteDsl(name, config).register() } } } -private class JsSuiteDsl(val parentConfig: TestConfig) : SuiteDsl { +private class JsSuiteDsl(val suiteName: String, val parentConfig: TestConfig) : SuiteDsl { override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { println("Registering suite '$name'…") val thisConfig = parentConfig + config - kTest.kotlin.test.suite(name, thisConfig[Ignored] != null) { - JsSuiteDsl(thisConfig).block() + val thisName = "$suiteName • $name" + kTest.kotlin.test.suite(thisName, thisConfig[Ignored] != null) { + JsSuiteDsl(thisName, thisConfig).block() } } -- 2.51.2 From 022c62c646178c2e4fd825fa5300b86c87f61c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 13:55:17 +0100 Subject: [PATCH 55/62] docs(runner-kotlin-test): Module-level documentation --- runners/runner-kotlin-test/README.js.md | 20 +++++++++ runners/runner-kotlin-test/README.jvm.md | 34 ++++++++++++++++ runners/runner-kotlin-test/README.md | 52 ++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 runners/runner-kotlin-test/README.js.md create mode 100644 runners/runner-kotlin-test/README.jvm.md create mode 100644 runners/runner-kotlin-test/README.md diff --git a/runners/runner-kotlin-test/README.js.md b/runners/runner-kotlin-test/README.js.md new file mode 100644 index 0000000..b9b4b83 --- /dev/null +++ b/runners/runner-kotlin-test/README.js.md @@ -0,0 +1,20 @@ +# Module Execute with Kotlin-test + +Execute Prepared tests alongside tests written using the standard test library. + + + +## How it works + +Because JavaScript doesn't have a concept of visibility, we are able to directly access the internals of kotlin-test +and declare tests ourselves. +To learn more, see [this prototype](https://youtrack.jetbrains.com/issue/KT-46899/Dynamic-test-API#focus=Comments-27-6859886.0-0). + +## Limitations + +Since kotlin-test does not support nested test suites (as tests normally can only be declared in classes), +the JS implementation doesn't allow nested test suites either. +All suites declared using the Prepared syntax are thus un-nested and executed at the top-level. + +Because this accesses the internals of the library directly, there is no guarantee that this keeps working in the future! +Please vote for [KT-46899](https://youtrack.jetbrains.com/issue/KT-46899/Dynamic-test-API#focus=Comments-27-6859886.0-0). diff --git a/runners/runner-kotlin-test/README.jvm.md b/runners/runner-kotlin-test/README.jvm.md new file mode 100644 index 0000000..78c0bb5 --- /dev/null +++ b/runners/runner-kotlin-test/README.jvm.md @@ -0,0 +1,34 @@ +# Module Execute with Kotlin-test + +Execute Prepared tests alongside tests written using the standard test library. + + + +## How it works + +We use the [JUnit5 dynamic test API][org.junit.jupiter.api.DynamicNode] to declare tests. + +## Limitations + +This is only compatible with JUnit5, not JUnit4. +To ensure your tests run, remember to configure Gradle: + +```kotlin +// For kotlin("jvm") +tasks.test { + useJUnitPlatform() +} +``` + +```kotlin +// For kotlin("multiplatform") +kotlin { + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } +} +``` diff --git a/runners/runner-kotlin-test/README.md b/runners/runner-kotlin-test/README.md new file mode 100644 index 0000000..9fc5795 --- /dev/null +++ b/runners/runner-kotlin-test/README.md @@ -0,0 +1,52 @@ +# Module Execute with Kotlin-test + +Execute Prepared tests alongside tests written using the standard test library. + + + +Prepared tests are declared in subclasses of [TestExecutor][opensavvy.prepared.runner.kotlin.TestExecutor]. + +```kotlin +class FooTest : TestExecutor() { + + // Declare tests normally using the + // @Test annotation + @Test + fun helloWorld() { + assertEquals("Hello world", "Hello world") + } + + // Declare tests using the Prepared syntax + // in the 'register' function + override fun SuiteDsl.register() { + test("Hello world") { + assertEquals("Hello world", "Hello world") + } + + suite("A group of related tests") { + test("Control the time") { + println("Current time: ${time.nowMillis}ms") + } + + test("Control randomness") { + random.setSeed(1) + + println("Random value: ${random.nextInt()}") + } + } + } +} +``` + +## Implementation notes + +The kotlin-test library doesn't allow declaring tests dynamically. + +- On the JVM, we bypass using kotlin-test, and declare tests to JUnit5 directly. +- On JS, we access the internals of kotlin-test to declare tests directly. +- On Native, we haven't found a way to declare tests dynamically—so this module doesn't support Native. + +For this library to be usable in the long term, and in all platforms, we need JetBrains to provide a low-level way to +declare tests dynamically for all platforms. If you'd like to support this, please vote for [KT-46899](https://youtrack.jetbrains.com/issue/KT-46899/Dynamic-test-API). + +To learn more about the platform-specific limitations, select a platform at the top of this page (below the title). -- 2.51.2 From 5ef21afe76415bbbb046a281ea4ae1edae820049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 14:09:40 +0100 Subject: [PATCH 56/62] docs(runner-kotest): Module-level documentation --- runners/runner-kotest/README.md | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 runners/runner-kotest/README.md diff --git a/runners/runner-kotest/README.md b/runners/runner-kotest/README.md new file mode 100644 index 0000000..913a8aa --- /dev/null +++ b/runners/runner-kotest/README.md @@ -0,0 +1,49 @@ +# Module Execute with Kotest + +Execute Prepared tests alongside tests written using Kotest. + + + +Prepared tests are declared using the helper [preparedSuite][opensavvy.prepared.runner.kotest.preparedSuite]. + +```kotlin +class FooTest : StringSpec({ + // Declare tests normally using the Kotest syntax + "Hello world" { + "Hello world" shouldBe "Hello world" + } + + // Declare tests using the Prepared syntax + // using the 'preparedSuite' function + preparedSuite { + test("Hello world") { + "Hello world" shouldBe "Hello world" + } + + suite("A group of related tests") { + test("Control the time") { + println("Current time: ${time.nowMillis}ms") + } + + test("Control randomness") { + random.setSeed(1) + + println("Random value: ${random.nextInt()}") + } + } + } +}) +``` + +## Setup + +Follow the [Kotest setup guide](https://kotest.io/docs/framework/project-setup.html), simply adding a dependency on this +module as well. + +## Limitations + +Kotest expects nested suites to be `suspend`. Because of this, Kotest cannot allow nested suites on Kotlin/JS. +Prepared supports nested suites on all platforms; they are un-nested automatically when executing with Kotest. + +Kotest doesn't expose the underlying dispatcher from [KotlinX.Coroutines.test](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/). Prepared needs access to it to implement the [backgroundScope][opensavvy.prepared.suite.backgroundScope] and [time control][opensavvy.prepared.suite.time]. This forces us to declare our own scheduler. +This is broken on Kotlin/JS at the moment, please see [#12](https://gitlab.com/opensavvy/prepared/-/issues/12), we welcome contributions! -- 2.51.2 From 55ceca575b821b445a5ecc63ee57e78e5614f551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:32:09 +0100 Subject: [PATCH 57/62] style(suite): Typo in the Random warning message --- suite/src/commonMain/kotlin/Random.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suite/src/commonMain/kotlin/Random.kt b/suite/src/commonMain/kotlin/Random.kt index 27366ec..debebe3 100644 --- a/suite/src/commonMain/kotlin/Random.kt +++ b/suite/src/commonMain/kotlin/Random.kt @@ -313,7 +313,7 @@ fun randomFloat() = prepared { nextFloat() } // endregion // region Warn on usages of kotlin.random.Random in tests -private const val DEPRECATION_MESSAGE_WRONG_RANDOM = "Using Random in a Prepared test is most likely a mistake. Either opt-in to using the Prepared reproducible opt-in generators (via the 'random' accessor in a test) or explicitly use kotlin.random.Random via qualified name." +private const val DEPRECATION_MESSAGE_WRONG_RANDOM = "Using Random in a Prepared test is most likely a mistake. Either opt-in to using the Prepared reproducible random generators (via the 'random' accessor in a test) or explicitly use kotlin.random.Random via qualified name to suppress this warning." /** * See [random]. -- 2.51.2 From f49b3c84dc3f329a7c35721cae11c63325d14ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:32:57 +0100 Subject: [PATCH 58/62] docs(suite): Stop recommending uncontrolled randomness in the Prepared documentation --- suite/src/commonMain/kotlin/Prepared.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 530e924..31a14b9 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -15,7 +15,7 @@ import kotlin.reflect.KProperty * distinct instance. Within a given test, however, the value is always the same: * ```kotlin * suite("Random integers") { - * val randomInteger by prepared { Random.nextInt() } + * val randomInteger by prepared { random.nextInt() } * * test("First test") { * println(randomInteger()) // some integer @@ -31,6 +31,8 @@ import kotlin.reflect.KProperty * Prepared values are constructed lazily when they are accessed within a test. * Because of this, they have access to the test's [TestDsl] and can `suspend`. * + * For the specific use-case of generating random values, see [random][TestDsl.random]. + * * ### Comparison with other frameworks * * Test frameworks usually provide a construct like `@BeforeTest` or similar. -- 2.51.2 From 6917ee1f84df7cddf9042c6375c8c128ac55aa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:33:26 +0100 Subject: [PATCH 59/62] refactor(suite): Reduce the Prepared API surface --- suite/src/commonMain/kotlin/Prepared.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 31a14b9..08a0b59 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -86,7 +86,7 @@ class Prepared internal constructor( /** * See [prepared]. */ -class PreparedDelegate( +class PreparedDelegate internal constructor( private val value: Prepared, ) { operator fun getValue(thisRef: Any?, property: KProperty<*>) = value @@ -118,7 +118,7 @@ class PreparedDelegate( * * It is also possible to use a provider to generate values without binding them to a [Prepared] instance; see [TestDsl.immediate]. */ -class PreparedProvider( +class PreparedProvider internal constructor( internal val block: suspend TestDsl.() -> T, ) { /** -- 2.51.2 From deef43d4dd516721c7953ec828e1508d2d566266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:35:33 +0100 Subject: [PATCH 60/62] feat(suite): Create Shared values --- .../src/commonTest/kotlin/ExecuteTest.kt | 12 ++ suite/src/commonMain/kotlin/Prepared.kt | 2 +- suite/src/commonMain/kotlin/Shared.kt | 193 ++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 suite/src/commonMain/kotlin/Shared.kt diff --git a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt index 905397e..94d23b4 100644 --- a/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt +++ b/runners/runner-kotlin-test/src/commonTest/kotlin/ExecuteTest.kt @@ -1,9 +1,11 @@ package opensavvy.prepared.runner.kotlin +import kotlinx.coroutines.delay import opensavvy.prepared.suite.SuiteDsl import opensavvy.prepared.suite.cleanUp import opensavvy.prepared.suite.config.Ignored import opensavvy.prepared.suite.prepared +import opensavvy.prepared.suite.shared import kotlin.random.Random @Suppress("unused") @@ -23,9 +25,14 @@ class ExecuteTest : TestExecutor() { Random.nextInt() * factor() } + val longTask by shared { + delay(10_000) + } + suite("Group of tests") { test("Test 1") { integer() + longTask() cleanUp("Stop the database") { println("Done") @@ -39,6 +46,11 @@ class ExecuteTest : TestExecutor() { integer() println("It also executes") } + + test("Test 3") { + longTask() + println("It also executes") + } } suite("Disabled suite", Ignored) { diff --git a/suite/src/commonMain/kotlin/Prepared.kt b/suite/src/commonMain/kotlin/Prepared.kt index 08a0b59..3da823b 100644 --- a/suite/src/commonMain/kotlin/Prepared.kt +++ b/suite/src/commonMain/kotlin/Prepared.kt @@ -7,7 +7,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlin.reflect.KProperty /** - * Represents a value that is lazily generated during test execution. + * Lazily-generated value unique to a test case. * * ### Usage * diff --git a/suite/src/commonMain/kotlin/Shared.kt b/suite/src/commonMain/kotlin/Shared.kt new file mode 100644 index 0000000..347fc02 --- /dev/null +++ b/suite/src/commonMain/kotlin/Shared.kt @@ -0,0 +1,193 @@ +package opensavvy.prepared.suite + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.reflect.KProperty + +/** + * Pure, immutable lazy value which is shared between all tests. + * + * Most of the time, we recommend using [Prepared] instead. + * + * ### Usage + * + * This helper allows to declare values that are needed by multiple tests. + * The first test to access this value computes it, after which all other tests access the same value. + * + * ```kotlin + * suite("Random integers") { + * val randomInteger by shared { someLongOperation() } + * + * test("First test") { + * println(randomInteger()) // some integer + * println(randomInteger()) // the same integer + * } + * + * test("Second test") { + * println(randomInteger()) // still the same integer + * } + * } + * ``` + * Notice the difference with [Prepared]: + * - using `Prepared`, all calls within the same test give the same value, but each test gets its own value. + * - using `Shared`, all calls give the same value, even if they are in different tests. + * + * Values are instantiated using the [shared] helper. + * + * ### When to use + * + * Only use this class to hold values that are deeply immutable, are produced by pure operations, and are too costly + * to rerun every test. + * + * **Deep immutability** is necessary because otherwise a test could modify the value and change the behavior of + * another test. + * + * **Produced by pure operations** because the value is only computed once in the context of the first test which + * accesses it. Side effects, if any, will only be observable in the first test. + * + * Additionally, **shared values cannot access most features of this library**, including [time control][TestDsl.time], + * [randomness control][TestDsl.random], [finalizers][TestDsl.cleanUp], etc. + * This is because these features are based on side effects, which this class swallows silently. + * + * To summarize: + * - if the value is mutable, use [Prepared] to ensure tests stay independent. + * - if the value is produced using a side effect, use [Prepared] to ensure all tests can observe it. + * - if the value is cheap to produce, use [Prepared] because tests being independent make them easier to debug. + */ +class Shared internal constructor( + val name: String, + private val block: suspend () -> T, +) { + + private val lock = Mutex() + private var result: Option = Option.Empty + + /** + * Computes the shared value, or returns the cached value if it has already been computed. + */ + suspend operator fun invoke(): T { + var fromHere: Boolean + lock.withLock { + if (result is Option.Empty) { + result = Option.Present(block()) + fromHere = true + } else { + fromHere = false + } + } + + 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}’: ${stored.value} " + if (fromHere) "(initialized by this test)" else "(reusing an already initialized value)") + return stored.value + } + + // 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() + } +} + +/** + * See [shared]. + */ +class SharedDelegate internal constructor( + private val value: Shared, +) { + operator fun getValue(thisRef: Any?, property: KProperty<*>) = value +} + +/** + * A [Shared] is a lazily-created value that is reused between tests. + * + * > Most of the time, [PreparedProvider] should be preferred to this class. + * > To learn why, see [Shared]. + * + * Although [SharedProvider] is conceptually equivalent to [PreparedProvider], and can be used + * to generate multiple [Shared] instances from the same block in exactly the same way, + * this is not recommended. + * + * This is because there is no way to parameterize the encapsulated block which is used to generate the values. + * Therefore, the only way for the block to give different values on each execution is if it relies on side + * effects. However, as explained in the [Shared] documentation, shared values should not be used when + * side effects are present. Instead, using [Prepared] and [PreparedProvider], which encapsulate side effects safely. + * + * This class exists because of its other use-case: capturing the name of the property it is instantiated to, + * using the `by` keyword. See [shared] and [provideDelegate]. + */ +class SharedProvider internal constructor( + private val block: suspend () -> T, +) { + + /** + * Provides a [Shared] value instance bound to the given [name]. + * + * This is not recommended, because it likely means you are relying on side effects. + * See [Shared] and [SharedProvider] for an explanation. + */ + @Deprecated("The primary use-case for this method is to generate multiple shared values from a single provider. This implies you are relying on side effects in the shared value generation. This is not recommended. See the documentation of Shared and SharedProvider to learn more.") + fun named(name: String) = + Shared(name = name, block = block) + + /** + * Provides a [Shared] instance bound to the given [property]. + * + * @see shared + */ + operator fun provideDelegate(thisRef: Any?, property: KProperty<*>) = + SharedDelegate(Shared(property.name, block)) +} + +/** + * Declares a lazily-computed value that is constructed by calling [block], and is then shared between all tests. + * + * > Most of the time, [prepared] should be preferred to this helper. To learn why, see [Shared]. + * + * The shared value returned by this function is automatically named after the variable it is stored in. + * + * For more information, see [Shared]. + * + * ### Example + * + * ```kotlin + * val precompute by shared { longRunningOperation() } + * ``` + */ +@PreparedDslMarker +fun shared( + context: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> T, +) = SharedProvider { + withContext(context) { + block() + } +} + +/** + * Declares a lazily-computer value called [name] that is constructed by calling [block], and is then shared between all tests. + * + * > Most of the time, [prepared] should be preferred to this helper. To learn why, see [Shared]. + * + * For more information, see [Shared]. + * + * ### Example + * + * ``` + * val precompute by shared("A costly operation") { longRunningOperation() } + * ``` + */ +@PreparedDslMarker +fun shared( + name: String, + context: CoroutineContext = EmptyCoroutineContext, + block: suspend () -> T, +) = Shared(name) { + withContext(context) { + block() + } +} -- 2.51.2 From f3c61d42aa4dfe11ecb7a173ecb09f81fc9b0bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:51:40 +0100 Subject: [PATCH 61/62] refactor(compat-java-time): Move the java.time compatibility to its own module --- build.gradle.kts | 1 + compat/compat-java-time/README.jvm.md | 36 +++++++++++++++++++ compat/compat-java-time/build.gradle.kts | 27 ++++++++++++++ .../src/jvmMain/kotlin/JavaTime.kt | 5 ++- settings.gradle.kts | 1 + suite/README.jvm.md | 5 --- 6 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 compat/compat-java-time/README.jvm.md create mode 100644 compat/compat-java-time/build.gradle.kts rename {suite => compat/compat-java-time}/src/jvmMain/kotlin/JavaTime.kt (90%) delete mode 100644 suite/README.jvm.md diff --git a/build.gradle.kts b/build.gradle.kts index 737b539..5ac6dcf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { dokkatoo(projects.framework) dokkatoo(projects.compat.compatGradle) dokkatoo(projects.compat.compatKotlinxDatetime) + dokkatoo(projects.compat.compatJavaTime) dokkatoo(projects.compat.compatFilesystem) dokkatoo(projects.compat.compatArrow) diff --git a/compat/compat-java-time/README.jvm.md b/compat/compat-java-time/README.jvm.md new file mode 100644 index 0000000..7796735 --- /dev/null +++ b/compat/compat-java-time/README.jvm.md @@ -0,0 +1,36 @@ +# Module Compatibility with java.time + +Control the virtual time during tests using the `java.time` package. + + + +Builds upon the [virtual time control][opensavvy.prepared.suite.time] available out-of-the-box to allow +[instancing clocks][opensavvy.prepared.compat.java.time.clockJava], [setting the current time][opensavvy.prepared.compat.java.time.set] or [waiting for a given time][opensavvy.prepared.compat.java.time.delayUntil]. + +## Example + +We want to test a Java class that makes computations based on the current time. + +To allow writing multiple tests using the same class, we declare it as a [prepared value][opensavvy.prepared.suite.prepared]. +To ensure it has access to the virtual time, we inject the [Java clock][opensavvy.prepared.compat.java.time.clockJava]. + +```kotlin +val prepareComputer by prepared { + TimeComputer( + clock = time.clockJava, + ) +} +``` + +Now, we can use the helper functions [to set the current time][opensavvy.prepared.compat.java.time.set] +and to [wait until a specific time][opensavvy.prepared.compat.java.time.delayUntil]. + +```kotlin +test("A test that uses the time") { + time.set(Instant.parse("2023-11-08T12:00:00Z")) + + val computer = prepareComputer() + + computer.isInTheFuture("2023-11-08T11:00:00Z") shouldBe false +} +``` diff --git a/compat/compat-java-time/build.gradle.kts b/compat/compat-java-time/build.gradle.kts new file mode 100644 index 0000000..95c99cb --- /dev/null +++ b/compat/compat-java-time/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("conventions.base") + id("conventions.kotlin") + id("conventions.library") +} + +kotlin { + jvm() + + val jvmMain by sourceSets.getting { + dependencies { + api(projects.suite) + } + } + + val jvmTest by sourceSets.getting { + dependencies { + implementation(projects.framework) + } + } +} + +library { + name.set("Compatibility with java.time") + description.set("Control the passing of time in Prepared tests using objects and methods from java.time, including Clock and Instant") + homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-java-time/index.html") +} diff --git a/suite/src/jvmMain/kotlin/JavaTime.kt b/compat/compat-java-time/src/jvmMain/kotlin/JavaTime.kt similarity index 90% rename from suite/src/jvmMain/kotlin/JavaTime.kt rename to compat/compat-java-time/src/jvmMain/kotlin/JavaTime.kt index 6f9703e..ea6aac3 100644 --- a/suite/src/jvmMain/kotlin/JavaTime.kt +++ b/compat/compat-java-time/src/jvmMain/kotlin/JavaTime.kt @@ -1,8 +1,11 @@ -package opensavvy.prepared.suite +package opensavvy.prepared.compat.java.time import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.TestCoroutineScheduler +import opensavvy.prepared.suite.Time +import opensavvy.prepared.suite.advanceByMillis +import opensavvy.prepared.suite.nowMillis import java.time.Clock import java.time.Instant import java.time.ZoneId diff --git a/settings.gradle.kts b/settings.gradle.kts index 6480a55..659f0be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,6 +35,7 @@ include( "framework", "compat:compat-kotlinx-datetime", + "compat:compat-java-time", "compat:compat-gradle", "compat:compat-filesystem", "compat:compat-arrow", diff --git a/suite/README.jvm.md b/suite/README.jvm.md deleted file mode 100644 index 792be2a..0000000 --- a/suite/README.jvm.md +++ /dev/null @@ -1,5 +0,0 @@ -# Module Suite - -The JVM module has all the features of the common module, plus: - -- Time control with `java.time` classes (see [time.clockJava][opensavvy.prepared.suite.clockJava], [time.nowJava][opensavvy.prepared.suite.nowJava]). -- 2.51.2 From 775d3d27bfff0f0760a3356a58ba8faf47b716d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 8 Nov 2023 15:54:49 +0100 Subject: [PATCH 62/62] docs: Fix MavenCentral documentation link --- compat/compat-arrow/build.gradle.kts | 2 +- compat/compat-filesystem/build.gradle.kts | 2 +- compat/compat-gradle/build.gradle.kts | 2 +- compat/compat-java-time/build.gradle.kts | 2 +- compat/compat-kotlinx-datetime/build.gradle.kts | 2 +- framework/build.gradle.kts | 2 +- runners/runner-kotest/build.gradle.kts | 2 +- runners/runner-kotlin-test/build.gradle.kts | 2 +- suite/build.gradle.kts | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/compat/compat-arrow/build.gradle.kts b/compat/compat-arrow/build.gradle.kts index 921b01d..4782d20 100644 --- a/compat/compat-arrow/build.gradle.kts +++ b/compat/compat-arrow/build.gradle.kts @@ -36,5 +36,5 @@ kotlin { library { name.set("Functional error management with Arrow") description.set("Bind Either and Validated to fail test cases, with origin tracing") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-arrow/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/compat/compat-arrow/index.html") } diff --git a/compat/compat-filesystem/build.gradle.kts b/compat/compat-filesystem/build.gradle.kts index d20e78d..71c93d7 100644 --- a/compat/compat-filesystem/build.gradle.kts +++ b/compat/compat-filesystem/build.gradle.kts @@ -23,5 +23,5 @@ kotlin { library { name.set("Filesystem access") description.set("Create and check files from the filesystem") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-filesystem/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/compat/compat-filesystem/index.html") } diff --git a/compat/compat-gradle/build.gradle.kts b/compat/compat-gradle/build.gradle.kts index 94e91ed..ad02b23 100644 --- a/compat/compat-gradle/build.gradle.kts +++ b/compat/compat-gradle/build.gradle.kts @@ -26,5 +26,5 @@ kotlin { library { name.set("Compatibility with Gradle TestKit") description.set("Test Gradle plugins using Prepared") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-gradle/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/compat/compat-gradle/index.html") } diff --git a/compat/compat-java-time/build.gradle.kts b/compat/compat-java-time/build.gradle.kts index 95c99cb..b79fc15 100644 --- a/compat/compat-java-time/build.gradle.kts +++ b/compat/compat-java-time/build.gradle.kts @@ -23,5 +23,5 @@ kotlin { library { name.set("Compatibility with java.time") description.set("Control the passing of time in Prepared tests using objects and methods from java.time, including Clock and Instant") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-java-time/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/compat/compat-java-time/index.html") } diff --git a/compat/compat-kotlinx-datetime/build.gradle.kts b/compat/compat-kotlinx-datetime/build.gradle.kts index e3ebd95..d31071d 100644 --- a/compat/compat-kotlinx-datetime/build.gradle.kts +++ b/compat/compat-kotlinx-datetime/build.gradle.kts @@ -29,5 +29,5 @@ kotlin { library { name.set("Compatibility with KotlinX.Datetime") description.set("Control the passing of time in Prepared tests using objects and methods from KotlinX.Datetime, including Clock and Instant") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/compat-kotlinx-datetime/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/compat/compat-kotlinx-datetime/index.html") } diff --git a/framework/build.gradle.kts b/framework/build.gradle.kts index d16aeeb..37a4d18 100644 --- a/framework/build.gradle.kts +++ b/framework/build.gradle.kts @@ -22,5 +22,5 @@ kotlin { library { name.set("Prepared Framework") description.set("Collection of libraries to make Prepared as useful as possible, with as little setup as possible.") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/framework/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/framework/index.html") } diff --git a/runners/runner-kotest/build.gradle.kts b/runners/runner-kotest/build.gradle.kts index 12821e7..84f70ab 100644 --- a/runners/runner-kotest/build.gradle.kts +++ b/runners/runner-kotest/build.gradle.kts @@ -48,5 +48,5 @@ tasks.withType(Test::class) { library { name.set("Execute with Kotest") description.set("Execute Prepared test suites in projects that use Kotest") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotest/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/runners/runner-kotest/index.html") } diff --git a/runners/runner-kotlin-test/build.gradle.kts b/runners/runner-kotlin-test/build.gradle.kts index ef8d17d..63d1f05 100644 --- a/runners/runner-kotlin-test/build.gradle.kts +++ b/runners/runner-kotlin-test/build.gradle.kts @@ -60,5 +60,5 @@ tasks.withType(Test::class) { library { name.set("Execute with Kotlin-test") description.set("Execute Prepared test suites in projects that use kotlin-test") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/runner-kotlin-test/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/runners/runner-kotlin-test/index.html") } diff --git a/suite/build.gradle.kts b/suite/build.gradle.kts index 672ccd3..40dcd1a 100644 --- a/suite/build.gradle.kts +++ b/suite/build.gradle.kts @@ -29,5 +29,5 @@ kotlin { library { name.set("Suite") description.set("Magicless test framework for Kotlin Multiplatform") - homeUrl.set("https://opensavvy.gitlab.io/prepared/documentation/suite/index.html") + homeUrl.set("https://opensavvy.gitlab.io/prepared/api-docs/suite/index.html") } -- 2.51.2