Skip to content

Commit d46ca91

Browse files
committed
Add restoring from snapshot and idempotency interceptor
1 parent d65746e commit d46ca91

File tree

4 files changed

+198
-65
lines changed

4 files changed

+198
-65
lines changed

workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestParams.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package com.squareup.workflow1.testing
33
import com.squareup.workflow1.RuntimeConfig
44
import com.squareup.workflow1.Snapshot
55
import com.squareup.workflow1.TreeSnapshot
6+
import com.squareup.workflow1.WorkflowInterceptor
7+
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
68
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode
79
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh
810
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromCompleteSnapshot
911
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromState
1012
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot
13+
import kotlinx.coroutines.CoroutineScope
1114
import org.jetbrains.annotations.TestOnly
1215

1316
/**
@@ -85,3 +88,33 @@ public class WorkflowTestParams<out StateT>(
8588
public class StartFromState<StateT>(public val state: StateT) : StartMode<StateT>()
8689
}
8790
}
91+
92+
// Helper function to create interceptors from WorkflowTestParams
93+
public fun <StateT> WorkflowTestParams<StateT>.createInterceptors(): List<WorkflowInterceptor> {
94+
val interceptors = mutableListOf<WorkflowInterceptor>()
95+
96+
if (checkRenderIdempotence) {
97+
interceptors += RenderIdempotencyChecker
98+
}
99+
100+
(startFrom as? StartFromState)?.let { startFrom ->
101+
interceptors += object : WorkflowInterceptor {
102+
@Suppress("UNCHECKED_CAST")
103+
override fun <P, S> onInitialState(
104+
props: P,
105+
snapshot: Snapshot?,
106+
workflowScope: CoroutineScope,
107+
proceed: (P, Snapshot?, CoroutineScope) -> S,
108+
session: WorkflowSession
109+
): S {
110+
return if (session.parent == null) {
111+
startFrom.state as S
112+
} else {
113+
proceed(props, snapshot, workflowScope)
114+
}
115+
}
116+
}
117+
}
118+
119+
return interceptors
120+
}

workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTestRuntime.kt

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import com.squareup.workflow1.Snapshot
88
import com.squareup.workflow1.StatefulWorkflow
99
import com.squareup.workflow1.TreeSnapshot
1010
import com.squareup.workflow1.Workflow
11-
import com.squareup.workflow1.WorkflowInterceptor
12-
import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession
1311
import com.squareup.workflow1.config.JvmTestRuntimeConfigTools
1412
import com.squareup.workflow1.renderWorkflowIn
1513
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh
@@ -301,35 +299,6 @@ public fun <T, PropsT, StateT, OutputT, RenderingT>
301299
}
302300
}
303301

304-
private fun WorkflowTestParams<*>.createInterceptors(): List<WorkflowInterceptor> {
305-
val interceptors = mutableListOf<WorkflowInterceptor>()
306-
307-
if (checkRenderIdempotence) {
308-
interceptors += RenderIdempotencyChecker
309-
}
310-
311-
(startFrom as? StartFromState)?.let { startFrom ->
312-
interceptors += object : WorkflowInterceptor {
313-
@Suppress("UNCHECKED_CAST")
314-
override fun <P, S> onInitialState(
315-
props: P,
316-
snapshot: Snapshot?,
317-
workflowScope: CoroutineScope,
318-
proceed: (P, Snapshot?, CoroutineScope) -> S,
319-
session: WorkflowSession
320-
): S {
321-
return if (session.parent == null) {
322-
startFrom.state as S
323-
} else {
324-
proceed(props, snapshot, workflowScope)
325-
}
326-
}
327-
}
328-
}
329-
330-
return interceptors
331-
}
332-
333302
private fun <T> unwrapCancellationCause(block: () -> T): T {
334303
try {
335304
return block()

workflow-testing/src/main/java/com/squareup/workflow1/testing/WorkflowTurbine.kt

Lines changed: 154 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
package com.squareup.workflow1.testing
22

33
import app.cash.turbine.ReceiveTurbine
4-
import app.cash.turbine.test
5-
import app.cash.turbine.testIn
64
import app.cash.turbine.turbineScope
75
import com.squareup.workflow1.RuntimeConfig
6+
import com.squareup.workflow1.StatefulWorkflow
87
import com.squareup.workflow1.TreeSnapshot
98
import com.squareup.workflow1.Workflow
109
import com.squareup.workflow1.WorkflowInterceptor
1110
import com.squareup.workflow1.config.JvmTestRuntimeConfigTools
1211
import com.squareup.workflow1.renderWorkflowIn
12+
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh
13+
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromCompleteSnapshot
14+
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromState
15+
import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot
1316
import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS
1417
import kotlinx.coroutines.CoroutineScope
1518
import kotlinx.coroutines.ExperimentalCoroutinesApi
1619
import kotlinx.coroutines.cancel
1720
import kotlinx.coroutines.channels.Channel
1821
import kotlinx.coroutines.flow.MutableStateFlow
19-
import kotlinx.coroutines.flow.receiveAsFlow
22+
import kotlinx.coroutines.flow.SharingStarted
2023
import kotlinx.coroutines.flow.StateFlow
2124
import kotlinx.coroutines.flow.asStateFlow
22-
import kotlinx.coroutines.flow.SharingStarted
2325
import kotlinx.coroutines.flow.drop
2426
import kotlinx.coroutines.flow.map
27+
import kotlinx.coroutines.flow.receiveAsFlow
2528
import kotlinx.coroutines.flow.shareIn
2629
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2730
import kotlinx.coroutines.test.runTest
@@ -34,7 +37,7 @@ import kotlin.time.Duration.Companion.milliseconds
3437
* state persistence as that is not needed for this style of test.
3538
*
3639
* The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the
37-
* scope for the Workflow runtime for you but you can still specify context for it.
40+
* scope for the Workflow runtime for you, but you can still specify context for it.
3841
*
3942
* A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for
4043
* 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
4851
@OptIn(ExperimentalCoroutinesApi::class)
4952
public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.renderForTest(
5053
props: StateFlow<PropsT>,
54+
testParams: WorkflowTestParams<Nothing> = WorkflowTestParams(),
5155
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
5256
interceptors: List<WorkflowInterceptor> = emptyList(),
53-
runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(),
5457
onOutput: suspend (OutputT) -> Unit = {},
5558
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
5659
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
57-
) {
58-
val workflow = this
60+
) = asStatefulWorkflow().renderForTest(
61+
props,
62+
testParams,
63+
coroutineContext,
64+
onOutput,
65+
testTimeout,
66+
testCase
67+
)
68+
69+
/**
70+
* Version of [renderForTest] that does not require props. For Workflows that have [Unit]
71+
* props type.
72+
*/
73+
@OptIn(ExperimentalCoroutinesApi::class)
74+
public fun <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.renderForTest(
75+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
76+
testParams: WorkflowTestParams<Nothing> = WorkflowTestParams(),
77+
interceptors: List<WorkflowInterceptor> = emptyList(),
78+
onOutput: suspend (OutputT) -> Unit = {},
79+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
80+
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
81+
): Unit = renderForTest(
82+
props = MutableStateFlow(Unit).asStateFlow(),
83+
testParams = testParams,
84+
coroutineContext = coroutineContext,
85+
interceptors = interceptors,
86+
onOutput = onOutput,
87+
testTimeout = testTimeout,
88+
testCase = testCase
89+
)
90+
91+
/**
92+
* Version of [renderForTest] for a [StatefulWorkflow]
93+
* that accepts [WorkflowTestParams] for configuring the test,
94+
* including starting from a specific state or snapshot.
95+
*
96+
* @param props StateFlow of props to send to the workflow.
97+
* @param testParams Test configuration parameters. See [WorkflowTestParams] for details.
98+
* @param coroutineContext Optional [CoroutineContext] to use for the test.
99+
* @param onOutput Callback for workflow outputs.
100+
* @param testTimeout Maximum time to wait for workflow operations in milliseconds.
101+
* @param testCase The test code to run with access to the [WorkflowTurbine].
102+
*/
103+
@OptIn(ExperimentalCoroutinesApi::class)
104+
public fun <PropsT, StateT, OutputT, RenderingT>
105+
StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>.renderForTest(
106+
props: StateFlow<PropsT>,
107+
testParams: WorkflowTestParams<StateT> = WorkflowTestParams(),
108+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
109+
onOutput: suspend (OutputT) -> Unit = {},
110+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
111+
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
112+
) {
113+
val workflow: Workflow<PropsT, OutputT, RenderingT> = this
114+
115+
// Determine the initial snapshot based on startFrom mode
116+
val initialSnapshot = when (val startFrom = testParams.startFrom) {
117+
StartFresh -> null
118+
is StartFromWorkflowSnapshot -> TreeSnapshot.forRootOnly(startFrom.snapshot)
119+
is StartFromCompleteSnapshot -> startFrom.snapshot
120+
is StartFromState -> null
121+
}
122+
123+
val interceptors = testParams.createInterceptors()
124+
125+
val runtimeConfig = testParams.runtimeConfig ?: JvmTestRuntimeConfigTools.getTestRuntimeConfig()
59126

60127
runTest(
61128
context = coroutineContext,
@@ -65,13 +132,13 @@ public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.r
65132
// tests don't all have to do that themselves.
66133
val workflowRuntimeScope = CoroutineScope(coroutineContext)
67134

68-
// Capture outputs in a channel
69135
val outputsChannel = Channel<OutputT>(Channel.UNLIMITED)
70136

71137
val renderings = renderWorkflowIn(
72138
workflow = workflow,
73139
props = props,
74140
scope = workflowRuntimeScope,
141+
initialSnapshot = initialSnapshot,
75142
interceptors = interceptors,
76143
runtimeConfig = runtimeConfig,
77144
onOutput = { output ->
@@ -122,35 +189,95 @@ public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.r
122189
}
123190

124191
/**
125-
* Version of [renderForTest] that does not require props. For Workflows that have [Unit]
126-
* props type.
192+
* Version of [renderForTest] for a [StatefulWorkflow]
193+
* that accepts [WorkflowTestParams] and doesn't require props.
194+
* For Workflows that have [Unit] props type.
127195
*/
128196
@OptIn(ExperimentalCoroutinesApi::class)
129-
public fun <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.renderForTest(
130-
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
131-
interceptors: List<WorkflowInterceptor> = emptyList(),
132-
runtimeConfig: RuntimeConfig = JvmTestRuntimeConfigTools.getTestRuntimeConfig(),
133-
onOutput: suspend (OutputT) -> Unit = {},
134-
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
135-
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
136-
): Unit = renderForTest(
197+
public fun <StateT, OutputT, RenderingT>
198+
StatefulWorkflow<Unit, StateT, OutputT, RenderingT>.renderForTest(
199+
testParams: WorkflowTestParams<StateT> = WorkflowTestParams(),
200+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
201+
onOutput: suspend (OutputT) -> Unit = {},
202+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
203+
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
204+
): Unit = renderForTest(
137205
props = MutableStateFlow(Unit).asStateFlow(),
206+
testParams = testParams,
207+
coroutineContext = coroutineContext,
208+
onOutput = onOutput,
209+
testTimeout = testTimeout,
210+
testCase = testCase
211+
)
212+
213+
/**
214+
* Convenience function to test a workflow starting from a specific state.
215+
*
216+
* This is equivalent to calling [renderForTest] with
217+
* `testParams = WorkflowTestParams(startFrom = StartFromState(initialState))`.
218+
*
219+
* @param props StateFlow of props to send to the workflow.
220+
* @param initialState The state to start the workflow from.
221+
* @param coroutineContext Optional [CoroutineContext] to use for the test.
222+
* @param onOutput Callback for workflow outputs.
223+
* @param testTimeout Maximum time to wait for workflow operations in milliseconds.
224+
* @param testCase The test code to run with access to the [WorkflowTurbine].
225+
*/
226+
@OptIn(ExperimentalCoroutinesApi::class)
227+
public fun <PropsT, StateT, OutputT, RenderingT>
228+
StatefulWorkflow<PropsT, StateT, OutputT, RenderingT>.renderForTestFromStateWith(
229+
props: StateFlow<PropsT>,
230+
initialState: StateT,
231+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
232+
onOutput: suspend (OutputT) -> Unit = {},
233+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
234+
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
235+
): Unit = renderForTest(
236+
props = props,
237+
testParams = WorkflowTestParams(startFrom = StartFromState(initialState)),
238+
coroutineContext = coroutineContext,
239+
onOutput = onOutput,
240+
testTimeout = testTimeout,
241+
testCase = testCase
242+
)
243+
244+
/**
245+
* Convenience function to test a workflow starting from a specific state.
246+
* Version for workflows with [Unit] props.
247+
*
248+
* @param initialState The state to start the workflow from.
249+
* @param coroutineContext Optional [CoroutineContext] to use for the test.
250+
* @param onOutput Callback for workflow outputs.
251+
* @param testTimeout Maximum time to wait for workflow operations in milliseconds.
252+
* @param testCase The test code to run with access to the [WorkflowTurbine].
253+
*/
254+
@OptIn(ExperimentalCoroutinesApi::class)
255+
public fun <StateT, OutputT, RenderingT>
256+
StatefulWorkflow<Unit, StateT, OutputT, RenderingT>.renderForTestFromStateWith(
257+
initialState: StateT,
258+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
259+
onOutput: suspend (OutputT) -> Unit = {},
260+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
261+
testCase: suspend WorkflowTurbine<RenderingT, OutputT>.() -> Unit
262+
): Unit = renderForTestFromStateWith(
263+
props = MutableStateFlow(Unit).asStateFlow(),
264+
initialState = initialState,
138265
coroutineContext = coroutineContext,
139-
interceptors = interceptors,
140-
runtimeConfig = runtimeConfig,
141266
onOutput = onOutput,
142267
testTimeout = testTimeout,
143268
testCase = testCase
144269
)
145270

146271
/**
147-
* Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific
148-
* to Workflow renderings.
272+
* Provides independent access to three flows emitted by the workflow runtime: renderings, snapshots,
273+
* and outputs. Uses [shareIn] to broadcast the combined rendering/snapshot flow to multiple turbines,
274+
* ensuring all emissions are available to all flows without race conditions.
149275
*
150-
* @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is
151-
* provided separately if any assertions or operations are needed from it.
152-
* @property firstSnapshot The first snapshot of the Workflow runtime is made synchronously. This is
153-
* provided separately if any assertions or operations are needed from it.
276+
* @property firstRendering The first rendering, made synchronously when the workflow runtime starts.
277+
* @property firstSnapshot The first snapshot, made synchronously when the workflow runtime starts.
278+
* @property renderingTurbine Turbine for consuming subsequent renderings.
279+
* @property snapshotTurbine Turbine for consuming subsequent snapshots.
280+
* @property outputTurbine Turbine for consuming workflow outputs.
154281
*/
155282
public class WorkflowTurbine<RenderingT, OutputT>(
156283
public val firstRendering: RenderingT,

workflow-testing/src/test/java/com/squareup/workflow1/testing/WorkflowTurbineTest.kt

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,11 @@ class WorkflowTurbineTest {
9191
context: RenderContext<Unit, Int, Nothing>
9292
): Pair<Int, () -> Unit> {
9393
val increment = {
94-
context.actionSink.send(action("increment") {
95-
state = renderState + 1
96-
})
94+
context.actionSink.send(
95+
action("increment") {
96+
state = renderState + 1
97+
}
98+
)
9799
}
98100
return renderState to increment
99101
}
@@ -201,10 +203,12 @@ class WorkflowTurbineTest {
201203
context: RenderContext<Unit, Int, String>
202204
): Pair<Int, () -> Unit> {
203205
val emitOutput = {
204-
context.actionSink.send(action("emit") {
205-
state = renderState + 1
206-
setOutput("output-$renderState")
207-
})
206+
context.actionSink.send(
207+
action("emit") {
208+
state = renderState + 1
209+
setOutput("output-$renderState")
210+
}
211+
)
208212
}
209213
return renderState to emitOutput
210214
}

0 commit comments

Comments
 (0)