Skip to content

Commit c879b40

Browse files
Add HeadlessIntegrationTest to Workflow
1 parent a69a23c commit c879b40

File tree

2 files changed

+211
-0
lines changed

2 files changed

+211
-0
lines changed

workflow-testing/api/workflow-testing.api

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
public final class com/squareup/workflow1/testing/HeadlessIntegrationTestKt {
2+
public static final fun headlessIntegrationTest (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V
3+
public static final fun headlessIntegrationTest (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
4+
public static synthetic fun headlessIntegrationTest$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
5+
public static synthetic fun headlessIntegrationTest$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
6+
}
7+
18
public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com/squareup/workflow1/WorkflowInterceptor {
29
public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderIdempotencyChecker;
310
public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
@@ -155,3 +162,18 @@ public final class com/squareup/workflow1/testing/WorkflowTestRuntimeKt {
155162
public static synthetic fun launchForTestingWith$default (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object;
156163
}
157164

165+
public final class com/squareup/workflow1/testing/WorkflowTurbine {
166+
public static final field Companion Lcom/squareup/workflow1/testing/WorkflowTurbine$Companion;
167+
public static final field WORKFLOW_TEST_DEFAULT_TIMEOUT_MS J
168+
public fun <init> (Ljava/lang/Object;Lapp/cash/turbine/ReceiveTurbine;)V
169+
public final fun awaitNext (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
170+
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;
171+
public final fun awaitNextRendering (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
172+
public final fun awaitNextRenderingSatisfying (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
173+
public final fun getFirstRendering ()Ljava/lang/Object;
174+
public final fun skipRenderings (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
175+
}
176+
177+
public final class com/squareup/workflow1/testing/WorkflowTurbine$Companion {
178+
}
179+
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.squareup.workflow1.testing
2+
3+
import app.cash.turbine.ReceiveTurbine
4+
import app.cash.turbine.test
5+
import com.squareup.workflow1.RuntimeConfig
6+
import com.squareup.workflow1.RuntimeConfigOptions
7+
import com.squareup.workflow1.Workflow
8+
import com.squareup.workflow1.WorkflowInterceptor
9+
import com.squareup.workflow1.renderWorkflowIn
10+
import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.ExperimentalCoroutinesApi
13+
import kotlinx.coroutines.cancel
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.flow.StateFlow
16+
import kotlinx.coroutines.flow.asStateFlow
17+
import kotlinx.coroutines.flow.drop
18+
import kotlinx.coroutines.flow.map
19+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
20+
import kotlinx.coroutines.test.runTest
21+
import kotlin.coroutines.CoroutineContext
22+
import kotlin.time.Duration.Companion.milliseconds
23+
24+
/**
25+
* This is a test harness to run integration tests for a Workflow tree. The parameters passed here are
26+
* the same as those to start a Workflow runtime with [renderWorkflowIn] except for ignoring
27+
* state persistence as that is not needed for this style of test.
28+
*
29+
* The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the
30+
* scope for the Workflow runtime for you but you can still specify context for it.
31+
*
32+
* A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for
33+
* any particular test. This is the max amount of time the test could spend waiting on a rendering.
34+
*
35+
* This will start the Workflow runtime (with params as passed) rooted at whatever Workflow
36+
* it is called on and then create a [WorkflowTurbine] for its renderings and run [testCase] on that.
37+
* [testCase] can thus drive the test scenario and assert against renderings.
38+
*/
39+
@OptIn(ExperimentalCoroutinesApi::class)
40+
public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.headlessIntegrationTest(
41+
props: StateFlow<PropsT>,
42+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
43+
interceptors: List<WorkflowInterceptor> = emptyList(),
44+
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
45+
onOutput: suspend (OutputT) -> Unit = {},
46+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
47+
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
48+
) {
49+
val workflow = this
50+
51+
runTest(
52+
context = coroutineContext,
53+
timeout = testTimeout.milliseconds
54+
) {
55+
// We use a sub-scope so that we can cancel the Workflow runtime when we are done with it so that
56+
// tests don't all have to do that themselves.
57+
val workflowRuntimeScope = CoroutineScope(coroutineContext)
58+
val renderings = renderWorkflowIn(
59+
workflow = workflow,
60+
props = props,
61+
scope = workflowRuntimeScope,
62+
interceptors = interceptors,
63+
runtimeConfig = runtimeConfig,
64+
onOutput = onOutput
65+
)
66+
67+
val firstRendering = renderings.value.rendering
68+
69+
// Drop one as its provided separately via `firstRendering`.
70+
renderings.drop(1).map {
71+
it.rendering
72+
}.test {
73+
val workflowTurbine = WorkflowTurbine(
74+
firstRendering,
75+
this
76+
)
77+
workflowTurbine.testCase()
78+
cancelAndIgnoreRemainingEvents()
79+
}
80+
workflowRuntimeScope.cancel()
81+
}
82+
}
83+
84+
/**
85+
* Version of [headlessIntegrationTest] that does not require props. For Workflows that have [Unit]
86+
* props type.
87+
*/
88+
@OptIn(ExperimentalCoroutinesApi::class)
89+
public fun <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.headlessIntegrationTest(
90+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
91+
interceptors: List<WorkflowInterceptor> = emptyList(),
92+
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
93+
onOutput: suspend (OutputT) -> Unit = {},
94+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
95+
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
96+
): Unit = headlessIntegrationTest(
97+
props = MutableStateFlow(Unit).asStateFlow(),
98+
coroutineContext = coroutineContext,
99+
interceptors = interceptors,
100+
runtimeConfig = runtimeConfig,
101+
onOutput = onOutput,
102+
testTimeout = testTimeout,
103+
testCase = testCase
104+
)
105+
106+
/**
107+
* Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific
108+
* to Workflow renderings.
109+
*
110+
* @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is
111+
* provided separately if any assertions or operations are needed from it.
112+
*/
113+
public class WorkflowTurbine<RenderingT>(
114+
public val firstRendering: RenderingT,
115+
private val receiveTurbine: ReceiveTurbine<RenderingT>
116+
) {
117+
private var usedFirst = false
118+
119+
/**
120+
* Suspend waiting for the next rendering to be produced by the Workflow runtime. Note this includes
121+
* the first (synchronously made) rendering.
122+
*
123+
* @return the rendering.
124+
*/
125+
public suspend fun awaitNextRendering(): RenderingT {
126+
if (!usedFirst) {
127+
usedFirst = true
128+
return firstRendering
129+
}
130+
return receiveTurbine.awaitItem()
131+
}
132+
133+
public suspend fun skipRenderings(count: Int) {
134+
val skippedCount = if (!usedFirst) {
135+
usedFirst = true
136+
count - 1
137+
} else {
138+
count
139+
}
140+
141+
if (skippedCount > 0) {
142+
receiveTurbine.skipItems(skippedCount)
143+
}
144+
}
145+
146+
/**
147+
* Suspend waiting for the next rendering to be produced by the Workflow runtime that satisfies the
148+
* [predicate].
149+
*
150+
* @return the rendering.
151+
*/
152+
public suspend fun awaitNextRenderingSatisfying(
153+
predicate: (RenderingT) -> Boolean
154+
): RenderingT {
155+
var rendering = awaitNextRendering()
156+
while (!predicate(rendering)) {
157+
rendering = awaitNextRendering()
158+
}
159+
return rendering
160+
}
161+
162+
/**
163+
* Suspend waiting for the next rendering which satisfies [precondition], can successfully be mapped
164+
* using [map] and satisfies the [satisfying] predicate when called on the [T] rendering after it
165+
* has been mapped.
166+
*
167+
* @return the mapped rendering as [T]
168+
*/
169+
public suspend fun <T> awaitNext(
170+
precondition: (RenderingT) -> Boolean = { true },
171+
map: (RenderingT) -> T,
172+
satisfying: T.() -> Boolean = { true }
173+
): T =
174+
map(
175+
awaitNextRenderingSatisfying {
176+
precondition(it) &&
177+
with(map(it)) {
178+
this.satisfying()
179+
}
180+
}
181+
)
182+
183+
public companion object {
184+
/**
185+
* Default timeout to use while waiting for renderings.
186+
*/
187+
public const val WORKFLOW_TEST_DEFAULT_TIMEOUT_MS: Long = 60_000L
188+
}
189+
}

0 commit comments

Comments
 (0)