Skip to content

Commit

Permalink
Merge pull request #918 from square/sedwards/910-tester-refactor
Browse files Browse the repository at this point in the history
910: Integrate WorkerTester with TestScope
  • Loading branch information
steve-the-edwards authored Feb 6, 2023
2 parents e30c4c6 + 00b3789 commit f750b5b
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 55 deletions.
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ squareup-workflow = "1.0.0"

timber = "4.7.1"
truth = "1.1.3"
turbine = "0.12.1"
vanniktech-publish = "0.22.0"

[plugins]
Expand Down Expand Up @@ -247,6 +248,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }

truth = { module = "com.google.truth:truth", version.ref = "truth" }

turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }

vanniktech-publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "vanniktech-publish" }

[bundles]
1 change: 1 addition & 0 deletions workflow-testing/api/workflow-testing.api
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public abstract interface class com/squareup/workflow1/testing/WorkerTester {
public abstract fun assertNotFinished ()V
public abstract fun cancelWorker (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun getException (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun getTestCoroutineScheduler ()Lkotlinx/coroutines/test/TestCoroutineScheduler;
public abstract fun nextOutput (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

Expand Down
3 changes: 2 additions & 1 deletion workflow-testing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {

dependencies {
api(libs.kotlin.jdk7)
api(libs.kotlinx.coroutines.test)

api(project(":workflow-core"))
api(project(":workflow-runtime"))

compileOnly(libs.jetbrains.annotations)

implementation(libs.kotlin.reflect)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)

implementation(project(":internal-testing-utils"))
implementation(project(":workflow-config:config-jvm"))
Expand Down
10 changes: 6 additions & 4 deletions workflow-testing/dependencies/runtimeClasspath.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
:workflow-config:config-jvm
:workflow-core
:workflow-runtime
app.cash.turbine:turbine-jvm:0.12.1
app.cash.turbine:turbine:0.12.1
com.squareup.okio:okio-jvm:3.0.0
com.squareup.okio:okio:3.0.0
org.jetbrains.kotlin:kotlin-bom:1.7.10
org.jetbrains.kotlin:kotlin-reflect:1.7.10
org.jetbrains.kotlin:kotlin-stdlib-common:1.7.10
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.10
org.jetbrains.kotlin:kotlin-stdlib:1.7.10
org.jetbrains.kotlin:kotlin-stdlib-common:1.7.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.20
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.7.20
org.jetbrains.kotlin:kotlin-stdlib:1.7.20
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.4
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.4
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
@file:OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@file:OptIn(ExperimentalCoroutinesApi::class)

package com.squareup.workflow1.testing

import app.cash.turbine.Event.Item
import app.cash.turbine.test
import com.squareup.workflow1.Worker
import com.squareup.workflow1.testing.WorkflowTestRuntime.Companion.DEFAULT_TIMEOUT_MS
import kotlinx.coroutines.Dispatchers.Unconfined
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.produceIn
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.DurationUnit.MILLISECONDS
import kotlin.time.toDuration

public interface WorkerTester<T> {

/**
* Access the [TestCoroutineScheduler] of the [kotlinx.coroutines.test.TestScope] running
* the [Worker]'s [test].
*
* This can be used to advance virtual time for the [CoroutineDispatcher] that the the Worker's
* flow is flowing on.
*/
public val testCoroutineScheduler: TestCoroutineScheduler

/**
* Suspends until the worker emits its next value, then returns it.
*
* Throws an [AssertionError] if the Worker completes or has an error.
*/
public suspend fun nextOutput(): T

Expand All @@ -31,7 +39,7 @@ public interface WorkerTester<T> {
/**
* Suspends until the worker emits an output or finishes.
*
* Throws an [AssertionError] if an output was emitted.
* Throws an [AssertionError] if an output was emitted or the Worker has an error.
*/
public suspend fun assertFinished()

Expand All @@ -54,67 +62,81 @@ public interface WorkerTester<T> {
/**
* Test a [Worker] by defining assertions on its output within [block].
*/
@OptIn(ExperimentalCoroutinesApi::class)
public fun <T> Worker<T>.test(
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
block: suspend WorkerTester<T>.() -> Unit
) {
runBlocking {
supervisorScope {
val channel: ReceiveChannel<T> = run().produceIn(this + Unconfined)

runTest {
// Use a Turbine which consumes outputs/errors from an underlying channel.
run().test(
timeout = timeoutMs.toDuration(MILLISECONDS)
) {
val tester = object : WorkerTester<T> {
override suspend fun nextOutput(): T = channel.receive()
override val testCoroutineScheduler: TestCoroutineScheduler = testScheduler

override suspend fun nextOutput(): T = awaitItem()

override fun assertNoOutput() {
// isEmpty returns false if the channel is closed.
if (!channel.isEmpty && !channel.isClosedForReceive) {
try {
expectNoEvents()
} catch (e: AssertionError) {
throw AssertionError("Expected no output to have been emitted.")
}
}

override suspend fun assertFinished() {
if (!channel.isClosedForReceive) {
try {
withTimeoutOrNull(timeoutMs) {
awaitComplete()
} ?: throw AssertionError()
} catch (e: AssertionError) {
// Note there is some complicated logic here to build the message. The messages predate
// Turbine integration but we wanted to keep them stable, and so extract what's needed
// from the Turbine AssertionErrors.
val message = buildString {
append("Expected Worker to be finished.")
val outputs = mutableListOf<T>()
while (!channel.isEmpty) {
@Suppress("UNCHECKED_CAST")
outputs += channel.tryReceive().getOrNull() as T
val outputStrings = cancelAndConsumeRemainingEvents().filterIsInstance<Item<T>>()
.map { it.value.toString() }.toMutableList()
// Consumed and only reported in the exception.
e.message?.substringAfter(
delimiter = "Item(",
missingDelimiterValue = ""
)?.substringBeforeLast(
delimiter = ')',
missingDelimiterValue = ""
)?.let {
if (it.isNotEmpty()) {
outputStrings.add(0, it)
}
}
if (outputs.isNotEmpty()) {
append(" Emitted outputs: $outputs")
if (outputStrings.isNotEmpty()) {
append(" Emitted outputs: $outputStrings")
}
}
throw AssertionError(message)
}
}

override fun assertNotFinished() {
if (channel.isClosedForReceive) {
if (asChannel().isClosedForReceive) {
throw AssertionError("Expected Worker to not be finished.")
}
}

override suspend fun getException(): Throwable = try {
val output = channel.receive()
throw AssertionError("Expected Worker to throw an exception, but emitted output: $output")
awaitError()
} catch (e: Throwable) {
e
}

override suspend fun cancelWorker() {
channel.cancel()
cancelAndIgnoreRemainingEvents()
}
}

// Yield to let the produce coroutine start, since we can't specify UNDISPATCHED.
yield()

withTimeout(timeoutMs) {
block(tester)
}

coroutineContext.cancelChildren()
tester.block()
cancelAndIgnoreRemainingEvents()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ package com.squareup.workflow1

import com.squareup.workflow1.testing.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
Expand Down Expand Up @@ -65,6 +63,7 @@ internal class WorkerTest {
}

worker.test {
assertFinished()
assertTrue(ran)
}
}
Expand Down Expand Up @@ -125,22 +124,19 @@ internal class WorkerTest {
}

@Test fun `timer emits and finishes after delay`() {
val testDispatcher = StandardTestDispatcher()
val worker = Worker.timer(1000)
// Run the timer on the test dispatcher so we can control time.
.transform { it.flowOn(testDispatcher) }

worker.test {
assertNoOutput()
assertNotFinished()

testDispatcher.scheduler.advanceTimeBy(999)
testDispatcher.scheduler.runCurrent()
testCoroutineScheduler.advanceTimeBy(999)
testCoroutineScheduler.runCurrent()
assertNoOutput()
assertNotFinished()

testDispatcher.scheduler.advanceTimeBy(1)
testDispatcher.scheduler.runCurrent()
testCoroutineScheduler.advanceTimeBy(1)
testCoroutineScheduler.runCurrent()
assertEquals(Unit, nextOutput())
assertFinished()
}
Expand Down
Loading

0 comments on commit f750b5b

Please sign in to comment.