diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ed76d051..3495f8ec0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,6 +82,7 @@ squareup-workflow = "1.0.0" timber = "4.7.1" truth = "1.1.3" +turbine = "0.12.1" vanniktech-publish = "0.22.0" [plugins] @@ -247,6 +248,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } truth = { module = "com.google.truth:truth", version.ref = "truth" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } + vanniktech-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "vanniktech-publish" } [bundles] diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 22f673676..de6e445ee 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -83,6 +83,7 @@ public abstract interface class com/squareup/workflow1/testing/WorkerTester { public abstract fun assertNotFinished ()V public abstract fun cancelWorker (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getException (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getTestCoroutineScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler; public abstract fun nextOutput (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/workflow-testing/build.gradle.kts b/workflow-testing/build.gradle.kts index 18ff737e6..b06f20ca2 100644 --- a/workflow-testing/build.gradle.kts +++ b/workflow-testing/build.gradle.kts @@ -22,6 +22,7 @@ tasks.withType { dependencies { api(libs.kotlin.jdk7) + api(libs.kotlinx.coroutines.test) api(project(":workflow-core")) api(project(":workflow-runtime")) @@ -29,7 +30,7 @@ dependencies { compileOnly(libs.jetbrains.annotations) implementation(libs.kotlin.reflect) - implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) implementation(project(":internal-testing-utils")) implementation(project(":workflow-config:config-jvm")) diff --git a/workflow-testing/dependencies/runtimeClasspath.txt b/workflow-testing/dependencies/runtimeClasspath.txt index dfb197031..b636cc3d3 100644 --- a/workflow-testing/dependencies/runtimeClasspath.txt +++ b/workflow-testing/dependencies/runtimeClasspath.txt @@ -2,14 +2,16 @@ :workflow-config:config-jvm :workflow-core :workflow-runtime +app.cash.turbine:turbine-jvm:0.12.1 +app.cash.turbine:turbine:0.12.1 com.squareup.okio:okio-jvm:3.0.0 com.squareup.okio:okio:3.0.0 org.jetbrains.kotlin:kotlin-bom:1.7.10 org.jetbrains.kotlin:kotlin-reflect:1.7.10 -org.jetbrains.kotlin:kotlin-stdlib-common:1.7.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10 -org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10 -org.jetbrains.kotlin:kotlin-stdlib:1.7.10 +org.jetbrains.kotlin:kotlin-stdlib-common:1.7.20 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.20 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20 +org.jetbrains.kotlin:kotlin-stdlib:1.7.20 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4 diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerTester.kt index a959b8355..9a85510a4 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkerTester.kt @@ -1,25 +1,33 @@ -@file:OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@file:OptIn(ExperimentalCoroutinesApi::class) package com.squareup.workflow1.testing +import app.cash.turbine.Event.Item +import app.cash.turbine.test import com.squareup.workflow1.Worker import com.squareup.workflow1.testing.WorkflowTestRuntime.Companion.DEFAULT_TIMEOUT_MS -import kotlinx.coroutines.Dispatchers.Unconfined import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.produceIn -import kotlinx.coroutines.plus -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.yield +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.DurationUnit.MILLISECONDS +import kotlin.time.toDuration public interface WorkerTester { + /** + * Access the [TestCoroutineScheduler] of the [kotlinx.coroutines.test.TestScope] running + * the [Worker]'s [test]. + * + * This can be used to advance virtual time for the [CoroutineDispatcher] that the the Worker's + * flow is flowing on. + */ + public val testCoroutineScheduler: TestCoroutineScheduler + /** * Suspends until the worker emits its next value, then returns it. + * + * Throws an [AssertionError] if the Worker completes or has an error. */ public suspend fun nextOutput(): T @@ -31,7 +39,7 @@ public interface WorkerTester { /** * Suspends until the worker emits an output or finishes. * - * Throws an [AssertionError] if an output was emitted. + * Throws an [AssertionError] if an output was emitted or the Worker has an error. */ public suspend fun assertFinished() @@ -54,35 +62,56 @@ public interface WorkerTester { /** * Test a [Worker] by defining assertions on its output within [block]. */ +@OptIn(ExperimentalCoroutinesApi::class) public fun Worker.test( timeoutMs: Long = DEFAULT_TIMEOUT_MS, block: suspend WorkerTester.() -> Unit ) { - runBlocking { - supervisorScope { - val channel: ReceiveChannel = run().produceIn(this + Unconfined) - + runTest { + // Use a Turbine which consumes outputs/errors from an underlying channel. + run().test( + timeout = timeoutMs.toDuration(MILLISECONDS) + ) { val tester = object : WorkerTester { - override suspend fun nextOutput(): T = channel.receive() + override val testCoroutineScheduler: TestCoroutineScheduler = testScheduler + + override suspend fun nextOutput(): T = awaitItem() override fun assertNoOutput() { - // isEmpty returns false if the channel is closed. - if (!channel.isEmpty && !channel.isClosedForReceive) { + try { + expectNoEvents() + } catch (e: AssertionError) { throw AssertionError("Expected no output to have been emitted.") } } override suspend fun assertFinished() { - if (!channel.isClosedForReceive) { + try { + withTimeoutOrNull(timeoutMs) { + awaitComplete() + } ?: throw AssertionError() + } catch (e: AssertionError) { + // Note there is some complicated logic here to build the message. The messages predate + // Turbine integration but we wanted to keep them stable, and so extract what's needed + // from the Turbine AssertionErrors. val message = buildString { append("Expected Worker to be finished.") - val outputs = mutableListOf() - while (!channel.isEmpty) { - @Suppress("UNCHECKED_CAST") - outputs += channel.tryReceive().getOrNull() as T + val outputStrings = cancelAndConsumeRemainingEvents().filterIsInstance>() + .map { it.value.toString() }.toMutableList() + // Consumed and only reported in the exception. + e.message?.substringAfter( + delimiter = "Item(", + missingDelimiterValue = "" + )?.substringBeforeLast( + delimiter = ')', + missingDelimiterValue = "" + )?.let { + if (it.isNotEmpty()) { + outputStrings.add(0, it) + } } - if (outputs.isNotEmpty()) { - append(" Emitted outputs: $outputs") + if (outputStrings.isNotEmpty()) { + append(" Emitted outputs: $outputStrings") } } throw AssertionError(message) @@ -90,31 +119,24 @@ public fun Worker.test( } override fun assertNotFinished() { - if (channel.isClosedForReceive) { + if (asChannel().isClosedForReceive) { throw AssertionError("Expected Worker to not be finished.") } } override suspend fun getException(): Throwable = try { - val output = channel.receive() - throw AssertionError("Expected Worker to throw an exception, but emitted output: $output") + awaitError() } catch (e: Throwable) { e } override suspend fun cancelWorker() { - channel.cancel() + cancelAndIgnoreRemainingEvents() } } - // Yield to let the produce coroutine start, since we can't specify UNDISPATCHED. - yield() - - withTimeout(timeoutMs) { - block(tester) - } - - coroutineContext.cancelChildren() + tester.block() + cancelAndIgnoreRemainingEvents() } } } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt index 927c51cb3..ee1c9e005 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/WorkerTest.kt @@ -4,8 +4,6 @@ package com.squareup.workflow1 import com.squareup.workflow1.testing.test import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.test.StandardTestDispatcher import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotSame @@ -65,6 +63,7 @@ internal class WorkerTest { } worker.test { + assertFinished() assertTrue(ran) } } @@ -125,22 +124,19 @@ internal class WorkerTest { } @Test fun `timer emits and finishes after delay`() { - val testDispatcher = StandardTestDispatcher() val worker = Worker.timer(1000) - // Run the timer on the test dispatcher so we can control time. - .transform { it.flowOn(testDispatcher) } worker.test { assertNoOutput() assertNotFinished() - testDispatcher.scheduler.advanceTimeBy(999) - testDispatcher.scheduler.runCurrent() + testCoroutineScheduler.advanceTimeBy(999) + testCoroutineScheduler.runCurrent() assertNoOutput() assertNotFinished() - testDispatcher.scheduler.advanceTimeBy(1) - testDispatcher.scheduler.runCurrent() + testCoroutineScheduler.advanceTimeBy(1) + testCoroutineScheduler.runCurrent() assertEquals(Unit, nextOutput()) assertFinished() } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkerTesterTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkerTesterTest.kt index d0ec333ef..9b12fbc01 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkerTesterTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkerTesterTest.kt @@ -2,11 +2,17 @@ package com.squareup.workflow1.testing import com.squareup.workflow1.Worker import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.coroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +@OptIn(ExperimentalCoroutinesApi::class) class WorkerTesterTest { @Test fun `assertNoOutput passes after worker finishes without emitting`() { @@ -16,9 +22,24 @@ class WorkerTesterTest { } } - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `assertNoOutput fails after worker emits`() { + @Test fun `assertNotFinished fails after worker finished`() { + val worker = Worker.finished() + val error = assertFailsWith { + worker.test { + assertNotFinished() + } + } + assertEquals("Expected Worker to not be finished.", error.message) + } + + @Test fun `assertNotFinished true while worker running`() { + val worker = Worker.from { suspendCancellableCoroutine {} } + worker.test { + assertNotFinished() + } + } + + @Test fun `assertNoOutput fails after worker emits`() { val worker = Worker.from { Unit } val error = assertFailsWith { worker.test { @@ -71,4 +92,89 @@ class WorkerTesterTest { } assertEquals("Expected Worker to be finished. Emitted outputs: [foo, bar]", error.message) } + + @Test fun `nextOutput returns expected`() { + val worker = Worker.create { + emit("foo") + emit("bar") + suspendCancellableCoroutine {} + } + worker.test { + val first = nextOutput() + assertEquals("foo", first) + } + } + + @Test fun `cancelWorker cancels worker`() { + var capturedContext: CoroutineContext? = null + val worker = Worker.create { + capturedContext = coroutineContext + emit("foo") + emit("bar") + suspendCancellableCoroutine {} + } + worker.test { + cancelWorker() + assertFalse(capturedContext!!.isActive, "Expected worker to be canceled.") + } + } + + @Test fun `getException gives expected value`() { + val expectedException = Throwable("My Special One.") + val worker = Worker.create { + throw expectedException + } + worker.test { + val exception = getException() + assertEquals(expectedException.message, exception.message) + } + } + + @Test fun `skips delays`() { + val worker = Worker.create { + delay(1000) + emit("foo") + } + worker.test( + timeoutMs = 50L + ) { + val first = nextOutput() + assertEquals("foo", first) + } + } + + @Test fun `times out when it should`() { + val worker = Worker.create { + emit("foo") + suspendCancellableCoroutine {} + } + val error = assertFailsWith { + worker.test( + timeoutMs = 5L + ) { + val first = nextOutput() + assertEquals("foo", first) + nextOutput() + } + } + assertEquals(error.message!!, "No value produced in 5ms") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `testCoroutineScheduler can control time for Worker`() { + val worker = Worker.create { + delay(300) + emit("foo") + emit("bar") + suspendCancellableCoroutine {} + } + worker.test { + assertNoOutput() + testCoroutineScheduler.advanceTimeBy(300) + testCoroutineScheduler.runCurrent() + val first = nextOutput() + assertEquals("foo", first) + } + } }