From d65746e73493828200c669e50ec296ddc0ada652 Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Sun, 19 Oct 2025 17:05:04 -0400 Subject: [PATCH 1/6] Add awaitNextSnapshot and awaitNextOutput to WorkflowTurbine --- .../workflow1/testing/WorkflowTurbine.kt | 103 ++++-- .../workflow1/testing/WorkflowTurbineTest.kt | 314 ++++++++++++++++++ 2 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt index 4d7f0f120..bfbd7a697 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt @@ -2,7 +2,10 @@ package com.squareup.workflow1.testing import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.config.JvmTestRuntimeConfigTools @@ -11,11 +14,15 @@ import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DE import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.coroutines.CoroutineContext @@ -46,7 +53,7 @@ public fun Workflow.r runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(), onOutput: suspend (OutputT) -> Unit = {}, testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, - testCase: suspend WorkflowTurbine.() -> Unit + testCase: suspend WorkflowTurbine.() -> Unit ) { val workflow = this @@ -57,28 +64,59 @@ public fun Workflow.r // We use a sub-scope so that we can cancel the Workflow runtime when we are done with it so that // tests don't all have to do that themselves. val workflowRuntimeScope = CoroutineScope(coroutineContext) + + // Capture outputs in a channel + val outputsChannel = Channel(Channel.UNLIMITED) + val renderings = renderWorkflowIn( workflow = workflow, props = props, scope = workflowRuntimeScope, interceptors = interceptors, runtimeConfig = runtimeConfig, - onOutput = onOutput + onOutput = { output -> + outputsChannel.send(output) + onOutput(output) + } ) val firstRendering = renderings.value.rendering + val firstSnapshot = renderings.value.snapshot + + // Share the RenderingAndSnapshot flow so multiple subscribers can collect from it + // Use workflowRuntimeScope so it's cancelled when the workflow is cancelled + val sharedRenderings = renderings.drop(1) + .shareIn( + scope = workflowRuntimeScope, + started = SharingStarted.Eagerly, + replay = 0 + ) + + // Use turbineScope to test multiple flows + turbineScope { + // Map the shared flow to extract renderings and snapshots separately + val renderingTurbine = sharedRenderings.map { it.rendering } + .testIn(backgroundScope, timeout = testTimeout.milliseconds, name = "renderings") + val snapshotTurbine = sharedRenderings.map { it.snapshot } + .testIn(backgroundScope, timeout = testTimeout.milliseconds, name = "snapshots") + val outputTurbine = outputsChannel.receiveAsFlow() + .testIn(backgroundScope, timeout = testTimeout.milliseconds, name = "outputs") - // Drop one as its provided separately via `firstRendering`. - renderings.drop(1).map { - it.rendering - }.test { val workflowTurbine = WorkflowTurbine( - firstRendering, - this + firstRendering = firstRendering, + firstSnapshot = firstSnapshot, + renderingTurbine = renderingTurbine, + snapshotTurbine = snapshotTurbine, + outputTurbine = outputTurbine ) workflowTurbine.testCase() - cancelAndIgnoreRemainingEvents() + + // Cancel all turbines + renderingTurbine.cancel() + snapshotTurbine.cancel() + outputTurbine.cancel() } + workflowRuntimeScope.cancel() } } @@ -94,7 +132,7 @@ public fun Workflow.renderForTe runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(), onOutput: suspend (OutputT) -> Unit = {}, testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, - testCase: suspend WorkflowTurbine.() -> Unit + testCase: suspend WorkflowTurbine.() -> Unit ): Unit = renderForTest( props = MutableStateFlow(Unit).asStateFlow(), coroutineContext = coroutineContext, @@ -111,12 +149,18 @@ public fun Workflow.renderForTe * * @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is * provided separately if any assertions or operations are needed from it. + * @property firstSnapshot The first snapshot of the Workflow runtime is made synchronously. This is + * provided separately if any assertions or operations are needed from it. */ -public class WorkflowTurbine( +public class WorkflowTurbine( public val firstRendering: RenderingT, - private val receiveTurbine: ReceiveTurbine + public val firstSnapshot: TreeSnapshot, + private val renderingTurbine: ReceiveTurbine, + private val snapshotTurbine: ReceiveTurbine, + private val outputTurbine: ReceiveTurbine, ) { - private var usedFirst = false + private var usedFirstRendering = false + private var usedFirstSnapshot = false /** * Suspend waiting for the next rendering to be produced by the Workflow runtime. Note this includes @@ -125,23 +169,44 @@ public class WorkflowTurbine( * @return the rendering. */ public suspend fun awaitNextRendering(): RenderingT { - if (!usedFirst) { - usedFirst = true + if (!usedFirstRendering) { + usedFirstRendering = true return firstRendering } - return receiveTurbine.awaitItem() + return renderingTurbine.awaitItem() + } + + /** + * Suspend waiting for the next output to be produced by the Workflow runtime. + * + * @return the output. + */ + public suspend fun awaitNextOutput(): OutputT = outputTurbine.awaitItem() + + /** + * Suspend waiting for the next snapshot to be produced by the Workflow runtime. Note this includes + * the first (synchronously made) snapshot. + * + * @return the snapshot. + */ + public suspend fun awaitNextSnapshot(): TreeSnapshot { + if (!usedFirstSnapshot) { + usedFirstSnapshot = true + return firstSnapshot + } + return snapshotTurbine.awaitItem() } public suspend fun skipRenderings(count: Int) { - val skippedCount = if (!usedFirst) { - usedFirst = true + val skippedCount = if (!usedFirstRendering) { + usedFirstRendering = true count - 1 } else { count } if (skippedCount > 0) { - receiveTurbine.skipItems(skippedCount) + renderingTurbine.skipItems(skippedCount) } } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt new file mode 100644 index 000000000..991eaacdb --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt @@ -0,0 +1,314 @@ +package com.squareup.workflow1.testing + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.action +import com.squareup.workflow1.stateful +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull + +/** + * Tests for WorkflowTurbine to verify that awaitNextRendering, awaitNextOutput, and + * awaitNextSnapshot all work correctly with the shareIn-based implementation. + */ +class WorkflowTurbineTest { + + @Test fun `awaitNextRendering returns first rendering`() { + val workflow = Workflow.stateful( + initialState = 42, + render = { state: Int -> + state + } + ) + + workflow.renderForTest { + // First rendering should be 42 + assertEquals(42, awaitNextRendering()) + } + } + + @Test fun `awaitNextSnapshot returns first snapshot`() { + val workflow = Workflow.stateful( + initialState = { snapshot: Snapshot? -> 42 }, + render = { state: Int -> + state + }, + snapshot = { state: Int -> Snapshot.of(state) } + ) + + workflow.renderForTest { + // First snapshot should exist + val firstSnapshot = awaitNextSnapshot() + assertNotNull(firstSnapshot) + } + } + + @Test fun `firstRendering property is accessible`() { + val workflow = Workflow.stateful( + initialState = "hello", + render = { state: String -> + state + } + ) + + workflow.renderForTest { + // First rendering property should be accessible + assertEquals("hello", firstRendering) + // awaitNextRendering should return the same value + assertEquals("hello", awaitNextRendering()) + } + } + + @Test fun `firstSnapshot property is accessible`() { + val workflow = Workflow.stateful( + initialState = { snapshot: Snapshot? -> "hello" }, + render = { state: String -> + state + }, + snapshot = { state: String -> Snapshot.of(state) } + ) + + workflow.renderForTest { + // First snapshot property should be accessible + assertNotNull(firstSnapshot) + // awaitNextSnapshot should return the same value + assertEquals(firstSnapshot, awaitNextSnapshot()) + } + } + + // Workflow that can increment state + private object IncrementWorkflow : StatefulWorkflow Unit>>() { + override fun initialState(props: Unit, snapshot: Snapshot?) = 0 + + override fun snapshotState(state: Int): Snapshot = Snapshot.of(state) + + override fun render( + renderProps: Unit, + renderState: Int, + context: RenderContext + ): Pair Unit> { + val increment = { + context.actionSink.send(action("increment") { + state = renderState + 1 + }) + } + return renderState to increment + } + } + + @Test fun `awaitNextRendering and awaitNextSnapshot are independent`() { + IncrementWorkflow.renderForTest { + // Get first rendering + val (value0, increment0) = awaitNextRendering() + assertEquals(0, value0) + + // Trigger state change BEFORE consuming snapshot + increment0() + + // Now get first snapshot - should still be for state 0 + val snapshot0 = awaitNextSnapshot() + assertNotNull(snapshot0) + + // Now get second rendering - should be state 1 + val (value1, increment1) = awaitNextRendering() + assertEquals(1, value1) + + // And second snapshot - should be for state 1 + val snapshot1 = awaitNextSnapshot() + assertNotNull(snapshot1) + assertNotEquals(snapshot0, snapshot1) + + // Trigger another change + increment1() + + // Third rendering + val (value2, _) = awaitNextRendering() + assertEquals(2, value2) + + // Third snapshot + val snapshot2 = awaitNextSnapshot() + assertNotNull(snapshot2) + assertNotEquals(snapshot1, snapshot2) + } + } + + @Test fun `awaitNextSnapshot and awaitNextRendering are synchronized`() { + IncrementWorkflow.renderForTest { + // Consume first rendering + val (value0, increment) = awaitNextRendering() + assertEquals(0, value0) + + // Trigger state change + increment() + + // Consume second rendering + val (value1, _) = awaitNextRendering() + assertEquals(1, value1) + + // Now consume snapshots - they should be in sync + val snapshot0 = awaitNextSnapshot() + assertNotNull(snapshot0) + + val snapshot1 = awaitNextSnapshot() + assertNotNull(snapshot1) + assertNotEquals(snapshot0, snapshot1) + } + } + + @Test fun `shareIn works - both turbines receive same emissions`() { + IncrementWorkflow.renderForTest { + // Consume renderings first + val (value0, increment0) = awaitNextRendering() + assertEquals(0, value0) + + increment0() + val (value1, increment1) = awaitNextRendering() + assertEquals(1, value1) + + increment1() + val (value2, _) = awaitNextRendering() + assertEquals(2, value2) + + // Now consume snapshots - should have all 3 available because shareIn broadcasted to both + val snapshot0 = awaitNextSnapshot() + assertNotNull(snapshot0) + + val snapshot1 = awaitNextSnapshot() + assertNotNull(snapshot1) + + val snapshot2 = awaitNextSnapshot() + assertNotNull(snapshot2) + + // All snapshots should be different + assertNotEquals(snapshot0, snapshot1) + assertNotEquals(snapshot1, snapshot2) + assertNotEquals(snapshot0, snapshot2) + } + } + + // Workflow that emits outputs + private object OutputWorkflow : StatefulWorkflow Unit>>() { + override fun initialState(props: Unit, snapshot: Snapshot?) = 0 + + override fun snapshotState(state: Int): Snapshot? = null + + override fun render( + renderProps: Unit, + renderState: Int, + context: RenderContext + ): Pair Unit> { + val emitOutput = { + context.actionSink.send(action("emit") { + state = renderState + 1 + setOutput("output-$renderState") + }) + } + return renderState to emitOutput + } + } + + @Test fun `awaitNextOutput receives workflow outputs`() { + OutputWorkflow.renderForTest { + // Get first rendering + val (value0, emit0) = awaitNextRendering() + assertEquals(0, value0) + + // Trigger output + emit0() + + // Should receive output + val output0 = awaitNextOutput() + assertEquals("output-0", output0) + + // Should also get next rendering + val (value1, emit1) = awaitNextRendering() + assertEquals(1, value1) + + // Trigger another output + emit1() + + // Should receive second output + val output1 = awaitNextOutput() + assertEquals("output-1", output1) + + // And third rendering + val (value2, _) = awaitNextRendering() + assertEquals(2, value2) + } + } + + @Test fun `all three await methods work together independently`() { + OutputWorkflow.renderForTest { + // Get first rendering + val (value0, emit0) = awaitNextRendering() + assertEquals(0, value0) + + // Trigger output and state change + emit0() + + // Can consume in any order - output first + val output0 = awaitNextOutput() + assertEquals("output-0", output0) + + // Then rendering + val (value1, emit1) = awaitNextRendering() + assertEquals(1, value1) + + // Then snapshot + val snapshot0 = awaitNextSnapshot() + assertNotNull(snapshot0) + + // Trigger another change + emit1() + + // Consume in different order - snapshot first + val snapshot1 = awaitNextSnapshot() + assertNotNull(snapshot1) + + // Then output + val output1 = awaitNextOutput() + assertEquals("output-1", output1) + + // Then rendering + val (value2, _) = awaitNextRendering() + assertEquals(2, value2) + } + } + + @Test fun `multiple state changes produce multiple emissions for all flows`() { + IncrementWorkflow.renderForTest { + val (value0, increment0) = awaitNextRendering() + assertEquals(0, value0) + + // Trigger 3 rapid state changes + increment0() + val (_, increment1) = awaitNextRendering() + increment1() + val (_, increment2) = awaitNextRendering() + increment2() + + // Should have renderings 1, 2, 3 available + val (value3, _) = awaitNextRendering() + assertEquals(3, value3) + + // Should also have all snapshots 0, 1, 2, 3 available + val snapshot0 = awaitNextSnapshot() + val snapshot1 = awaitNextSnapshot() + val snapshot2 = awaitNextSnapshot() + val snapshot3 = awaitNextSnapshot() + + assertNotNull(snapshot0) + assertNotNull(snapshot1) + assertNotNull(snapshot2) + assertNotNull(snapshot3) + + // All should be different + assertNotEquals(snapshot0, snapshot1) + assertNotEquals(snapshot1, snapshot2) + assertNotEquals(snapshot2, snapshot3) + } + } +} From d46ca91b00a0e97d5945a4e87408d580fbaee43f Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Sun, 19 Oct 2025 17:51:11 -0400 Subject: [PATCH 2/6] Add restoring from snapshot and idempotency interceptor --- .../workflow1/testing/WorkflowTestParams.kt | 33 ++++ .../workflow1/testing/WorkflowTestRuntime.kt | 31 --- .../workflow1/testing/WorkflowTurbine.kt | 181 +++++++++++++++--- .../workflow1/testing/WorkflowTurbineTest.kt | 18 +- 4 files changed, 198 insertions(+), 65 deletions(-) diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt index b6a227241..d7682c6f4 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt @@ -3,11 +3,14 @@ package com.squareup.workflow1.testing import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.Snapshot import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.testing.WorkflowTestParams.StartMode import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromCompleteSnapshot import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromState import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot +import kotlinx.coroutines.CoroutineScope import org.jetbrains.annotations.TestOnly /** @@ -85,3 +88,33 @@ public class WorkflowTestParams( public class StartFromState(public val state: StateT) : StartMode() } } + +// Helper function to create interceptors from WorkflowTestParams +public fun WorkflowTestParams.createInterceptors(): List { + val interceptors = mutableListOf() + + if (checkRenderIdempotence) { + interceptors += RenderIdempotencyChecker + } + + (startFrom as? StartFromState)?.let { startFrom -> + interceptors += object : WorkflowInterceptor { + @Suppress("UNCHECKED_CAST") + override fun onInitialState( + props: P, + snapshot: Snapshot?, + workflowScope: CoroutineScope, + proceed: (P, Snapshot?, CoroutineScope) -> S, + session: WorkflowSession + ): S { + return if (session.parent == null) { + startFrom.state as S + } else { + proceed(props, snapshot, workflowScope) + } + } + } + } + + return interceptors +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt index febcb0485..91dcf79f5 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt @@ -8,8 +8,6 @@ import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowInterceptor -import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import com.squareup.workflow1.config.JvmTestRuntimeConfigTools import com.squareup.workflow1.renderWorkflowIn import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh @@ -301,35 +299,6 @@ public fun } } -private fun WorkflowTestParams<*>.createInterceptors(): List { - val interceptors = mutableListOf() - - if (checkRenderIdempotence) { - interceptors += RenderIdempotencyChecker - } - - (startFrom as? StartFromState)?.let { startFrom -> - interceptors += object : WorkflowInterceptor { - @Suppress("UNCHECKED_CAST") - override fun onInitialState( - props: P, - snapshot: Snapshot?, - workflowScope: CoroutineScope, - proceed: (P, Snapshot?, CoroutineScope) -> S, - session: WorkflowSession - ): S { - return if (session.parent == null) { - startFrom.state as S - } else { - proceed(props, snapshot, workflowScope) - } - } - } - } - - return interceptors -} - private fun unwrapCancellationCause(block: () -> T): T { try { return block() diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt index bfbd7a697..7dc6ff4cf 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt @@ -1,27 +1,30 @@ package com.squareup.workflow1.testing import app.cash.turbine.ReceiveTurbine -import app.cash.turbine.test -import app.cash.turbine.testIn import app.cash.turbine.turbineScope import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.config.JvmTestRuntimeConfigTools import com.squareup.workflow1.renderWorkflowIn +import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh +import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromCompleteSnapshot +import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromState +import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -34,7 +37,7 @@ import kotlin.time.Duration.Companion.milliseconds * state persistence as that is not needed for this style of test. * * The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the - * scope for the Workflow runtime for you but you can still specify context for it. + * scope for the Workflow runtime for you, but you can still specify context for it. * * A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for * any particular test. This is the max amount of time the test could spend waiting on a rendering. @@ -48,14 +51,78 @@ import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) public fun Workflow.renderForTest( props: StateFlow, + testParams: WorkflowTestParams = WorkflowTestParams(), coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), interceptors: List = emptyList(), - runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(), onOutput: suspend (OutputT) -> Unit = {}, testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, testCase: suspend WorkflowTurbine.() -> Unit -) { - val workflow = this +) = asStatefulWorkflow().renderForTest( + props, + testParams, + coroutineContext, + onOutput, + testTimeout, + testCase +) + +/** + * Version of [renderForTest] that does not require props. For Workflows that have [Unit] + * props type. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun Workflow.renderForTest( + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + testParams: WorkflowTestParams = WorkflowTestParams(), + interceptors: List = emptyList(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit +): Unit = renderForTest( + props = MutableStateFlow(Unit).asStateFlow(), + testParams = testParams, + coroutineContext = coroutineContext, + interceptors = interceptors, + onOutput = onOutput, + testTimeout = testTimeout, + testCase = testCase +) + +/** + * Version of [renderForTest] for a [StatefulWorkflow] + * that accepts [WorkflowTestParams] for configuring the test, + * including starting from a specific state or snapshot. + * + * @param props StateFlow of props to send to the workflow. + * @param testParams Test configuration parameters. See [WorkflowTestParams] for details. + * @param coroutineContext Optional [CoroutineContext] to use for the test. + * @param onOutput Callback for workflow outputs. + * @param testTimeout Maximum time to wait for workflow operations in milliseconds. + * @param testCase The test code to run with access to the [WorkflowTurbine]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun + StatefulWorkflow.renderForTest( + props: StateFlow, + testParams: WorkflowTestParams = WorkflowTestParams(), + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit + ) { + val workflow: Workflow = this + + // Determine the initial snapshot based on startFrom mode + val initialSnapshot = when (val startFrom = testParams.startFrom) { + StartFresh -> null + is StartFromWorkflowSnapshot -> TreeSnapshot.forRootOnly(startFrom.snapshot) + is StartFromCompleteSnapshot -> startFrom.snapshot + is StartFromState -> null + } + + val interceptors = testParams.createInterceptors() + + val runtimeConfig = testParams.runtimeConfig ?: JvmTestRuntimeConfigTools.getTestRuntimeConfig() runTest( context = coroutineContext, @@ -65,13 +132,13 @@ public fun Workflow.r // tests don't all have to do that themselves. val workflowRuntimeScope = CoroutineScope(coroutineContext) - // Capture outputs in a channel val outputsChannel = Channel(Channel.UNLIMITED) val renderings = renderWorkflowIn( workflow = workflow, props = props, scope = workflowRuntimeScope, + initialSnapshot = initialSnapshot, interceptors = interceptors, runtimeConfig = runtimeConfig, onOutput = { output -> @@ -122,35 +189,95 @@ public fun Workflow.r } /** - * Version of [renderForTest] that does not require props. For Workflows that have [Unit] - * props type. + * Version of [renderForTest] for a [StatefulWorkflow] + * that accepts [WorkflowTestParams] and doesn't require props. + * For Workflows that have [Unit] props type. */ @OptIn(ExperimentalCoroutinesApi::class) -public fun Workflow.renderForTest( - coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), - interceptors: List = emptyList(), - runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(), - onOutput: suspend (OutputT) -> Unit = {}, - testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, - testCase: suspend WorkflowTurbine.() -> Unit -): Unit = renderForTest( +public fun + StatefulWorkflow.renderForTest( + testParams: WorkflowTestParams = WorkflowTestParams(), + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit + ): Unit = renderForTest( props = MutableStateFlow(Unit).asStateFlow(), + testParams = testParams, + coroutineContext = coroutineContext, + onOutput = onOutput, + testTimeout = testTimeout, + testCase = testCase +) + +/** + * Convenience function to test a workflow starting from a specific state. + * + * This is equivalent to calling [renderForTest] with + * `testParams = WorkflowTestParams(startFrom = StartFromState(initialState))`. + * + * @param props StateFlow of props to send to the workflow. + * @param initialState The state to start the workflow from. + * @param coroutineContext Optional [CoroutineContext] to use for the test. + * @param onOutput Callback for workflow outputs. + * @param testTimeout Maximum time to wait for workflow operations in milliseconds. + * @param testCase The test code to run with access to the [WorkflowTurbine]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun + StatefulWorkflow.renderForTestFromStateWith( + props: StateFlow, + initialState: StateT, + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit + ): Unit = renderForTest( + props = props, + testParams = WorkflowTestParams(startFrom = StartFromState(initialState)), + coroutineContext = coroutineContext, + onOutput = onOutput, + testTimeout = testTimeout, + testCase = testCase +) + +/** + * Convenience function to test a workflow starting from a specific state. + * Version for workflows with [Unit] props. + * + * @param initialState The state to start the workflow from. + * @param coroutineContext Optional [CoroutineContext] to use for the test. + * @param onOutput Callback for workflow outputs. + * @param testTimeout Maximum time to wait for workflow operations in milliseconds. + * @param testCase The test code to run with access to the [WorkflowTurbine]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun + StatefulWorkflow.renderForTestFromStateWith( + initialState: StateT, + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit + ): Unit = renderForTestFromStateWith( + props = MutableStateFlow(Unit).asStateFlow(), + initialState = initialState, coroutineContext = coroutineContext, - interceptors = interceptors, - runtimeConfig = runtimeConfig, onOutput = onOutput, testTimeout = testTimeout, testCase = testCase ) /** - * Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific - * to Workflow renderings. + * Provides independent access to three flows emitted by the workflow runtime: renderings, snapshots, + * and outputs. Uses [shareIn] to broadcast the combined rendering/snapshot flow to multiple turbines, + * ensuring all emissions are available to all flows without race conditions. * - * @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is - * provided separately if any assertions or operations are needed from it. - * @property firstSnapshot The first snapshot of the Workflow runtime is made synchronously. This is - * provided separately if any assertions or operations are needed from it. + * @property firstRendering The first rendering, made synchronously when the workflow runtime starts. + * @property firstSnapshot The first snapshot, made synchronously when the workflow runtime starts. + * @property renderingTurbine Turbine for consuming subsequent renderings. + * @property snapshotTurbine Turbine for consuming subsequent snapshots. + * @property outputTurbine Turbine for consuming workflow outputs. */ public class WorkflowTurbine( public val firstRendering: RenderingT, diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt index 991eaacdb..70871a388 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt @@ -91,9 +91,11 @@ class WorkflowTurbineTest { context: RenderContext ): Pair Unit> { val increment = { - context.actionSink.send(action("increment") { - state = renderState + 1 - }) + context.actionSink.send( + action("increment") { + state = renderState + 1 + } + ) } return renderState to increment } @@ -201,10 +203,12 @@ class WorkflowTurbineTest { context: RenderContext ): Pair Unit> { val emitOutput = { - context.actionSink.send(action("emit") { - state = renderState + 1 - setOutput("output-$renderState") - }) + context.actionSink.send( + action("emit") { + state = renderState + 1 + setOutput("output-$renderState") + } + ) } return renderState to emitOutput } From 015085232d892692625b570ffd82800a3777f5e3 Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Sun, 19 Oct 2025 18:22:08 -0400 Subject: [PATCH 3/6] Fix unit tests, change order of base renderForTest method and convienience overloads --- .../workflow1/testing/WorkflowTurbine.kt | 110 +++++++++--------- .../workflow1/WorkflowsLifecycleTests.kt | 25 +++- 2 files changed, 74 insertions(+), 61 deletions(-) diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt index 7dc6ff4cf..a191c0780 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt @@ -49,58 +49,6 @@ import kotlin.time.Duration.Companion.milliseconds * The default [RuntimeConfig] will be the one specified via [JvmTestRuntimeConfigTools]. */ @OptIn(ExperimentalCoroutinesApi::class) -public fun Workflow.renderForTest( - props: StateFlow, - testParams: WorkflowTestParams = WorkflowTestParams(), - coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), - interceptors: List = emptyList(), - onOutput: suspend (OutputT) -> Unit = {}, - testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, - testCase: suspend WorkflowTurbine.() -> Unit -) = asStatefulWorkflow().renderForTest( - props, - testParams, - coroutineContext, - onOutput, - testTimeout, - testCase -) - -/** - * Version of [renderForTest] that does not require props. For Workflows that have [Unit] - * props type. - */ -@OptIn(ExperimentalCoroutinesApi::class) -public fun Workflow.renderForTest( - coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), - testParams: WorkflowTestParams = WorkflowTestParams(), - interceptors: List = emptyList(), - onOutput: suspend (OutputT) -> Unit = {}, - testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, - testCase: suspend WorkflowTurbine.() -> Unit -): Unit = renderForTest( - props = MutableStateFlow(Unit).asStateFlow(), - testParams = testParams, - coroutineContext = coroutineContext, - interceptors = interceptors, - onOutput = onOutput, - testTimeout = testTimeout, - testCase = testCase -) - -/** - * Version of [renderForTest] for a [StatefulWorkflow] - * that accepts [WorkflowTestParams] for configuring the test, - * including starting from a specific state or snapshot. - * - * @param props StateFlow of props to send to the workflow. - * @param testParams Test configuration parameters. See [WorkflowTestParams] for details. - * @param coroutineContext Optional [CoroutineContext] to use for the test. - * @param onOutput Callback for workflow outputs. - * @param testTimeout Maximum time to wait for workflow operations in milliseconds. - * @param testCase The test code to run with access to the [WorkflowTurbine]. - */ -@OptIn(ExperimentalCoroutinesApi::class) public fun StatefulWorkflow.renderForTest( props: StateFlow, @@ -189,9 +137,8 @@ public fun } /** - * Version of [renderForTest] for a [StatefulWorkflow] - * that accepts [WorkflowTestParams] and doesn't require props. - * For Workflows that have [Unit] props type. + * Version of [renderForTest] that does not require props. For [StatefulWorkflow]s that have [Unit] + * props type. */ @OptIn(ExperimentalCoroutinesApi::class) public fun @@ -210,6 +157,59 @@ public fun testCase = testCase ) +/** + * Version of [renderForTest] for any [Workflow] + * that accepts [WorkflowTestParams] for configuring the test, + * including starting from a specific state or snapshot. + * + * @param props StateFlow of props to send to the workflow. + * @param testParams Test configuration parameters. See [WorkflowTestParams] for details. + * @param coroutineContext Optional [CoroutineContext] to use for the test. + * @param onOutput Callback for workflow outputs. + * @param testTimeout Maximum time to wait for workflow operations in milliseconds. + * @param testCase The test code to run with access to the [WorkflowTurbine]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun Workflow.renderForTestForStartWith( + props: StateFlow, + testParams: WorkflowTestParams = WorkflowTestParams(), + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + interceptors: List = emptyList(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit +) = asStatefulWorkflow().renderForTest( + props, + testParams, + coroutineContext, + onOutput, + testTimeout, + testCase +) + +/** + * Version of [renderForTest] for any [Workflow] + * that accepts [WorkflowTestParams] and doesn't require props. + * For Workflows that have [Unit] props type. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun Workflow.renderForTestForStartWith( + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + testParams: WorkflowTestParams = WorkflowTestParams(), + interceptors: List = emptyList(), + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit +): Unit = renderForTestForStartWith( + props = MutableStateFlow(Unit).asStateFlow(), + testParams = testParams, + coroutineContext = coroutineContext, + interceptors = interceptors, + onOutput = onOutput, + testTimeout = testTimeout, + testCase = testCase +) + /** * Convenience function to test a workflow starting from a specific state. * diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt b/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt index 08c43bf2c..593404d6e 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt @@ -7,6 +7,7 @@ import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.NONE import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS +import com.squareup.workflow1.testing.WorkflowTestParams import com.squareup.workflow1.testing.renderForTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -85,7 +86,9 @@ class WorkflowsLifecycleTests( @Test fun sideEffectsStartedWhenExpected() { workflowWithSideEffects.renderForTest( - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { // One time starts but does not stop the side effect. repeat(1) { @@ -99,7 +102,9 @@ class WorkflowsLifecycleTests( @Test fun sideEffectsStoppedWhenExpected() { workflowWithSideEffects.renderForTest( - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { // Twice will start and stop the side effect. repeat(2) { @@ -113,7 +118,9 @@ class WorkflowsLifecycleTests( @Test fun childSessionWorkflowStartedWhenExpected() { workflowWithChildSession.renderForTest( - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { // One time starts but does not stop the child session workflow. repeat(1) { @@ -141,7 +148,9 @@ class WorkflowsLifecycleTests( val dispatcher = UnconfinedTestDispatcher() workflowWithSideEffects.renderForTest( coroutineContext = dispatcher, - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { val (_, setState) = awaitNextRendering() @@ -164,7 +173,9 @@ class WorkflowsLifecycleTests( @Test fun childSessionWorkflowStoppedWhenExpected() { workflowWithChildSession.renderForTest( - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { // Twice will start and stop the child session workflow. repeat(2) { @@ -187,7 +198,9 @@ class WorkflowsLifecycleTests( val dispatcher = UnconfinedTestDispatcher() workflowWithChildSession.renderForTest( coroutineContext = dispatcher, - runtimeConfig = runtimeConfig + testParams = WorkflowTestParams( + runtimeConfig = runtimeConfig + ) ) { val (_, setState) = awaitNextRendering() From 97afd39a8671780915e30f164ba5e086f46272d1 Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Wed, 5 Nov 2025 15:42:20 -0500 Subject: [PATCH 4/6] Address PR comments: remove wrong comment --- .../java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt index 70871a388..96efcc9a4 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.testing import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.action import com.squareup.workflow1.stateful @@ -150,7 +151,6 @@ class WorkflowTurbineTest { val (value1, _) = awaitNextRendering() assertEquals(1, value1) - // Now consume snapshots - they should be in sync val snapshot0 = awaitNextSnapshot() assertNotNull(snapshot0) From a3e3fc0de6d4613e9fa4eabfa12807bc1e529a29 Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Wed, 5 Nov 2025 15:43:23 -0500 Subject: [PATCH 5/6] Fix apiDump failure --- workflow-testing/api/workflow-testing.api | 25 ++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 63fd45a7f..0c82de88a 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -159,6 +159,10 @@ public final class com/squareup/workflow1/testing/WorkflowTestParams$StartMode$S public final fun getSnapshot ()Lcom/squareup/workflow1/Snapshot; } +public final class com/squareup/workflow1/testing/WorkflowTestParamsKt { + public static final fun createInterceptors (Lcom/squareup/workflow1/testing/WorkflowTestParams;)Ljava/util/List; +} + public final class com/squareup/workflow1/testing/WorkflowTestRuntime { public static final field Companion Lcom/squareup/workflow1/testing/WorkflowTestRuntime$Companion; public static final field DEFAULT_TIMEOUT_MS J @@ -193,12 +197,15 @@ public final class com/squareup/workflow1/testing/WorkflowTestRuntimeKt { public final class com/squareup/workflow1/testing/WorkflowTurbine { public static final field Companion Lcom/squareup/workflow1/testing/WorkflowTurbine$Companion; public static final field WORKFLOW_TEST_DEFAULT_TIMEOUT_MS J - public fun (Ljava/lang/Object;Lapp/cash/turbine/ReceiveTurbine;)V + public fun (Ljava/lang/Object;Lcom/squareup/workflow1/TreeSnapshot;Lapp/cash/turbine/ReceiveTurbine;Lapp/cash/turbine/ReceiveTurbine;Lapp/cash/turbine/ReceiveTurbine;)V public final fun awaitNext (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun awaitNext$default (Lcom/squareup/workflow1/testing/WorkflowTurbine;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun awaitNextOutput (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun awaitNextRendering (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun awaitNextRenderingSatisfying (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun awaitNextSnapshot (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getFirstRendering ()Ljava/lang/Object; + public final fun getFirstSnapshot ()Lcom/squareup/workflow1/TreeSnapshot; public final fun skipRenderings (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -206,9 +213,17 @@ public final class com/squareup/workflow1/testing/WorkflowTurbine$Companion { } public final class com/squareup/workflow1/testing/WorkflowTurbineKt { - public static final fun renderForTest (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V - public static final fun renderForTest (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V - public static synthetic fun renderForTest$default (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V - public static synthetic fun renderForTest$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun renderForTest (Lcom/squareup/workflow1/StatefulWorkflow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static final fun renderForTest (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun renderForTest$default (Lcom/squareup/workflow1/StatefulWorkflow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun renderForTest$default (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun renderForTestForStartWith (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Lcom/squareup/workflow1/testing/WorkflowTestParams;Ljava/util/List;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static final fun renderForTestForStartWith (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun renderForTestForStartWith$default (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Lcom/squareup/workflow1/testing/WorkflowTestParams;Ljava/util/List;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun renderForTestForStartWith$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static final fun renderForTestFromStateWith (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static final fun renderForTestFromStateWith (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlinx/coroutines/flow/StateFlow;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun renderForTestFromStateWith$default (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun renderForTestFromStateWith$default (Lcom/squareup/workflow1/StatefulWorkflow;Lkotlinx/coroutines/flow/StateFlow;Ljava/lang/Object;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } From cb733ce37de39a45af354ca0cd6a6f71e298c915 Mon Sep 17 00:00:00 2001 From: Isaac Olukunle Date: Wed, 5 Nov 2025 16:18:58 -0500 Subject: [PATCH 6/6] Make WorkflowTurbineTest better by actually testing snapshot values --- workflow-runtime/api/workflow-runtime.api | 1 + .../com/squareup/workflow1/TreeSnapshot.kt | 4 +++ .../workflow1/testing/WorkflowTurbineTest.kt | 29 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index 4498c7384..328d7193b 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -44,6 +44,7 @@ public final class com/squareup/workflow1/TreeSnapshot { public fun equals (Ljava/lang/Object;)Z public fun hashCode ()I public final fun toByteString ()Lokio/ByteString; + public final fun workflowSnapshotByteString ()Lokio/ByteString; } public final class com/squareup/workflow1/TreeSnapshot$Companion { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/TreeSnapshot.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/TreeSnapshot.kt index 0ee986a1a..e4c99bc40 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/TreeSnapshot.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/TreeSnapshot.kt @@ -63,6 +63,10 @@ public class TreeSnapshot internal constructor( sink.readByteString() } + fun workflowSnapshotByteString(): ByteString? { + return workflowSnapshot?.bytes + } + override fun equals(other: Any?): Boolean = when { other === this -> true other !is TreeSnapshot -> false diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt index 96efcc9a4..c61459c46 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt @@ -5,6 +5,7 @@ import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.action +import com.squareup.workflow1.parse import com.squareup.workflow1.stateful import kotlin.test.Test import kotlin.test.assertEquals @@ -44,6 +45,8 @@ class WorkflowTurbineTest { // First snapshot should exist val firstSnapshot = awaitNextSnapshot() assertNotNull(firstSnapshot) + val actualSnapshotValue = firstSnapshot.readIntValue() + assertEquals(42, actualSnapshotValue) } } @@ -77,6 +80,7 @@ class WorkflowTurbineTest { assertNotNull(firstSnapshot) // awaitNextSnapshot should return the same value assertEquals(firstSnapshot, awaitNextSnapshot()) + assertEquals("hello", firstSnapshot.readStringValue()) } } @@ -114,6 +118,7 @@ class WorkflowTurbineTest { // Now get first snapshot - should still be for state 0 val snapshot0 = awaitNextSnapshot() assertNotNull(snapshot0) + assertEquals(0, snapshot0.readIntValue()) // Now get second rendering - should be state 1 val (value1, increment1) = awaitNextRendering() @@ -123,6 +128,7 @@ class WorkflowTurbineTest { val snapshot1 = awaitNextSnapshot() assertNotNull(snapshot1) assertNotEquals(snapshot0, snapshot1) + assertEquals(1, snapshot1.readIntValue()) // Trigger another change increment1() @@ -135,6 +141,7 @@ class WorkflowTurbineTest { val snapshot2 = awaitNextSnapshot() assertNotNull(snapshot2) assertNotEquals(snapshot1, snapshot2) + assertEquals(2, snapshot2.readIntValue()) } } @@ -153,10 +160,12 @@ class WorkflowTurbineTest { val snapshot0 = awaitNextSnapshot() assertNotNull(snapshot0) + assertEquals(0, snapshot0.readIntValue()) val snapshot1 = awaitNextSnapshot() assertNotNull(snapshot1) assertNotEquals(snapshot0, snapshot1) + assertEquals(1, snapshot1.readIntValue()) } } @@ -177,12 +186,15 @@ class WorkflowTurbineTest { // Now consume snapshots - should have all 3 available because shareIn broadcasted to both val snapshot0 = awaitNextSnapshot() assertNotNull(snapshot0) + assertEquals(0, snapshot0.readIntValue()) val snapshot1 = awaitNextSnapshot() assertNotNull(snapshot1) + assertEquals(1, snapshot1.readIntValue()) val snapshot2 = awaitNextSnapshot() assertNotNull(snapshot2) + assertEquals(2, snapshot2.readIntValue()) // All snapshots should be different assertNotEquals(snapshot0, snapshot1) @@ -309,6 +321,11 @@ class WorkflowTurbineTest { assertNotNull(snapshot2) assertNotNull(snapshot3) + assertEquals(0, snapshot0.readIntValue()) + assertEquals(1, snapshot1.readIntValue()) + assertEquals(2, snapshot2.readIntValue()) + assertEquals(3, snapshot3.readIntValue()) + // All should be different assertNotEquals(snapshot0, snapshot1) assertNotEquals(snapshot1, snapshot2) @@ -316,3 +333,15 @@ class WorkflowTurbineTest { } } } + +/** + * Extension function to read an Int value from a TreeSnapshot. + */ +private fun TreeSnapshot.readIntValue(): Int? = + workflowSnapshotByteString()?.parse { it.readInt() } + +/*** + * Extension function to read a String value from a TreeSnapshot. + */ +private fun TreeSnapshot.readStringValue(): String? = + workflowSnapshotByteString()?.parse { it.readUtf8() }