From c43729fc2a674af4ee509590b627d0e0b27445a9 Mon Sep 17 00:00:00 2001 From: Stephen Edwards Date: Mon, 11 Dec 2023 15:39:13 -0500 Subject: [PATCH] Add testRender with CoroutineScope for SessionWorkflow Fixes #1138 --- workflow-testing/api/workflow-testing.api | 1 + .../workflow1/testing/RenderTester.kt | 31 +++++- .../workflow1/testing/RealRenderTesterTest.kt | 94 +++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 0fd219c83..920284679 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -64,6 +64,7 @@ public final class com/squareup/workflow1/testing/RenderTesterKt { public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lcom/squareup/workflow1/WorkflowIdentifier;Ljava/lang/Object;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lcom/squareup/workflow1/WorkflowIdentifier;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow1/testing/RenderTester;Lkotlin/reflect/KClass;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; + public static final fun testRender (Lcom/squareup/workflow1/SessionWorkflow;Ljava/lang/Object;Lkotlinx/coroutines/CoroutineScope;)Lcom/squareup/workflow1/testing/RenderTester; public static final fun testRender (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; public static final fun testRender (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;)Lcom/squareup/workflow1/testing/RenderTester; } diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt index d9dc8f565..8435b4954 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/RenderTester.kt @@ -1,14 +1,17 @@ package com.squareup.workflow1.testing import com.squareup.workflow1.ActionApplied +import com.squareup.workflow1.SessionWorkflow import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowOutput import com.squareup.workflow1.identifier import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch import com.squareup.workflow1.workflowIdentifier +import kotlinx.coroutines.CoroutineScope import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.KTypeProjection @@ -19,6 +22,7 @@ import kotlin.reflect.KTypeProjection * * See [RenderTester] for usage documentation. */ +@OptIn(WorkflowExperimentalApi::class) // Opt-in is only for the argument check. @Suppress("UNCHECKED_CAST") public fun Workflow.testRender( props: PropsT @@ -26,7 +30,32 @@ public fun Workflow.t val statefulWorkflow = asStatefulWorkflow() as StatefulWorkflow return statefulWorkflow.testRender( props = props, - initialState = statefulWorkflow.initialState(props, null) + initialState = run { + require(this !is SessionWorkflow) { + "Called testRender on a SessionWorkflow without a CoroutineScope. Use the version that passes a CoroutineScope." + } + statefulWorkflow.initialState(props, null) + } + ) as RenderTester +} + +/** + * Create a [RenderTester] to unit test an individual render pass of this [SessionWorkflow], + * using the workflow's [initial state][StatefulWorkflow.initialState], in the [workflowScope]. + * + * See [RenderTester] for usage documentation. + */ +@OptIn(WorkflowExperimentalApi::class) +@Suppress("UNCHECKED_CAST") +public fun SessionWorkflow.testRender( + props: PropsT, + workflowScope: CoroutineScope +): RenderTester { + val sessionWorkflow: SessionWorkflow = + asStatefulWorkflow() as SessionWorkflow + return sessionWorkflow.testRender( + props = props, + initialState = sessionWorkflow.initialState(props, null, workflowScope) ) as RenderTester } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/RealRenderTesterTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/RealRenderTesterTest.kt index d05122d1a..71da1d050 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/testing/RealRenderTesterTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/RealRenderTesterTest.kt @@ -10,6 +10,7 @@ import com.squareup.workflow1.Worker import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.WorkflowExperimentalApi import com.squareup.workflow1.WorkflowIdentifier import com.squareup.workflow1.WorkflowOutput import com.squareup.workflow1.action @@ -19,14 +20,18 @@ import com.squareup.workflow1.identifier import com.squareup.workflow1.renderChild import com.squareup.workflow1.rendering import com.squareup.workflow1.runningWorker +import com.squareup.workflow1.sessionWorkflow import com.squareup.workflow1.stateful import com.squareup.workflow1.stateless import com.squareup.workflow1.testing.RenderTester.ChildWorkflowMatch.Matched import com.squareup.workflow1.unsnapshottableIdentifier import com.squareup.workflow1.workflowIdentifier import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.runTest import org.mockito.kotlin.mock import kotlin.reflect.typeOf import kotlin.test.Test @@ -1263,6 +1268,95 @@ internal class RealRenderTesterTest { assertEquals(2, renderCount) } + @OptIn(WorkflowExperimentalApi::class) + @Test + fun `testRender with SessionWorkflow throws exception`() { + class TestAction : WorkflowAction() { + override fun Updater.apply() { + state = "new state" + setOutput("output") + } + } + + val workflow = Workflow.sessionWorkflow>( + initialState = { _, _: CoroutineScope -> "initial" }, + render = { _, _ -> + actionSink.contraMap { it } + } + ) + + val exception = assertFailsWith { + workflow.testRender(Unit) + .render { sink -> + sink.send(TestAction()) + } + } + + assertEquals( + exception.message, + "Called testRender on a SessionWorkflow without a CoroutineScope. Use the version that passes a CoroutineScope." + ) + } + + @OptIn(WorkflowExperimentalApi::class) + @Test + fun `testRender with CoroutineScope works for SessionWorkflow`() = runTest { + class TestAction : WorkflowAction() { + override fun Updater.apply() { + state = "new state" + setOutput("output") + } + } + + val workflow = Workflow.sessionWorkflow>( + initialState = { _, _: CoroutineScope -> "initial" }, + render = { _, _ -> + actionSink.contraMap { it } + } + ) + + val testResult = workflow.testRender(Unit, this) + .render { sink -> + sink.send(TestAction()) + } + + testResult.verifyActionResult { state, output -> + assertEquals("new state", state) + assertEquals("output", output?.value) + } + } + + @OptIn(WorkflowExperimentalApi::class) + @Test + fun `testRender with CoroutineScope uses the correct scope`() = runTest { + val signalMutex = Mutex(locked = true) + class TestAction : WorkflowAction() { + override fun Updater.apply() { + state = "new state" + setOutput("output") + } + } + + val workflow = Workflow.sessionWorkflow>( + initialState = { _, workflowScope: CoroutineScope -> + assertEquals(workflowScope, this@runTest) + signalMutex.unlock() + "initial" + }, + render = { _, _ -> + actionSink.contraMap { it } + } + ) + + workflow.testRender(Unit, this) + .render { sink -> + sink.send(TestAction()) + } + + // Assertion happens in the `initialState` call above. + signalMutex.lock() + } + @Test fun `createRenderChildInvocation() for Workflow-stateless{}`() { val workflow = Workflow.stateless {} val invocation = createRenderChildInvocation(workflow, "props", "key")