Skip to content

Commit

Permalink
Partially fix #89: Introduce test coroutine dispatchers (early testin…
Browse files Browse the repository at this point in the history
…g utility) (#927)

* Initial introduction of test coroutine dispatchers to replace Kotlin's
test coroutine dispatcher.

This includes introducing a test-only module to contain testing
dependencies.

* Early work at introducing FakeSystemClock tests (not yet complete).

* Remove infeasible testing structures, add documentation, and clean up
implementation to prepare for code review.

* Add notice that the dispatchers utility is temporary.

* Use ktlint to reformat TestCoroutineDispatchers per reviewer comment
thread.
  • Loading branch information
BenHenning authored May 28, 2020
1 parent ce4d9f7 commit 6f31261
Show file tree
Hide file tree
Showing 10 changed files with 467 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ app/build
data/build
domain/build
model/build
testing/build
utility/build
.DS_Store
/build
Expand Down
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion settings.gradle
Original file line number Diff line number Diff line change
@@ -1 +1 @@
include ':app', ':model', ':utility', ':domain', ':data'
include ':app', ':model', ':utility', ':domain', ':data', ':testing'
50 changes: 50 additions & 0 deletions testing/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
compileSdkVersion 28
buildToolsVersion "29.0.1"

defaultConfig {
minSdkVersion 19
targetSdkVersion 28
versionCode 1
versionName "1.0"
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
}

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation(
'androidx.annotation:annotation:1.1.0',
'com.google.dagger:dagger:2.24',
'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2',
'org.robolectric:robolectric:4.3',
project(":utility"),
)
testImplementation(
'androidx.test.ext:junit:1.1.1',
'com.google.truth:truth:0.43',
'junit:junit:4.12',
)
kapt(
'com.google.dagger:dagger-compiler:2.24',
)
kaptTest(
'com.google.dagger:dagger-compiler:2.24',
)
annotationProcessor(
'com.google.auto.service:auto-service:1.0-rc4',
)
}
1 change: 1 addition & 0 deletions testing/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest package="org.oppia.testing"/>
27 changes: 27 additions & 0 deletions testing/src/main/java/org/oppia/testing/FakeSystemClock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.oppia.testing

import android.os.SystemClock
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject

// TODO(#89): Actually finish this implementation so that it properly works across Robolectric and
// Espresso, and add tests for it.
/**
* A Robolectric-specific fake for the system clock that can be used to manipulate time in a
* consistent way.
*/
class FakeSystemClock @Inject constructor() {
private val currentTimeMillis = AtomicLong(0L)

init {
SystemClock.setCurrentTimeMillis(0)
}

fun getTimeMillis(): Long = currentTimeMillis.get()

fun advanceTime(millis: Long): Long {
val newTime = currentTimeMillis.addAndGet(millis)
SystemClock.setCurrentTimeMillis(newTime)
return newTime
}
}
203 changes: 203 additions & 0 deletions testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package org.oppia.testing;

import androidx.annotation.GuardedBy
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Delay
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Runnable
import kotlinx.coroutines.test.DelayController
import kotlinx.coroutines.test.UncompletedCoroutinesError
import java.lang.Long.max
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import javax.inject.Inject
import kotlin.concurrent.withLock
import kotlin.coroutines.CoroutineContext

/**
* Replacement for Kotlin's test coroutine dispatcher that can be used to replace coroutine
* dispatching functionality in a Robolectric test in a way that can be coordinated across multiple
* dispatchers for execution synchronization.
*/
@InternalCoroutinesApi
@Suppress("EXPERIMENTAL_API_USAGE")
class TestCoroutineDispatcher private constructor(
private val fakeSystemClock: FakeSystemClock,
private val realCoroutineDispatcher: CoroutineDispatcher
): CoroutineDispatcher(), Delay, DelayController {

private val dispatcherLock = ReentrantLock()
/** Sorted set that first sorts on when a task should be executed, then insertion order. */
@GuardedBy("dispatcherLock") private val taskQueue = sortedSetOf(
Comparator.comparingLong(Task::timeMillis)
.thenComparing(Task::insertionOrder)
)
private val isRunning = AtomicBoolean(true) // Partially blocked on dispatcherLock.
private val executingTaskCount = AtomicInteger(0)
private val totalTaskCount = AtomicInteger(0)

@ExperimentalCoroutinesApi
override val currentTime: Long
get() = fakeSystemClock.getTimeMillis()

override fun dispatch(context: CoroutineContext, block: Runnable) {
enqueueTask(createDeferredRunnable(context, block))
}

override fun scheduleResumeAfterDelay(
timeMillis: Long,
continuation: CancellableContinuation<Unit>
) {
enqueueTask(createContinuationRunnable(continuation), delayMillis = timeMillis)
}

@ExperimentalCoroutinesApi
override fun advanceTimeBy(delayTimeMillis: Long): Long {
flushTaskQueue(fakeSystemClock.advanceTime(delayTimeMillis))
return delayTimeMillis
}

@ExperimentalCoroutinesApi
override fun advanceUntilIdle(): Long {
val timeToLastTask = max(0, taskQueue.last().timeMillis - fakeSystemClock.getTimeMillis())
return advanceTimeBy(timeToLastTask)
}

@ExperimentalCoroutinesApi
override fun cleanupTestCoroutines() {
val remainingTaskCount = dispatcherLock.withLock {
flushTaskQueue(fakeSystemClock.getTimeMillis())
return@withLock taskQueue.size
}
if (remainingTaskCount != 0) {
throw UncompletedCoroutinesError(
"Expected no remaining tasks for test dispatcher, but found $remainingTaskCount"
)
}
}

@ExperimentalCoroutinesApi
override fun pauseDispatcher() {
dispatcherLock.withLock { isRunning.set(false) }
}

@ExperimentalCoroutinesApi
override suspend fun pauseDispatcher(block: suspend () -> Unit) {
// There's not a clear way to handle this block while maintaining the thread of the dispatcher,
// so disabled it for now until it's needed later.
throw UnsupportedOperationException()
}

@ExperimentalCoroutinesApi
override fun resumeDispatcher() {
isRunning.set(true)
flushTaskQueue(fakeSystemClock.getTimeMillis())
}

@ExperimentalCoroutinesApi
override fun runCurrent() {
flushTaskQueue(fakeSystemClock.getTimeMillis())
}

internal fun hasPendingTasks(): Boolean {
return dispatcherLock.withLock {
taskQueue.isNotEmpty()
}
}

internal fun hasPendingCompletableTasks(): Boolean {
return dispatcherLock.withLock {
taskQueue.hasPendingCompletableTasks(fakeSystemClock.getTimeMillis())
}
}

private fun enqueueTask(block: Runnable, delayMillis: Long = 0L) {
val task = Task(
timeMillis = fakeSystemClock.getTimeMillis() + delayMillis,
block = block,
insertionOrder = totalTaskCount.incrementAndGet()
)
enqueueTask(task)
}

private fun enqueueTask(task: Task) {
dispatcherLock.withLock {
taskQueue += task
}
}

private fun flushTaskQueue(currentTimeMillis: Long) {
// TODO(#89): Add timeout support so that the dispatcher can't effectively deadlock or livelock
// for inappropriately behaved tests.
while (isRunning.get()) {
if (!dispatcherLock.withLock { flushActiveTaskQueue(currentTimeMillis) }) {
break
}
}
}

/** Flushes the current task queue and returns whether it was active. */
@GuardedBy("dispatcherLock")
private fun flushActiveTaskQueue(currentTimeMillis: Long): Boolean {
if (isTaskQueueActive(currentTimeMillis)) {
taskQueue.forEach { task ->
if (isRunning.get()) {
task.block.run()
}
}
taskQueue.clear()
return true
}
return false
}

private fun isTaskQueueActive(currentTimeMillis: Long): Boolean {
return taskQueue.hasPendingCompletableTasks(currentTimeMillis) || executingTaskCount.get() != 0
}

private fun createDeferredRunnable(context: CoroutineContext, block: Runnable): Runnable {
return Runnable {
executingTaskCount.incrementAndGet()
realCoroutineDispatcher.dispatch(context, Runnable {
try {
block.run()
} finally {
executingTaskCount.decrementAndGet()
}
})
}
}

private fun createContinuationRunnable(continuation: CancellableContinuation<Unit>): Runnable {
val block: CancellableContinuation<Unit>.() -> Unit = {
realCoroutineDispatcher.resumeUndispatched(Unit)
}
return Runnable {
try {
executingTaskCount.incrementAndGet()
continuation.block()
} finally {
executingTaskCount.decrementAndGet()
}
}
}

class Factory @Inject constructor(private val fakeSystemClock: FakeSystemClock) {
fun createDispatcher(realDispatcher: CoroutineDispatcher): TestCoroutineDispatcher {
return TestCoroutineDispatcher(fakeSystemClock, realDispatcher)
}
}
}

private data class Task(
internal val block: Runnable,
internal val timeMillis: Long,
internal val insertionOrder: Int
)

private fun Set<Task>.hasPendingCompletableTasks(currentTimeMilis: Long): Boolean {
return any { task -> task.timeMillis <= currentTimeMilis }
}
Loading

0 comments on commit 6f31261

Please sign in to comment.