Skip to content

Commit

Permalink
Introduce TestScope.test(RibCoroutineWorker) test helper utility.
Browse files Browse the repository at this point in the history
This helper utility is meant to be used in tests inside `runTest { }` blocks
and should facilitate `RibCoroutineWorker` testing by automatically binding
and unbinding the worker in the scope of the lambda.

```
@test fun test() = runTest {
  test(worker) {
    // Worker is bound. Make assertions
  }
  // Worker is unbound.
}
```
  • Loading branch information
psteiger committed Oct 6, 2023
1 parent adc3b0d commit 73a5958
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Delay
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.awaitCancellation
Expand All @@ -47,10 +48,12 @@ import org.junit.Rule
import org.junit.Test

private const val ON_START_DELAY_DURATION_MILLIS = 100L
private const val INNER_COROUTINE_DELAY_DURATION_MILLIS = 200L

@OptIn(ExperimentalCoroutinesApi::class)
class RibCoroutineWorkerTest {
@get:Rule val coroutineRule = RibCoroutinesRule()
private val worker = TestRibCoroutineWorker()
private val worker = TestWorker()

@Test
fun bindWorkHandle_onJoin_thenJoinsBindingOperation() = runTest {
Expand Down Expand Up @@ -79,7 +82,7 @@ class RibCoroutineWorkerTest {
assertThat(suppressed).isInstanceOf(IllegalStateException::class.java)
assertThat(suppressed).hasMessageThat().isEqualTo(onStopErrorMsg)
assertThat(worker.onStartFinished).isTrue()
assertThat(worker.onStopFinished).isTrue()
assertThat(worker.onStopRan).isTrue()
}
}
}
Expand All @@ -95,9 +98,9 @@ class RibCoroutineWorkerTest {
val bindHandle = bind(worker)
bindHandle.join()
val unbindHandle = bindHandle.unbind()
assertThat(worker.onStopFinished).isFalse()
assertThat(worker.onStopRan).isFalse()
unbindHandle.join()
assertThat(worker.onStopFinished).isTrue()
assertThat(worker.onStopRan).isTrue()
assertThat(worker.onStopCause).isInstanceOf(CancellationException::class.java)
assertThat(worker.onStopCause).hasMessageThat().isEqualTo("Worker was manually unbound.")
}
Expand All @@ -110,7 +113,7 @@ class RibCoroutineWorkerTest {
cancel(cancellationMsg)
}
advanceUntilIdle()
assertThat(worker.onStopFinished).isTrue()
assertThat(worker.onStopRan).isTrue()
assertThat(worker.onStopCause).isInstanceOf(CancellationException::class.java)
assertThat(worker.onStopCause).hasMessageThat().isEqualTo(cancellationMsg)
}
Expand Down Expand Up @@ -173,9 +176,44 @@ class RibCoroutineWorkerTest {
assertThat(dispatcher.dispatchCount).isEqualTo(1) // no new dispatch done
handle.unbind()
assertThat(worker.onStopThread!!.id).isEqualTo(Thread.currentThread().id)
assertThat(worker.onStopFinished).isTrue()
assertThat(worker.onStopRan).isTrue()
}
}

@Test
fun testHelperFunction() = runTest {
// Sanity - assert initial state.
assertThat(worker.onStartStarted).isFalse()
assertThat(worker.onStartFinished).isFalse()
assertThat(worker.innerCoroutineStarted).isFalse()
assertThat(worker.innerCoroutineIdle).isFalse()
assertThat(worker.innerCoroutineCompleted).isFalse()
assertThat(worker.onStopRan).isFalse()
test(worker) {
// Assert onStart and inner coroutine started but have not finished (it has delays)
assertThat(it.onStartStarted).isTrue()
assertThat(it.innerCoroutineStarted).isTrue()
assertThat(it.onStartFinished).isFalse()
// Advance time so only onStart finishes
advanceTimeBy(ON_START_DELAY_DURATION_MILLIS)
runCurrent()
assertThat(it.onStartFinished).isTrue()
assertThat(it.innerCoroutineIdle).isFalse()
// Advance time so inner coroutine becomes idle (reaches awaitCancellation).
val remainingTime = INNER_COROUTINE_DELAY_DURATION_MILLIS - testScheduler.currentTime
advanceTimeBy(remainingTime)
runCurrent()
assertThat(it.innerCoroutineIdle).isTrue()
assertThat(it.innerCoroutineCompleted).isFalse()
// onStop should only be called after the lambda returns
assertThat(it.onStopRan).isFalse()
}
// Worker should be unbound at this point.
assertThat(worker.innerCoroutineCompleted).isTrue()
assertThat(worker.onStopRan).isTrue()
assertThat(worker.onStopCause).isInstanceOf(CancellationException::class.java)
assertThat(worker.onStopCause).hasMessageThat().isEqualTo("Worker was manually unbound.")
}
}

@OptIn(InternalCoroutinesApi::class)
Expand All @@ -202,13 +240,16 @@ private class ImmediateDispatcher(
}
}

private class TestRibCoroutineWorker : RibCoroutineWorker {
private class TestWorker : RibCoroutineWorker {
var onStartStarted = false
var onStartFinished = false
var onStartThread: Thread? = null
var onStopCause: Throwable? = null
var onStopFinished = false
var onStopRan = false
var onStopThread: Thread? = null
var innerCoroutineStarted = false
var innerCoroutineIdle = false
var innerCoroutineCompleted = false

private var _doOnStart: suspend () -> Unit = {}
private var _doOnStop: () -> Unit = {}
Expand All @@ -225,7 +266,16 @@ private class TestRibCoroutineWorker : RibCoroutineWorker {
onStartStarted = true
onStartThread = Thread.currentThread()
try {
scope.launch { awaitCancellation() }
scope.launch {
try {
innerCoroutineStarted = true
delay(INNER_COROUTINE_DELAY_DURATION_MILLIS)
innerCoroutineIdle = true
awaitCancellation()
} finally {
innerCoroutineCompleted = true
}
}
delay(ON_START_DELAY_DURATION_MILLIS)
_doOnStart()
} finally {
Expand All @@ -239,7 +289,7 @@ private class TestRibCoroutineWorker : RibCoroutineWorker {
try {
_doOnStop()
} finally {
onStopFinished = true
onStopRan = true
}
}
}
1 change: 1 addition & 0 deletions android/libraries/rib-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ dependencies {
api(testLibs.junit)
api(testLibs.truth)
api(testLibs.mockito)
api(testLibs.coroutines.test)
implementation(testLibs.mockitoKotlin)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2023. Uber Technologies
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.uber.rib.core

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent

/**
* Binds [worker], runs [testBody] with the worker, and unbinds worker after [testBody] returns.
*
* This function calls [runCurrent] on the [TestScope] immediately after binding the [worker]. This
* means that if, and only if, there's no delay in [RibCoroutineWorker.onStart] function, worker
* will already be bound at the start of [testBody] lambda. If there are delays, calling
* [advanceTimeBy] or [advanceUntilIdle] at the start of [testBody] is needed to complete the
* binding.
*
* The same rationale applies for coroutines launched in the [CoroutineScope] parameter of
* [RibCoroutineWorker.onStart]: if there are no delays involved, coroutines will be run until idle
* or completed, otherwise, the aforementioned time advancing API must be used.
*/
@OptIn(ExperimentalCoroutinesApi::class)
public inline fun <T : RibCoroutineWorker> TestScope.test(
worker: T,
crossinline testBody: TestScope.(T) -> Unit,
) {
val dispatcher = StandardTestDispatcher(testScheduler)
val handle = bind(worker, dispatcher)
runCurrent()
try {
testBody(worker)
} finally {
handle.unbind()
runCurrent()
}
}

0 comments on commit 73a5958

Please sign in to comment.