diff --git a/suite/src/commonMain/kotlin/Async.kt b/suite/src/commonMain/kotlin/Async.kt index 9e0ca20..6773382 100644 --- a/suite/src/commonMain/kotlin/Async.kt +++ b/suite/src/commonMain/kotlin/Async.kt @@ -11,6 +11,7 @@ import kotlin.coroutines.EmptyCoroutineContext * * Tasks started in this scope respect the controlled [time]. */ +@PreparedDslMarker val TestDsl.foregroundScope: CoroutineScope get() = environment.coroutineScope @@ -25,6 +26,7 @@ val TestDsl.foregroundScope: CoroutineScope * * Tasks started in this scope respect the controlled [time]. */ +@PreparedDslMarker val TestDsl.backgroundScope: CoroutineScope get() = environment.coroutineScope.backgroundScope @@ -36,6 +38,7 @@ val TestDsl.backgroundScope: CoroutineScope * * The task will respect the controlled [time]. */ +@PreparedDslMarker fun TestDsl.launch( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, @@ -53,6 +56,7 @@ fun TestDsl.launch( * * The task will respect the controlled [time]. */ +@PreparedDslMarker fun TestDsl.launchInBackground( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, -- 2.51.2 From 0bcd76e251890bc546efe3f0a002cc629178e136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 3 Jun 2024 21:09:06 +0200 Subject: [PATCH 2/5] feat(suite): Default name for foreground and background coroutines --- suite/src/commonMain/kotlin/Async.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/suite/src/commonMain/kotlin/Async.kt b/suite/src/commonMain/kotlin/Async.kt index 6773382..d88ac89 100644 --- a/suite/src/commonMain/kotlin/Async.kt +++ b/suite/src/commonMain/kotlin/Async.kt @@ -43,7 +43,7 @@ fun TestDsl.launch( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -) = foregroundScope.launch(context, start, block) +) = foregroundScope.launch(CoroutineName("Test foreground task") + context, start, block) /** * Starts a task in the [backgroundScope] scope. The test will **not** wait for this task before finishing. @@ -61,4 +61,4 @@ fun TestDsl.launchInBackground( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, -) = backgroundScope.launch(context, start, block) +) = backgroundScope.launch(CoroutineName("Test background task") + context, start, block) -- 2.51.2 From a8777179706acd80134ae6f447335e4d4de4d25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 3 Jun 2024 21:09:55 +0200 Subject: [PATCH 3/5] refactor(runner-kotest): Removed the deprecated testCoroutineDispatcher option --- runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt index fd6847e..f400455 100644 --- a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt @@ -62,7 +62,6 @@ private class NonNestedSuite(private val root: RootScope, private val parentConf tags = config[Tag] .mapTo(HashSet()) { io.kotest.core.Tag(it.name) } .takeIf { it.isNotEmpty() }, - testCoroutineDispatcher = true, coroutineTestScope = true, coroutineDebugProbes = true, ) -- 2.51.2 From 2de2990f9e13d8a9955e3b83f229470b49ee092a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 3 Jun 2024 21:39:21 +0200 Subject: [PATCH 4/5] docs(suite): Cross-link the various asynchronous helpers --- suite/src/commonMain/kotlin/Async.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/suite/src/commonMain/kotlin/Async.kt b/suite/src/commonMain/kotlin/Async.kt index d88ac89..b41f90f 100644 --- a/suite/src/commonMain/kotlin/Async.kt +++ b/suite/src/commonMain/kotlin/Async.kt @@ -9,7 +9,11 @@ import kotlin.coroutines.EmptyCoroutineContext * * The test will only finish when all tasks started in this scope are finished. * + * To start a single coroutine, see [launch]. + * * Tasks started in this scope respect the controlled [time]. + * + * @see backgroundScope */ @PreparedDslMarker val TestDsl.foregroundScope: CoroutineScope @@ -24,7 +28,11 @@ val TestDsl.foregroundScope: CoroutineScope * 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. * + * To start a single coroutines, see [launchInBackground]. + * * Tasks started in this scope respect the controlled [time]. + * + * @see foregroundScope */ @PreparedDslMarker val TestDsl.backgroundScope: CoroutineScope @@ -37,6 +45,8 @@ val TestDsl.backgroundScope: CoroutineScope * To execute tasks in parallel, explicitly use a [CoroutineDispatcher]. * * The task will respect the controlled [time]. + * + * @see launchInBackground */ @PreparedDslMarker fun TestDsl.launch( @@ -55,6 +65,8 @@ fun TestDsl.launch( * To execute tasks in parallel, explicitly use a [CoroutineDispatcher]. * * The task will respect the controlled [time]. + * + * @see launch */ @PreparedDslMarker fun TestDsl.launchInBackground( -- 2.51.2 From aa598e2128121bb6eb07c08a13a6b2c81766b9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Mon, 3 Jun 2024 21:40:19 +0200 Subject: [PATCH 5/5] docs(website): Document the asynchronous helpers --- docs/website/docs/features/async.md | 106 +++++++++++++++++++++++++ docs/website/docs/features/overview.md | 9 +++ docs/website/mkdocs.yml | 1 + 3 files changed, 116 insertions(+) create mode 100644 docs/website/docs/features/async.md diff --git a/docs/website/docs/features/async.md b/docs/website/docs/features/async.md new file mode 100644 index 0000000..87e826a --- /dev/null +++ b/docs/website/docs/features/async.md @@ -0,0 +1,106 @@ +# Asynchronous operations + +A test may need to execute other tasks concurrently with itself. +There are two categories of concurrent tasks: + +- **Foreground tasks** are considered to be part of the test: the test will fail if any of them fail, +- **Background tasks** are considered to be part of the infrastructure: the test will end as soon as all foreground tasks are finished, even if background tasks are still running. + +**Identifying which category a task is a part of is crucial for the correct execution of a test.** +For example, a test will fail if some foreground tasks are still running at the end of the test timeout, whereas background tasks will be killed at the end of the timeout without impacting the test result. + +!!! danger + Depending on the test runner, the test may or may not be able to execute children tasks in parallel. + + In the case of single-threaded test runners, calling `delay` or `yield` in your code may be necessary to give a chance to tasks to run. + +## Launching foreground tasks + +To start a task in the foreground, use [`launch`](https://opensavvy.gitlab.io/groundwork/prepared/api-docs/suite/opensavvy.prepared.suite/launch.html) within a test: +```kotlin +test("This is a test that starts a foreground task") { + launch { + delay(100) + println("In a foreground task!") + } + + println("In the test body") +} +``` + +The launched coroutine has the following properties: + +- The test will wait for it to finish running before terminating, +- If the coroutine fails with an exception, the test will be marked as failed with that exception, +- If the test timeout is reached before the coroutine finishes, the test fails and the coroutine is dumped. + +!!! tip + Use foreground tasks to model asynchronous operations triggered by the system-under-test. + + This way, you will be warned by the test if the system-under-test doesn't clean its resources properly (e.g. if it leaks coroutines). + +## Launching background tasks + +To start a task in the background, use [`launchInBackground`](https://opensavvy.gitlab.io/groundwork/prepared/api-docs/suite/opensavvy.prepared.suite/launch-in-background.html) within a test: +```kotlin +test("This is a test that starts a background task") { + launchInBackground { + while(true) { + delay(100) + println("In a background task!") + } + } + + delay(1000) + println("In the test body") +} +``` + +In this example, we start a background coroutine that performs an action every 100 milliseconds forever. +The test waits for 1 second before ending. The background task executes 10 times, then is killed when the test finishes. + +The launched coroutine has the following properties: + +- The coroutine will be cancelled when the test and all foreground coroutines, have finished executing, +- If the coroutine fails with an exception, it is reported but doesn't fail the test, +- If the test timeout is reached, the coroutine is dumped. + +!!! tip + Use background tasks to model operations that would outlive the system-under-test in production. For example: + + - a keep-alive ping sent to a database, + - a cache that has a cleanup task every few seconds, + - to communicate the virtual time to an external service… + +## Controlling the execution of external services + +Sometimes, a service used inside a test must itself be able to start asynchronous operations. +When written properly, this service accepts a `CoroutineScope` on creation. + +To allow the service to create foreground or background tasks, pass it either [`foregroundScope`](https://opensavvy.gitlab.io/groundwork/prepared/api-docs/suite/opensavvy.prepared.suite/foreground-scope.html) or [`backgroundScope`](https://opensavvy.gitlab.io/groundwork/prepared/api-docs/suite/opensavvy.prepared.suite/background-scope.html): + +```kotlin +val inMemoryCache by prepared { // (1)! + // Let's declare a cache that is used by our system-under-test, + // but isn't what we are trying to test. + Cache() + .inMemory() + .expireAfter(2.minutes, backgroundScope) +} + +val systemUnderTest by prepared { + // We are trying to test this system, and we want to ensure + // it doesn't leak coroutines. + SystemUnderTest(foregroundScope, inMemoryCache()) +} + +test("Create test users") { + systemUnderTest().createUsers() +} +``` + + 1. This test uses Prepared values, which allow to reuse initialization logic between multiple test cases. + Prepared values run as part of the test, and can thus use foreground and background tasks. + [Learn more](prepared-values.md). + +The behavior of coroutines started in each scope is identical to the equivalent `launch` variant described in the previous sections. diff --git a/docs/website/docs/features/overview.md b/docs/website/docs/features/overview.md index f0bf29a..9f3f22d 100644 --- a/docs/website/docs/features/overview.md +++ b/docs/website/docs/features/overview.md @@ -36,6 +36,13 @@ fun SuiteDsl.testUsers( // (1)! users().deleteUser(it) } + launchInBackground { // (8)! + while (true) { + delay(1000) + users().increaseAgeOf(it) + } + } + user } @@ -68,3 +75,5 @@ fun SuiteDsl.testUsers( // (1)! [Learn more](shared-values.md). 7. Finalizers are useful to co-locate clean up code next to value generation. [Learn more](finalizers.md). +8. Tests can create asynchronous tasks that run in the foreground or in the background. + [Learn more](async.md). diff --git a/docs/website/mkdocs.yml b/docs/website/mkdocs.yml index 0fbad0d..c766f47 100644 --- a/docs/website/mkdocs.yml +++ b/docs/website/mkdocs.yml @@ -91,6 +91,7 @@ nav: - features/prepared-values.md - features/shared-values.md - features/finalizers.md + - features/async.md - Best practices: - practices/overview.md