All Downloads are FREE. Search and download functionalities are using the official Maven repository.

main.com.squareup.workflow1.testing.HeadlessIntegrationTest.kt Maven / Gradle / Ivy

There is a newer version: 1.12.1-beta13
Show newest version
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  Workflow.headlessIntegrationTest(
  props: StateFlow,
  coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
  interceptors: List = emptyList(),
  runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
  onOutput: suspend (OutputT) -> Unit = {},
  testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
  testCase: suspend WorkflowTurbine.() -> 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  Workflow.headlessIntegrationTest(
  coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
  interceptors: List = emptyList(),
  runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
  onOutput: suspend (OutputT) -> Unit = {},
  testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
  testCase: suspend WorkflowTurbine.() -> 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(
  public val firstRendering: RenderingT,
  private val receiveTurbine: ReceiveTurbine
) {
  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  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
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy