Skip to content

Commit

Permalink
1093: Yield in runtime to run side effects that were launched lazily
Browse files Browse the repository at this point in the history
Add headlessIntegrationTest to renderWorkflowIn with a nice Turbine attached.

Closes #1093
  • Loading branch information
steve-the-edwards committed Aug 3, 2023
1 parent 551ee52 commit f896709
Show file tree
Hide file tree
Showing 7 changed files with 413 additions and 3 deletions.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ android.useAndroidX=true
systemProp.org.gradle.internal.publish.checksums.insecure=true

GROUP=com.squareup.workflow1
VERSION_NAME=1.11.0-beta03-SNAPSHOT
VERSION_NAME=1.11.0-beta03-v-SNAPSHOT

POM_DESCRIPTION=Square Workflow

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield

/**
* Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its
Expand Down Expand Up @@ -188,6 +189,8 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
// If this is not null, then we had an Output and we want to send it with the Rendering
// (stale or not).
while (actionResult is ActionApplied<*> && actionResult.output == null) {
// Yield if there are any side effects (they were launched lazily) that need starting.
yield()
// We have more actions we can process, so this rendering is stale.
actionResult = runner.processAction(waitForAnAction = false)

Expand All @@ -204,6 +207,8 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
renderingsAndSnapshots.value = nextRenderAndSnapshot
// And emit the Output.
sendOutput(actionResult, onOutput)
// Yield if there are any side effects (they were launched lazily) that need starting.
yield()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.Unconfined
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -29,7 +28,7 @@ import kotlinx.coroutines.test.runCurrent
import okio.ByteString
import kotlin.test.Test

@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, WorkflowExperimentalRuntime::class)
@OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class)
class RenderWorkflowInTest {

/**
Expand Down
22 changes: 22 additions & 0 deletions workflow-testing/api/workflow-testing.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
public final class com/squareup/workflow1/testing/HeadlessIntegrationTestKt {
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
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
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
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
}

public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com/squareup/workflow1/WorkflowInterceptor {
public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderIdempotencyChecker;
public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object;
Expand Down Expand Up @@ -155,3 +162,18 @@ public final class com/squareup/workflow1/testing/WorkflowTestRuntimeKt {
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;
}

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 <init> (Ljava/lang/Object;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 awaitNextRendering (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun awaitNextRenderingSatisfying (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getFirstRendering ()Ljava/lang/Object;
public final fun skipRenderings (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class com/squareup/workflow1/testing/WorkflowTurbine$Companion {
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package com.squareup.workflow1.testing

import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.squareup.workflow1.RuntimeConfig
import com.squareup.workflow1.RuntimeConfigOptions
import com.squareup.workflow1.Workflow
import com.squareup.workflow1.WorkflowInterceptor
import com.squareup.workflow1.renderWorkflowIn
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.coroutines.CoroutineContext
import kotlin.time.Duration.Companion.milliseconds

/**
* This is a test harness to run integration tests for a Workflow tree. The parameters passed here are
* the same as those to start a Workflow runtime with [renderWorkflowIn] except for ignoring
* 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.
*
* 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.
*
* This will start the Workflow runtime (with params as passed) rooted at whatever Workflow
* it is called on and then create a [WorkflowTurbine] for its renderings and run [testCase] on that.
* [testCase] can thus drive the test scenario and assert against renderings.
*/
@OptIn(ExperimentalCoroutinesApi::class)
public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.headlessIntegrationTest(
props: StateFlow<PropsT>,
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
interceptors: List<WorkflowInterceptor> = emptyList(),
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
onOutput: suspend (OutputT) -> Unit = {},
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
) {
val workflow = this

runTest(
context = coroutineContext,
timeout = testTimeout.milliseconds
) {
// 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)
val renderings = renderWorkflowIn(
workflow = workflow,
props = props,
scope = workflowRuntimeScope,
interceptors = interceptors,
runtimeConfig = runtimeConfig,
onOutput = onOutput
)

val firstRendering = renderings.value.rendering

// Drop one as its provided separately via `firstRendering`.
renderings.drop(1).map {
it.rendering
}.test {
val workflowTurbine = WorkflowTurbine(
firstRendering,
this
)
workflowTurbine.testCase()
cancelAndIgnoreRemainingEvents()
}
workflowRuntimeScope.cancel()
}
}

/**
* Version of [headlessIntegrationTest] that does not require props. For Workflows that have [Unit]
* props type.
*/
@OptIn(ExperimentalCoroutinesApi::class)
public fun <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.headlessIntegrationTest(
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
interceptors: List<WorkflowInterceptor> = emptyList(),
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
onOutput: suspend (OutputT) -> Unit = {},
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
): Unit = headlessIntegrationTest(
props = MutableStateFlow(Unit).asStateFlow(),
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.
*
* @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.
*/
public class WorkflowTurbine<RenderingT>(
public val firstRendering: RenderingT,
private val receiveTurbine: ReceiveTurbine<RenderingT>
) {
private var usedFirst = false

/**
* Suspend waiting for the next rendering to be produced by the Workflow runtime. Note this includes
* the first (synchronously made) rendering.
*
* @return the rendering.
*/
public suspend fun awaitNextRendering(): RenderingT {
if (!usedFirst) {
usedFirst = true
return firstRendering
}
return receiveTurbine.awaitItem()
}

public suspend fun skipRenderings(count: Int) {
val skippedCount = if (!usedFirst) {
usedFirst = true
count - 1
} else {
count
}

if (skippedCount > 0) {
receiveTurbine.skipItems(skippedCount)
}
}

/**
* Suspend waiting for the next rendering to be produced by the Workflow runtime that satisfies the
* [predicate].
*
* @return the rendering.
*/
public suspend fun awaitNextRenderingSatisfying(
predicate: (RenderingT) -> Boolean
): RenderingT {
var rendering = awaitNextRendering()
while (!predicate(rendering)) {
rendering = awaitNextRendering()
}
return rendering
}

/**
* Suspend waiting for the next rendering which satisfies [precondition], can successfully be mapped
* using [map] and satisfies the [satisfying] predicate when called on the [T] rendering after it
* has been mapped.
*
* @return the mapped rendering as [T]
*/
public suspend fun <T> awaitNext(
precondition: (RenderingT) -> Boolean = { true },
map: (RenderingT) -> T,
satisfying: T.() -> Boolean = { true }
): T =
map(
awaitNextRenderingSatisfying {
precondition(it) &&
with(map(it)) {
this.satisfying()
}
}
)

public companion object {
/**
* Default timeout to use while waiting for renderings.
*/
public const val WORKFLOW_TEST_DEFAULT_TIMEOUT_MS: Long = 60_000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.squareup.workflow1

import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
* This file is copied from workflow-runtime:commonTest so our tests that test across the runtime
* look consistent. We could have used a JUnit library like Jupiter, but didn't.
*
* This file is copied so as to avoid creating a workflow-core-testing module (for now).
*
* We do our best to tell you what the parameter was when the failure occured by wrapping
* assertions from kotlin.test and injecting our own message.
*/
class ParameterizedTestRunner<P : Any> {

var currentParam: P? = null

fun runParametrizedTest(
paramSource: Sequence<P>,
before: () -> Unit = {},
after: () -> Unit = {},
test: ParameterizedTestRunner<P>.(param: P) -> Unit
) {
paramSource.forEach {
before()
currentParam = it
test(it)
after()
}
}

fun <T> assertEquals(expected: T, actual: T) {
assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}")
}

fun <T> assertEquals(expected: T, actual: T, originalMessage: String) {
assertEquals(expected, actual, message = "$originalMessage; Using: ${currentParam?.toString()}")
}

fun assertTrue(statement: Boolean) {
assertTrue(statement, message = "Using: ${currentParam?.toString()}")
}

fun assertFalse(statement: Boolean) {
assertFalse(statement, message = "Using: ${currentParam?.toString()}")
}

inline fun <reified T : Throwable> assertFailsWith(block: () -> Unit) {
assertFailsWith<T>(message = "Using: ${currentParam?.toString()}", block)
}

fun <T : Any?> assertNotSame(illegal: T, actual: T) {
assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}")
}

fun <T : Any> assertNotNull(actual: T?) {
assertNotNull(actual, message = "Using: ${currentParam?.toString()}")
}

fun assertNull(actual: Any?) {
assertNull(actual, message = "Using: ${currentParam?.toString()}")
}
}
Loading

0 comments on commit f896709

Please sign in to comment.