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