Share
Embed
Unit Testing Channels & Flows - Droidcon Americas
Unit testing Channels and Flows can be a challenge as they are fairly new. In this talk, I will share with you how to implement and test practical examples from my experience. These examples are testing delays, retries, and errors. I'll also share testing more complex examples such as polling. For each use case, we'll look at how to use features in the coroutines library such as runBlockingTest and TestCoroutineDispatcher. From my journey of using and testing Flows in production, I'll share the challenges I experienced.
Mohit S
November 17, 2020
Transcript
196 slides
-
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } How do we test this flow? -
Kotlin/kotlinx.coroutines Code ! Issues Pull Requests kotlinx-coroutines-core 2,093 commits 4c28f942
13 hours ago Kotlinx-coroutines-debug Kotlinx-coroutines-test reactive js benchmarks -
Kotlin/kotlinx.coroutines Code ! Issues Pull Requests kotlinx-coroutines-core 2,093 commits 4c28f942
13 hours ago Kotlinx-coroutines-debug Kotlinx-coroutines-test reactive js benchmarks Testing Module -
Kotlin/kotlinx.coroutines Code ! Issues Pull Requests README.md Module Kotlin-coroutines-test Test
utilities for kotlinx.coroutines dependencies { testImplementation ‘org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.1' } -
@Test fun `should get locations`() = runBlockingTest { } Test
Scope + Test Dispatcher -
@Test fun `should get locations`() = runBlockingTest { } Test
Scope + Test Dispatcher What is executed in this coroutine? -
@Test fun `should get locations`() = runBlockingTest { } Test
Scope + Test Dispatcher -
@Test fun `should get locations`() = runBlockingTest { } Test
Scope + Test Dispatcher -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } -
@Test fun `should get locations`() = runBlockingTest { val flowOfLocations
= mockFlow() val locations: List<Location> = flowOfLocations.toList() } Mockito or create Fake -
@Test fun `should get locations`() = runBlockingTest { val flowOfLocations
= mockFlow() val locations: List<Location> = flowOfLocations.toList() } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } -
@Test fun `should get locations`() = runBlockingTest { val flowOfLocations
= mockFlow() val locations: List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } How does this work? -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (hasActiveJobs()) { throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs") } } Our test to run -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup Run Test -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup Run Test Cleanup -
@Test fun `should get locations`() = runBlockingTest { val flowOfLocations
= mockFlow() val locations: List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } -
@Test fun `should get locations`() = runBlockingTest { val flowOfLocations
= mockFlow() val locations: List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } Better way to collect from Flows? -
cashApp/turbine Code ! Issues Pull Requests Turbine Small testing library
for kotlinx.coroutines Flow. testImplementation 'app.cash.turbine:turbine:0.2.1' -
Channel Types • Unlimited • Buffered • Rendezvous • Broadcast
Channel [Obsolete] -
sealed class Event<out T> { object Complete data class Error(""...)
data class Item(val value: T) } How Turbine Works Channel (Unlimited) Event -
object Complete: Event() How Turbine Works Channel (Unlimited) How do
I query items in channels? -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } API to query Channel How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify duration of emission How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify no emissions How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify item emitted How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify error occurred How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } Verify all items emitted How Turbine Works -
interface FlowTurbine<T> { val timeout: Duration fun expectNoEvents() suspend fun
expectItem(): T fun expectError(): Throwable suspend fun expectComplete() } How Turbine Works -
How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {
} Create a coroutine -
How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {
val events = Channel<Event<T">>(UNLIMITED) } Channel of events -
How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {
val events = Channel<Event<T">>(UNLIMITED) launch { } } -
How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {
val events = Channel<Event<T">>(UNLIMITED) launch { collect { item "-> events.send(Event.Item(item)) } } Store emissions -
How Turbine Works suspend fun <T> Flow<T>.test( validate: suspend FlowTurbine<T>.()
"-> Unit ) { coroutineScope { val events = Channel<Event<T">>(UNLIMITED) launch { try { collect { ""... } } catch { events.send(Event.Error) API -
@Test fun `should get locations`() = runBlockingTest { val locations:
List<Location> = flowOfLocations.toList() locations shouldContainAll listOf(location1, location2, location3) } How do we use Turbine? -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } Read items into Channel -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } Get first emitted item -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } Assertion -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } Get 1st, 2nd, 3rd item -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } Verify flow completed -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectComplete() } } Missed varying location 3 -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectComplete() } } LocationRepoTest.kt Run: LocationRepoTest.kt should get locations app.cash.turbine.AssertionError: Expected complete but found Item(Location(…)) -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 } } LocationRepoTests.kt Run: LocationRepoTests should get locations app.cash.turbine.AssertionError: Unconsumed events found: - Complete -
@Test fun `should get locations`() = runBlockingTest { flowOfLocations.test {
expectItem() shouldBeEqualTo location1 expectItem() shouldBeEqualTo location2 expectItem() shouldBeEqualTo location3 expectComplete() } } -
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } Exception -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } .onCompletion { ""... } Complete on error -
How Turbine Works suspend fun <T> Flow<T>.test(""...) { coroutineScope {
val events = Channel<Event<T">>(UNLIMITED) launch { try { collect { ""... } } catch { events.send(Event.Error(""...)) } } Send error to Channel -
@Test fun `should handle exception`() = runBlockingTest { flowOfLocations.test {
expectError() } } Throwable -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } Better way to handle Exception? -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } .catch { ""... } Catch Exception -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } .catch { this: FlowCollector } Allow you to emit from catch -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatMap { ""... } .catch { throwable "-> } Exception -
Code ! Issues Pull Requests Jetbrains/kotlin stdlib/src/kotlin/util/Result.kt inline class Result(val
value: Any?) { val isSuccess: Boolean val isFailure: Boolean } -
flowOfLocations: Flow<Result> .filter { ""... } .map { ""... }
.flatMap { ""... } .catch { throwable "-> emit(Result.failure(throwable)) } Emit result -
flowOfLocations .filter { ""... } .map { ""... } .flatMap
{ ""... } .catch { ""... } Go to Catch when exception occurs -
Error stored as item data class Item(throwable): Event() How Turbine
Works Channel (Unlimited) -
@Test fun `should handle exception`() = runBlockingTest { flowOfLocations.test {
expectItem().isFailure } } Verify Failure -
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
@Test fun `should handle data updates`() = runBlockingTest { processData()
} suspend fun processData() { delay(1000) calculate() } -
@Test fun `should handle data updates`() = runBlockingTest { processData()
} suspend fun processData() { delay(1000) calculate() } -
@Test fun `should handle data updates`() = runBlockingTest { processData()
} suspend fun processData() { delay(1000) calculate() } How does this work in a test? -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup Run Test Cleanup -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Run Test Advance virtual time -
@Test fun `should handle data updates`() = runBlockingTest { processData()
} suspend fun processData() { delay(1000) calculate() } Advance virtual time until idle -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Creating a new coroutine -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } How is the delay handled? -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } SUSPEND -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Continue execution -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Run Test Advance virtual time -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Proceed forward to calculate -
@Test fun `should handle data updates`() = runBlockingTest { processData()
performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } How do I control this delay? -
interface DelayController { fun advanceTimeBy(delayTimeMillis: Long) fun advanceUntilIdle(): Long fun
pauseDispatcher() fun runCurrent() } Delay Utilities -
@Test fun `should handle data updates`() = runBlockingTest { processData()
advanceTimeBy(1000) performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Advance virtual time -
@Test fun `should handle data updates`() = runBlockingTest { processData()
advanceTimeBy(1000) performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Perform calculation -
@Test fun `should handle data updates`() = runBlockingTest { processData()
advanceTimeBy(1000) performAssertions() } suspend fun CoroutineScope.processData() { launch { delay(1000) calculate() } } Perform assertions -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } Exception -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } Retry? -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry { delay(2000) ""... } Retry extension -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } # of retries -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } Delay before retrying -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } Retry flow operators -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) Flow on Dispatcher Run on Dispatcher -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) How do you test this? -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(testCoroutineDispatcher) Run on test dispatcher -
val testCoroutineDispatcher = TestCoroutineDispatcher() @Test fun `should retry twice`() =
testCoroutineDispatcher.runBlockingTest { } Use dispatcher in test -
fun TestCoroutineDispatcher.runBlockingTest( block: suspend TestCoroutineScope.() "-> Unit ) = runBlockingTest(this,
block) Extension on dispatcher -
@Test fun `should retry twice`() = testCoroutineDispatcher.runBlockingTest { } flowOfLocations.test
{ } Start Collecting from Flow -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) Call operators -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) Exception -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) Delay -
@Test fun `should retry twice`() = testCoroutineDispatcher.runBlockingTest { } flowOfLocations.test
{ advanceTimeBy(2000) "// perform assertions } Advance virtual time -
flowOfLocations: Flow<Location> .filter { ""... } .map { ""... }
.flatmap { ""... } .retry(1) { delay(2000) ""... } .flowOn(dispatcher) Retry Flow -
@Test fun `should retry twice`() = testCoroutineDispatcher.runBlockingTest { } flowOfLocations.test
{ advanceTimeBy(2000) "// perform assertions } Perform assertion -
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
val ViewModel.viewModelScope: CoroutineScope get() { return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope( SupervisorJob() +
Dispatchers.Main.immediate) ) } Main Dispatcher -
class MyViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun updateState()
{ flowOfData .map { mapToState(data) } .onEach { state "-> states.value = state } .launchIn(viewModelScope) } } -
class MyViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun updateState()
{ flowOfData .map { mapToState(data) } .onEach { state "-> states.value = state } .launchIn(viewModelScope) } } -
class MyViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun updateState()
{ flowOfData .map { mapToState(data) } .onEach { state "-> states.value = state } .launchIn(viewModelScope) } } -
class MyViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun updateState()
{ flowOfData .map { mapToState(data) } .collect { state "-> states.value = state } "// logic after collect will run after it finishes } } Suspending -
class MyViewModel(): ViewModel() { val states = MutableStateFlow(State()) fun updateState()
{ flowOfData .map { mapToState(data) } .onEach { state "-> states.value = state } .launchIn(viewModelScope) } } How do we test this flow? -
@Test fun `should get data`() = runBlockingTest { mockDataFlow() viewModel.updateState()
viewModel.states.test { expectItem().data shouldBe state } } -
@Test fun `should get data`() = runBlockingTest { mockDataFlow() viewModel.updateState()
viewModel.states.test { expectItem().data shouldBe state } } -
@Test fun `should get data`() = runBlockingTest { mockDataFlow() viewModel.updateState()
viewModel.states.test { expectItem().data shouldBe state } } ViewModelTest.kt Run: ViewModelTest should get data Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used -
val ViewModel.viewModelScope: CoroutineScope get() { return setTagIfAbsent(JOB_KEY, CloseableCoroutineScope( SupervisorJob() +
Dispatchers.Main.immediate) ) } Main Dispatcher -
val dispatcher = TestCoroutineDispatcher() @Before fun setUp() { Dispatchers.setMain(dispatcher) }
@After fun tearDown() { Dispatchers.resetMain() } Set Main Dispatcher -
val dispatcher = TestCoroutineDispatcher() @Before fun setUp() { Dispatchers.setMain(dispatcher) }
@After fun tearDown() { Dispatchers.resetMain() } Reset Main Dispatcher -
class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)
} override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } } -
class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)
} override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } } -
class CoroutineTestRule(testDispatcher) : TestWatcher() { override fun starting(description) { Dispatchers.setMain(testDispatcher)
} override fun finished(description) { Dispatchers.resetMain() testDispatcher.cleanupTestCoroutines() } } -
@get:Rule val testRule = CoroutineTestRule() @Test fun `should get data`()
= runBlockingTest { mockDataFlow() viewModel.updateState() viewModel.states.test { expectItem().data shouldBe state } } -
@get:Rule val testRule = CoroutineTestRule() @Test fun `should get data`()
= runBlockingTest { mockDataFlow() viewModel.updateState() viewModel.states.test { expectItem().data shouldBe state } } -
@get:Rule val testRule = CoroutineTestRule() @Test fun `should get data`()
= runBlockingTest { mockDataFlow() viewModel.updateState() viewModel.states.test { expectItem().data shouldBe state } } ViewModelTest.kt Run: ViewModelTest should get data -
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Setup Run Test Cleanup -
fun runBlockingTest(testBody) { val scope = TestCoroutineScope(safeContext) val deferred =
scope.async { scope.testBody() } dispatcher.advanceUntilIdle() deferred.getCompletionExceptionOrNull()"?.let { throw it } scope.cleanupTestCoroutines() if (activeJobs()).isNotEmpty()) { throw UncompletedCoroutinesError("Test finished with active jobs") } } Cleanup Job is Active -
val channel = Channel<Event>() Active Jobs fun CoroutineScope.channelSend() { launch
{ channel.send(Event()) } ""... } Send events -
val channel = Channel<Event>() Active Jobs suspend fun processEvents() {
channel.consumeAsFlow().collect { event "-> ""... } } Process event -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() } Coroutine -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() } channel.send(LocationEvent()) Coroutine -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() } channel.send(LocationEvent()) Coroutine Rendezvous Channel -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() } channel.send(LocationEvent()) Coroutine SUSPENDED -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() } Tests.kt Run: Tests should process event Test finished with active jobs: ["Coroutine#2":StandaloneCoroutine{Active}] -
Active Jobs @Test fun `should process event`() = runBlockingTest {
sendEvent() processEvents() } -
Delay Active Jobs @Test fun `should process event`() = runBlockingTest
{ sendEvent() } suspend fun CoroutineScope.sendEvent() { launch(testCoroutineDispatcher) { delay(1000) ""... } } Delay -
@Test fun `should process event`() = runBlockingTest { sendEvent() }
suspend fun CoroutineScope.sendEvent() { launch(testCoroutineDispatcher) { delay(1000) ""... } } Delay Active Jobs Tests.kt Run: Tests should process event Test finished with active jobs: ["Coroutine#2":StandaloneCoroutine{Active}] -
Delay Active Jobs @Test fun `should process event`() = runBlockingTest
{ sendEvent() testCoroutineDispatcher.advanceTimeBy(1000) } suspend fun CoroutineScope.sendEvent() { launch(testCoroutineDispatcher) { delay(1000) ""... } } Advance virtual time -
Unit Testing Channels & Flows • Testing Flows • Errors
• Delays • Dispatcher Utilities • Common Testing Problems • Testing inside Coroutines Lib -
Testing inside Coroutines Lib • Learn Coroutines from tests inside
lib • Multiplatform testing - JS, JVM and Native -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } -
class JobBasicCancellationTest : TestBase() { @Test fun testCancelJobImpl() { }
} Defines utilities -
class JobBasicCancellationTest : TestBase() { @Test fun testCancelJobImpl() { }
} Test parent job is not cancelled -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Launch coroutine -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Child coroutine Job -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Cancel the job -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } Verify order of execution Verify completion -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } -
expect open class TestBase { fun error(message, cause) fun expect(index:
Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) } -
actual fun runTest( block: suspend CoroutineScope.() "-> Unit ) {
runBlocking( block = block, context = CoroutineExceptionHandler { ""... }) } -
expect open class TestBase { fun error(message, cause) fun expect(index:
Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) } How does this work? -
private var actionIndex = AtomicInteger() actual fun expect(index: Int) {
val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } } -
private var actionIndex = AtomicInteger() actual fun expect(index: Int) {
val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } } -
private var actionIndex = AtomicInteger() actual fun expect(index: Int) {
val wasIndex = actionIndex.incrementAndGet() check(index "== wasIndex) { “Expecting action index $index but it is actually $wasIndex" } } -
expect open class TestBase { fun error(message, cause) fun expect(index:
Int) fun finish(index: Int) fun runTest( block: suspend CoroutineScope.() "-> Unit ) } How does this work? -
private var finished = AtomicBoolean() actual fun finish(index: Int) {
expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } } -
private var finished = AtomicBoolean() actual fun finish(index: Int) {
expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } } -
private var finished = AtomicBoolean() actual fun finish(index: Int) {
expect(index) check(!finished.getAndSet(true)) { "Should call 'finish(""...)' at most once" } } -
@Test fun testCancelJobImpl() = runTest { val parent = launch
{ expect(1) val child = Job(coroutineContext[Job]) expect(2) child.cancel() child.join() expect(3) } parent.join() finish(4) } -
Resources • Unit Testing Delays, Errors & Retries with Kotlin
Flows https:"//codingwithmohit.com/coroutines/unit-testing-delays- errors-retries-with-kotlin-flows/ • Multiplatform Testing pattern inside Coroutine Lib https:"//codingwithmohit.com/coroutines/kotlin-coroutine-lib-testing/