-
Notifications
You must be signed in to change notification settings - Fork 527
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Partially fix #89: Introduce test coroutine dispatchers (early testin…
…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
1 parent
ce4d9f7
commit 6f31261
Showing
10 changed files
with
467 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,6 +11,7 @@ app/build | |
data/build | ||
domain/build | ||
model/build | ||
testing/build | ||
utility/build | ||
.DS_Store | ||
/build | ||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<manifest package="org.oppia.testing"/> |
27 changes: 27 additions & 0 deletions
27
testing/src/main/java/org/oppia/testing/FakeSystemClock.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
203
testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
Oops, something went wrong.