Skip to content

Commit

Permalink
Improve TestCoroutineScope
Browse files Browse the repository at this point in the history
* Add more detailed documentation;
* Move most verification logic from `runBlockingTest` to
  `cleanupTestCoroutines`
Fixes #1749
* Complete the scope's job if a new job was created for it
Fixes #1772
  • Loading branch information
dkhalanskyjb committed Oct 27, 2021
1 parent 5509e90 commit 6c52412
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 53 deletions.
45 changes: 3 additions & 42 deletions kotlinx-coroutines-test/common/src/TestBuilders.kt
Expand Up @@ -43,25 +43,16 @@ import kotlin.coroutines.*
*/
@ExperimentalCoroutinesApi
public fun runBlockingTest(context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit) {
val (safeContext, dispatcher) = context.checkTestScopeArguments()
val startingJobs = safeContext.activeJobs()
val scope = TestCoroutineScope(safeContext)
val scope = TestCoroutineScope(context)
val scheduler = scope.coroutineContext[TestCoroutineScheduler]!!
val deferred = scope.async {
scope.testBody()
}
dispatcher.scheduler.advanceUntilIdle()
scheduler.advanceUntilIdle()
deferred.getCompletionExceptionOrNull()?.let {
throw it
}
scope.cleanupTestCoroutines()
val endingJobs = safeContext.activeJobs()
if ((endingJobs - startingJobs).isNotEmpty()) {
throw UncompletedCoroutinesError("Test finished with active jobs: $endingJobs")
}
}

private fun CoroutineContext.activeJobs(): Set<Job> {
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
}

/**
Expand All @@ -78,33 +69,3 @@ public fun TestCoroutineScope.runBlockingTest(block: suspend TestCoroutineScope.
@ExperimentalCoroutinesApi
public fun TestCoroutineDispatcher.runBlockingTest(block: suspend TestCoroutineScope.() -> Unit): Unit =
runBlockingTest(this, block)

internal fun CoroutineContext.checkTestScopeArguments(): Pair<CoroutineContext, TestDispatcher> {
val scheduler: TestCoroutineScheduler
val dispatcher = when (val dispatcher = get(ContinuationInterceptor)) {
is TestDispatcher -> {
val ctxScheduler = get(TestCoroutineScheduler)
if (ctxScheduler == null) {
scheduler = dispatcher.scheduler
} else {
require(dispatcher.scheduler === ctxScheduler) {
"Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " +
"another scheduler were passed."
}
scheduler = ctxScheduler
}
dispatcher
}
null -> {
scheduler = get(TestCoroutineScheduler) ?: TestCoroutineScheduler()
TestCoroutineDispatcher(scheduler)
}
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
}
val exceptionHandler = get(CoroutineExceptionHandler).run {
this?.let { require(this is UncaughtExceptionCaptor) { "coroutineExceptionHandler must implement UncaughtExceptionCaptor: $this" } }
this ?: TestCoroutineExceptionHandler()
}
val job = get(Job) ?: SupervisorJob()
return Pair(this + scheduler + dispatcher + exceptionHandler + job, dispatcher)
}
84 changes: 74 additions & 10 deletions kotlinx-coroutines-test/common/src/TestCoroutineScope.kt
Expand Up @@ -13,12 +13,13 @@ import kotlin.coroutines.*
@ExperimentalCoroutinesApi
public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCaptor {
/**
* Call after the test completes.
* Called after the test completes.
*
* Calls [UncaughtExceptionCaptor.cleanupTestCoroutinesCaptor] and [DelayController.cleanupTestCoroutines].
* If a new job was created for this scope, the job is completed.
*
* @throws Throwable the first uncaught exception, if there are any uncaught exceptions.
* @throws AssertionError if any pending tasks are active, however it will not throw for suspended
* coroutines.
* @throws AssertionError if any pending tasks are active.
*/
@ExperimentalCoroutinesApi
public fun cleanupTestCoroutines()
Expand All @@ -32,29 +33,92 @@ public sealed interface TestCoroutineScope: CoroutineScope, UncaughtExceptionCap

private class TestCoroutineScopeImpl (
override val coroutineContext: CoroutineContext,
override val testScheduler: TestCoroutineScheduler
val ownJob: CompletableJob?
):
TestCoroutineScope,
UncaughtExceptionCaptor by coroutineContext.uncaughtExceptionCaptor
{
override val testScheduler: TestCoroutineScheduler
get() = coroutineContext[TestCoroutineScheduler]!!

/** These jobs existed before the coroutine scope was used, so it's alright if they don't get cancelled. */
val initialJobs = coroutineContext.activeJobs()

override fun cleanupTestCoroutines() {
coroutineContext.uncaughtExceptionCaptor.cleanupTestCoroutinesCaptor()
coroutineContext.delayController?.cleanupTestCoroutines()
val jobs = coroutineContext.activeJobs()
if ((jobs - initialJobs).isNotEmpty()) {
throw UncompletedCoroutinesError("Test finished with active jobs: $jobs")
}
ownJob?.complete()
}
}

private fun CoroutineContext.activeJobs(): Set<Job> {
return checkNotNull(this[Job]).children.filter { it.isActive }.toSet()
}

/**
* A scope which provides detailed control over the execution of coroutines for tests.
* A coroutine scope for launching test coroutines.
*
* If the provided context does not provide a [ContinuationInterceptor] (Dispatcher) or [CoroutineExceptionHandler], the
* scope adds [TestCoroutineDispatcher] and [TestCoroutineExceptionHandler] automatically.
* It ensures that all the test module machinery is properly initialized.
* * If [context] doesn't define a [TestCoroutineScheduler] for orchestrating the virtual time used for delay-skipping,
* a new one is created, unless a [TestDispatcher] is provided, in which case [TestDispatcher.scheduler] is used.
* * If [context] doesn't have a [ContinuationInterceptor], a [TestCoroutineDispatcher] is created.
* * If [context] does not provide a [CoroutineExceptionHandler], [TestCoroutineExceptionHandler] is created
* automatically.
* * If [context] provides a [Job], that job is used for the new scope, but is not completed once the scope completes.
* On the other hand, if there is no [Job] in the context, a [CompletableJob] is created and completed on
* [TestCoroutineScope.cleanupTestCoroutines].
*
* @param context an optional context that MAY provide [UncaughtExceptionCaptor] and/or [DelayController]
* @throws IllegalArgumentException if [context] has both [TestCoroutineScheduler] and a [TestDispatcher] linked to a
* different scheduler.
* @throws IllegalArgumentException if [context] has a [ContinuationInterceptor] that is not a [TestDispatcher].
* @throws IllegalArgumentException if [context] has an [CoroutineExceptionHandler] that is not an
* [UncaughtExceptionCaptor].
*/
@Suppress("FunctionName")
@ExperimentalCoroutinesApi
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope =
context.checkTestScopeArguments().let { TestCoroutineScopeImpl(it.first, it.second.scheduler) }
public fun TestCoroutineScope(context: CoroutineContext = EmptyCoroutineContext): TestCoroutineScope {
val scheduler: TestCoroutineScheduler
val dispatcher = when (val dispatcher = context[ContinuationInterceptor]) {
is TestDispatcher -> {
scheduler = dispatcher.scheduler
val ctxScheduler = context[TestCoroutineScheduler]
if (ctxScheduler != null) {
require(dispatcher.scheduler === ctxScheduler) {
"Both a TestCoroutineScheduler $ctxScheduler and TestDispatcher $dispatcher linked to " +
"another scheduler were passed."
}
}
dispatcher
}
null -> {
scheduler = context[TestCoroutineScheduler] ?: TestCoroutineScheduler()
TestCoroutineDispatcher(scheduler)
}
else -> throw IllegalArgumentException("Dispatcher must implement TestDispatcher: $dispatcher")
}
val exceptionHandler = context[CoroutineExceptionHandler].run {
this?.let {
require(this is UncaughtExceptionCaptor) {
"coroutineExceptionHandler must implement UncaughtExceptionCaptor: $context"
}
}
this ?: TestCoroutineExceptionHandler()
}
val job: Job
val ownJob: CompletableJob?
if (context[Job] == null) {
ownJob = SupervisorJob()
job = ownJob
} else {
ownJob = null
job = context[Job]!!
}
return TestCoroutineScopeImpl(context + scheduler + dispatcher + exceptionHandler + job, ownJob)
}

private inline val CoroutineContext.uncaughtExceptionCaptor: UncaughtExceptionCaptor
get() {
Expand Down
2 changes: 1 addition & 1 deletion kotlinx-coroutines-test/common/test/TestBuildersTest.kt
Expand Up @@ -104,7 +104,7 @@ class TestBuildersTest {
}

@Test
fun whenInrunBlocking_runBlockingTest_nestsProperly() {
fun whenInRunBlocking_runBlockingTest_nestsProperly() {
// this is not a supported use case, but it is possible so ensure it works

val scope = TestCoroutineScope()
Expand Down
Expand Up @@ -5,6 +5,7 @@
package kotlinx.coroutines.test

import kotlinx.coroutines.*
import kotlin.coroutines.*
import kotlin.test.*

class TestCoroutineSchedulerTest {
Expand Down
70 changes: 70 additions & 0 deletions kotlinx-coroutines-test/common/test/TestCoroutineScopeTest.kt
Expand Up @@ -50,6 +50,76 @@ class TestCoroutineScopeTest {
}
}

/** Tests that the cleanup procedure throws if there were uncompleted delays by the end. */
@Test
fun testPresentDelaysThrowing() {
val scope = TestCoroutineScope()
var result = false
scope.launch {
delay(5)
result = true
}
assertFalse(result)
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
assertFalse(result)
}

/** Tests that the cleanup procedure throws if there were active jobs by the end. */
@Test
fun testActiveJobsThrowing() {
val scope = TestCoroutineScope()
var result = false
val deferred = CompletableDeferred<String>()
scope.launch {
deferred.await()
result = true
}
assertFalse(result)
assertFailsWith<AssertionError> { scope.cleanupTestCoroutines() }
assertFalse(result)
}

/** Tests that the cleanup procedure doesn't throw if it detects that the job is already cancelled. */
@Test
fun testCancelledDelaysNotThrowing() {
val scope = TestCoroutineScope()
var result = false
val deferred = CompletableDeferred<String>()
val job = scope.launch {
deferred.await()
result = true
}
job.cancel()
assertFalse(result)
scope.cleanupTestCoroutines()
assertFalse(result)
}

/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
@Test
fun testCompletesOwnJob() {
val scope = TestCoroutineScope()
var handlerCalled = false
scope.coroutineContext.job.invokeOnCompletion {
handlerCalled = true
}
scope.cleanupTestCoroutines()
assertTrue(handlerCalled)
}

/** Tests that the coroutine scope completes its job if the job was not passed to it as an argument. */
@Test
fun testDoesNotCompleteGivenJob() {
var handlerCalled = false
val job = Job()
job.invokeOnCompletion {
handlerCalled = true
}
val scope = TestCoroutineScope(job)
scope.cleanupTestCoroutines()
assertFalse(handlerCalled)
}

private val invalidContexts = listOf(
Dispatchers.Default, // not a [TestDispatcher]
TestCoroutineDispatcher() + TestCoroutineScheduler(), // the dispatcher is not linked to the scheduler
Expand Down

0 comments on commit 6c52412

Please sign in to comment.