diff --git a/.gitignore b/.gitignore
index c26873b6b36..1dad49ba88c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ app/build
data/build
domain/build
model/build
+testing/build
utility/build
.DS_Store
/build
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 0fc86fb32cc..6aa35f75034 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -15,6 +15,7 @@
+
diff --git a/settings.gradle b/settings.gradle
index 4253dbdcae1..52d012f62a4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app', ':model', ':utility', ':domain', ':data'
+include ':app', ':model', ':utility', ':domain', ':data', ':testing'
diff --git a/testing/build.gradle b/testing/build.gradle
new file mode 100644
index 00000000000..aed6f826627
--- /dev/null
+++ b/testing/build.gradle
@@ -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',
+ )
+}
diff --git a/testing/src/main/AndroidManifest.xml b/testing/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..0120380ba65
--- /dev/null
+++ b/testing/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt
new file mode 100644
index 00000000000..83cdfacb4b7
--- /dev/null
+++ b/testing/src/main/java/org/oppia/testing/FakeSystemClock.kt
@@ -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
+ }
+}
diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt
new file mode 100644
index 00000000000..623612d9ece
--- /dev/null
+++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatcher.kt
@@ -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
+ ) {
+ 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): Runnable {
+ val block: CancellableContinuation.() -> 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.hasPendingCompletableTasks(currentTimeMilis: Long): Boolean {
+ return any { task -> task.timeMillis <= currentTimeMilis }
+}
diff --git a/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt
new file mode 100644
index 00000000000..01fb0acd8e3
--- /dev/null
+++ b/testing/src/main/java/org/oppia/testing/TestCoroutineDispatchers.kt
@@ -0,0 +1,116 @@
+package org.oppia.testing
+
+import android.os.SystemClock
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.InternalCoroutinesApi
+import org.robolectric.shadows.ShadowLooper
+
+/**
+ * Helper class to coordinate execution between all threads currently running in a test environment,
+ * using both Robolectric for the main thread and [TestCoroutineDispatcher] for application-specific
+ * threads.
+ *
+ * This class should be used at any point in a test where the test should ensure that a clean thread
+ * synchronization point is needed (such as after an async operation is kicked off). This class can
+ * guarantee that all threads enter a truly idle state (e.g. even in cases where execution "ping
+ * pongs" across multiple threads will still be resolved with a single call to [advanceUntilIdle]).
+ *
+ * Note that it's recommended all Robolectric tests that utilize this class run in a PAUSED looper
+ * mode so that clock coordination is consistent between Robolectric's scheduler and this utility
+ * class, otherwise unexpected inconsistencies may arise.
+ *
+ * *NOTE TO DEVELOPERS*: This class is NOT yet ready for broad use until after #89 is resolved.
+ * Please ask in oppia-android-dev if you have a use case that you think requires this class.
+ * Specific cases will be allowed to integrate with if other options are infeasible. Other tests
+ * should rely on existing mechanisms until this utility is ready for broad use.
+ */
+@InternalCoroutinesApi
+class TestCoroutineDispatchers @Inject constructor(
+ @BackgroundTestDispatcher private val backgroundTestDispatcher: TestCoroutineDispatcher,
+ @BlockingTestDispatcher private val blockingTestDispatcher: TestCoroutineDispatcher,
+ private val fakeSystemClock: FakeSystemClock
+) {
+ private val shadowUiLooper = ShadowLooper.shadowMainLooper()
+
+ /**
+ * Runs all current tasks pending, but does not follow up with executing any tasks that are
+ * scheduled after this method finishes.
+ *
+ * Note that it's generally not recommended to use this method since it may result in
+ * unanticipated dependencies on the order in which this class processes tasks for each handled
+ * thread and coroutine dispatcher.
+ */
+ @ExperimentalCoroutinesApi
+ fun runCurrent() {
+ do {
+ flushNextTasks()
+ } while (hasPendingCompletableTasks())
+ }
+
+ /**
+ * Advances the system clock by the specified time in milliseconds and then ensures any new tasks
+ * that were scheduled are fully executed before proceeding. This does not guarantee the
+ * dispatchers enter an idle state, but it should guarantee that any tasks previously not executed
+ * due to it not yet being the time for them to be executed may now run if the clock was
+ * sufficiently forwarded.
+ */
+ @ExperimentalCoroutinesApi
+ fun advanceTimeBy(delayTimeMillis: Long) {
+ fakeSystemClock.advanceTime(delayTimeMillis)
+ runCurrent()
+ }
+
+ /**
+ * Runs all tasks on all tracked threads & coroutine dispatchers until no other tasks are pending.
+ * However, tasks that require the clock to be advanced will likely not be run (depending on
+ * whether the test under question is using a paused execution model, which is recommended for
+ * Robolectric tests).
+ */
+ @ExperimentalCoroutinesApi
+ fun advanceUntilIdle() {
+ do {
+ flushAllTasks()
+ } while (hasPendingTasks())
+ }
+
+ @ExperimentalCoroutinesApi
+ private fun flushNextTasks() {
+ if (backgroundTestDispatcher.hasPendingCompletableTasks()) {
+ backgroundTestDispatcher.runCurrent()
+ }
+ if (blockingTestDispatcher.hasPendingCompletableTasks()) {
+ blockingTestDispatcher.runCurrent()
+ }
+ if (!shadowUiLooper.isIdle) {
+ shadowUiLooper.idle()
+ }
+ }
+
+ @ExperimentalCoroutinesApi
+ private fun flushAllTasks() {
+ val currentTimeMillis = fakeSystemClock.getTimeMillis()
+ if (backgroundTestDispatcher.hasPendingCompletableTasks()) {
+ backgroundTestDispatcher.advanceUntilIdle()
+ }
+ if (blockingTestDispatcher.hasPendingCompletableTasks()) {
+ blockingTestDispatcher.advanceUntilIdle()
+ }
+ shadowUiLooper.idleFor(currentTimeMillis, TimeUnit.MILLISECONDS)
+ SystemClock.setCurrentTimeMillis(currentTimeMillis)
+ }
+
+ private fun hasPendingTasks(): Boolean {
+ // TODO(#89): Ensure the check for pending UI thread tasks is actually correct.
+ return backgroundTestDispatcher.hasPendingTasks() ||
+ blockingTestDispatcher.hasPendingTasks() ||
+ !shadowUiLooper.isIdle
+ }
+
+ private fun hasPendingCompletableTasks(): Boolean {
+ return backgroundTestDispatcher.hasPendingCompletableTasks() ||
+ blockingTestDispatcher.hasPendingCompletableTasks() ||
+ !shadowUiLooper.isIdle
+ }
+}
diff --git a/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt b/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt
new file mode 100644
index 00000000000..910666ad994
--- /dev/null
+++ b/testing/src/main/java/org/oppia/testing/TestDispatcherModule.kt
@@ -0,0 +1,58 @@
+package org.oppia.testing
+
+import dagger.Module
+import dagger.Provides
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.coroutines.asCoroutineDispatcher
+import org.oppia.util.threading.BackgroundDispatcher
+import org.oppia.util.threading.BlockingDispatcher
+import java.util.concurrent.Executors
+import javax.inject.Singleton
+
+/**
+ * Dagger [Module] that provides [CoroutineDispatcher]s that bind to [BackgroundDispatcher] and
+ * [BlockingDispatcher] qualifiers.
+ */
+@Module
+class TestDispatcherModule {
+ @Provides
+ @InternalCoroutinesApi
+ @BackgroundDispatcher
+ fun provideBackgroundDispatcher(
+ @BackgroundTestDispatcher testCoroutineDispatcher: TestCoroutineDispatcher
+ ): CoroutineDispatcher {
+ return testCoroutineDispatcher
+ }
+
+ @Provides
+ @InternalCoroutinesApi
+ @BlockingDispatcher
+ fun provideBlockingDispatcher(
+ @BlockingTestDispatcher testCoroutineDispatcher: TestCoroutineDispatcher
+ ): CoroutineDispatcher {
+ return testCoroutineDispatcher
+ }
+
+ @Provides
+ @BackgroundTestDispatcher
+ @InternalCoroutinesApi
+ @Singleton
+ fun provideBackgroundTestDispatcher(
+ factory: TestCoroutineDispatcher.Factory
+ ): TestCoroutineDispatcher {
+ return factory.createDispatcher(
+ Executors.newFixedThreadPool(/* nThreads= */ 4).asCoroutineDispatcher()
+ )
+ }
+
+ @Provides
+ @BlockingTestDispatcher
+ @InternalCoroutinesApi
+ @Singleton
+ fun provideBlockingTestDispatcher(
+ factory: TestCoroutineDispatcher.Factory
+ ): TestCoroutineDispatcher {
+ return factory.createDispatcher(Executors.newSingleThreadExecutor().asCoroutineDispatcher())
+ }
+}
diff --git a/testing/src/main/java/org/oppia/testing/TestDispatchers.kt b/testing/src/main/java/org/oppia/testing/TestDispatchers.kt
new file mode 100644
index 00000000000..faf0026debb
--- /dev/null
+++ b/testing/src/main/java/org/oppia/testing/TestDispatchers.kt
@@ -0,0 +1,9 @@
+package org.oppia.testing
+
+import javax.inject.Qualifier
+
+/** Corresponds to the [TestCoroutineDispatcher] that's used for background task execution. */
+@Qualifier annotation class BackgroundTestDispatcher
+
+/** Corresponds to the [TestCoroutineDispatcher] that's used for blocking task execution. */
+@Qualifier annotation class BlockingTestDispatcher