Skip to content

Commit 6fa024c

Browse files
committed
Add some tests, fix a bug
The bug was that the test would time out if every non-background coroutine was waiting for something from the background ones.
1 parent 4e35d15 commit 6fa024c

10 files changed

+143
-31
lines changed

kotlinx-coroutines-test/common/src/TestBuilders.kt

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ public fun TestScope.runTest(
166166
createTestResult {
167167
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) {
168168
backgroundWorkScope.cancel()
169-
testScheduler.advanceUntilIdle(backgroundIsIdle = false)
169+
testScheduler.advanceUntilIdleOr { false }
170170
it.leave()
171171
}
172172
}
@@ -176,7 +176,7 @@ public fun TestScope.runTest(
176176
* Runs [testProcedure], creating a [TestResult].
177177
*/
178178
@Suppress("NO_ACTUAL_FOR_EXPECT") // actually suppresses `TestResult`
179-
internal expect fun createTestResult(testProcedure: suspend () -> Unit): TestResult
179+
internal expect fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit): TestResult
180180

181181
/** A coroutine context element indicating that the coroutine is running inside `runTest`. */
182182
internal object RunningInRunTest : CoroutineContext.Key<RunningInRunTest>, CoroutineContext.Element {
@@ -199,7 +199,7 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
199199
* The [cleanup] procedure may either throw [UncompletedCoroutinesError] to denote that child coroutines were leaked, or
200200
* return a list of uncaught exceptions that should be reported at the end of the test.
201201
*/
202-
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
202+
internal suspend fun <T: AbstractCoroutine<Unit>> CoroutineScope.runTestCoroutine(
203203
coroutine: T,
204204
dispatchTimeoutMs: Long,
205205
tryGetCompletionCause: T.() -> Throwable?,
@@ -220,16 +220,27 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
220220
completed = true
221221
continue
222222
}
223-
select<Unit> {
224-
coroutine.onJoin {
225-
completed = true
223+
// in case progress depends on some background work, we need to keep spinning it.
224+
val backgroundWorkRunner = launch(CoroutineName("background work runner")) {
225+
while (true) {
226+
scheduler.tryRunNextTaskUnless { !isActive }
227+
yield()
226228
}
227-
scheduler.onDispatchEvent {
228-
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
229-
}
230-
onTimeout(dispatchTimeoutMs) {
231-
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
229+
}
230+
try {
231+
select<Unit> {
232+
coroutine.onJoin {
233+
completed = true
234+
}
235+
scheduler.onDispatchEvent {
236+
// we received knowledge that `scheduler` observed a dispatch event, so we reset the timeout
237+
}
238+
onTimeout(dispatchTimeoutMs) {
239+
handleTimeout(coroutine, dispatchTimeoutMs, tryGetCompletionCause, cleanup)
240+
}
232241
}
242+
} finally {
243+
backgroundWorkRunner.cancelAndJoin()
233244
}
234245
}
235246
coroutine.getCompletionExceptionOrNull()?.let { exception ->

kotlinx-coroutines-test/common/src/TestCoroutineDispatchers.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ private class UnconfinedTestDispatcherImpl(
9696
@Suppress("INVISIBLE_MEMBER")
9797
override fun dispatch(context: CoroutineContext, block: Runnable) {
9898
checkSchedulerInContext(scheduler, context)
99-
scheduler.sendDispatchEvent()
99+
scheduler.sendDispatchEvent(context)
100100

101101
/** copy-pasted from [kotlinx.coroutines.Unconfined.dispatch] */
102102
/** It can only be called by the [yield] function. See also code of [yield] function. */

kotlinx-coroutines-test/common/src/TestCoroutineScheduler.kt

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
7575
events.addLast(event)
7676
/** can't be moved above: otherwise, [onDispatchEvent] could consume the token sent here before there's
7777
* actually anything in the event queue. */
78-
sendDispatchEvent()
78+
sendDispatchEvent(context)
7979
DisposableHandle {
8080
synchronized(lock) {
8181
events.remove(event)
@@ -86,11 +86,11 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
8686

8787
/**
8888
* Runs the next enqueued task, advancing the virtual time to the time of its scheduled awakening,
89-
* unless all the remaining tasks are the background ones.
89+
* unless [condition] holds.
9090
*/
91-
private fun tryRunNextTask(backgroundIsIdle: Boolean): Boolean {
91+
internal fun tryRunNextTaskUnless(condition: () -> Boolean): Boolean {
9292
val event = synchronized(lock) {
93-
if (backgroundIsIdle && events.none(TestDispatchEvent<*>::isForeground)) return false
93+
if (condition()) return false
9494
val event = events.removeFirstOrNull() ?: return false
9595
if (currentTime > event.time)
9696
currentTimeAheadOfEvents()
@@ -110,15 +110,14 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
110110
* functionality, query [currentTime] before and after the execution to achieve the same result.
111111
*/
112112
@ExperimentalCoroutinesApi
113-
public fun advanceUntilIdle(): Unit = advanceUntilIdle(backgroundIsIdle = true)
113+
public fun advanceUntilIdle(): Unit = advanceUntilIdleOr { events.none(TestDispatchEvent<*>::isForeground) }
114114

115115
/**
116-
* [backgroundIsIdle]: `true` if the background tasks should not be considered
117-
* when checking if the scheduler is already idle.
116+
* [condition]: guaranteed to be invoked under the lock.
118117
*/
119-
internal fun advanceUntilIdle(backgroundIsIdle: Boolean) {
118+
internal fun advanceUntilIdleOr(condition: () -> Boolean) {
120119
while (true) {
121-
if (!tryRunNextTask(backgroundIsIdle = backgroundIsIdle))
120+
if (!tryRunNextTaskUnless(condition))
122121
return
123122
}
124123
}
@@ -188,9 +187,12 @@ public class TestCoroutineScheduler : AbstractCoroutineContextElement(TestCorout
188187

189188
/**
190189
* Notifies this scheduler about a dispatch event.
190+
*
191+
* [context] is the context in which the task will be dispatched.
191192
*/
192-
internal fun sendDispatchEvent() {
193-
dispatchEvents.trySend(Unit)
193+
internal fun sendDispatchEvent(context: CoroutineContext) {
194+
if (context[BackgroundWork] !== BackgroundWork)
195+
dispatchEvents.trySend(Unit)
194196
}
195197

196198
/**
@@ -257,4 +259,4 @@ internal object BackgroundWork : CoroutineContext.Key<BackgroundWork>, Coroutine
257259
}
258260

259261
private fun<T> ThreadSafeHeap<T>.none(predicate: (T) -> Boolean) where T: ThreadSafeHeapNode, T: Comparable<T> =
260-
find(predicate) == null
262+
find(predicate) == null

kotlinx-coroutines-test/common/test/RunTestTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class RunTestTest {
7171

7272
/** Tests that a dispatch timeout of `0` will fail the test if there are some dispatches outside the scheduler. */
7373
@Test
74+
@NoNative // TODO: timeout leads to `Cannot execute task because event loop was shut down` on Native
7475
fun testRunTestWithZeroTimeoutWithUncontrolledDispatches() = testResultMap({ fn ->
7576
assertFailsWith<UncompletedCoroutinesError> { fn() }
7677
}) {

kotlinx-coroutines-test/common/test/TestScopeTest.kt

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.channels.*
9+
import kotlinx.coroutines.flow.*
810
import kotlin.coroutines.*
911
import kotlin.test.*
1012

@@ -327,6 +329,102 @@ class TestScopeTest {
327329
}
328330
}
329331

332+
/**
333+
* Tests using [Flow.stateIn] as a background job.
334+
*/
335+
@Test
336+
fun testExampleBackgroundJob1() = runTest {
337+
val myFlow = flow {
338+
var i = 0
339+
while (true) {
340+
emit(++i)
341+
delay(1)
342+
}
343+
}
344+
val stateFlow = myFlow.stateIn(backgroundWorkScope, SharingStarted.Eagerly, 0)
345+
var j = 0
346+
repeat(100) {
347+
assertEquals(j++, stateFlow.value)
348+
delay(1)
349+
}
350+
}
351+
352+
/**
353+
* A test from the documentation of [TestScope.backgroundWorkScope].
354+
*/
355+
@Test
356+
fun testExampleBackgroundJob2() = runTest {
357+
val channel = Channel<Int>()
358+
backgroundWorkScope.launch {
359+
var i = 0
360+
while (true) {
361+
channel.send(i++)
362+
}
363+
}
364+
repeat(100) {
365+
assertEquals(it, channel.receive())
366+
}
367+
}
368+
369+
/**
370+
* Tests that the test will timeout due to idleness even if some background tasks are running.
371+
*/
372+
@Test
373+
fun testBackgroundWorkNotPreventingTimeout(): TestResult = testResultMap({
374+
try {
375+
it()
376+
fail("unreached")
377+
} catch (_: UncompletedCoroutinesError) {
378+
379+
}
380+
}) {
381+
runTest(dispatchTimeoutMs = 100) {
382+
backgroundWorkScope.launch {
383+
while (true) {
384+
yield()
385+
}
386+
}
387+
backgroundWorkScope.launch {
388+
while (true) {
389+
delay(1)
390+
}
391+
}
392+
val deferred = CompletableDeferred<Unit>()
393+
deferred.await()
394+
}
395+
396+
}
397+
398+
@Test
399+
fun testUnconfinedBackgroundWorkNotPreventingTimeout(): TestResult = testResultMap({
400+
try {
401+
it()
402+
fail("unreached")
403+
} catch (_: UncompletedCoroutinesError) {
404+
405+
}
406+
}) {
407+
runTest(UnconfinedTestDispatcher(), dispatchTimeoutMs = 100) {
408+
/**
409+
* Having a coroutine like this will still cause the test to hang:
410+
backgroundWorkScope.launch {
411+
while (true) {
412+
yield()
413+
}
414+
}
415+
* The reason is that even the initial [advanceUntilIdle] will never return in this case.
416+
*/
417+
backgroundWorkScope.launch {
418+
while (true) {
419+
delay(1)
420+
}
421+
}
422+
val deferred = CompletableDeferred<Unit>()
423+
deferred.await()
424+
}
425+
426+
}
427+
330428
companion object {
331429
internal val invalidContexts = listOf(
332430
Dispatchers.Default, // not a [TestDispatcher]

kotlinx-coroutines-test/js/src/TestBuilders.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import kotlin.js.*
99
@Suppress("ACTUAL_WITHOUT_EXPECT", "ACTUAL_TYPE_ALIAS_TO_CLASS_WITH_DECLARATION_SITE_VARIANCE")
1010
public actual typealias TestResult = Promise<Unit>
1111

12-
internal actual fun createTestResult(testProcedure: suspend () -> Unit): TestResult =
12+
internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit): TestResult =
1313
GlobalScope.promise {
1414
testProcedure()
15-
}
15+
}

kotlinx-coroutines-test/jvm/src/TestBuildersJvm.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import kotlinx.coroutines.*
88
@Suppress("ACTUAL_WITHOUT_EXPECT")
99
public actual typealias TestResult = Unit
1010

11-
internal actual fun createTestResult(testProcedure: suspend () -> Unit) {
11+
internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit) {
1212
runBlocking {
1313
testProcedure()
1414
}
15-
}
15+
}

kotlinx-coroutines-test/jvm/src/migration/TestBuildersDeprecated.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public fun runBlockingTestOnTestScope(
9393
null // the deferred was not completed yet; `scope.leave()` should complain then about unfinished jobs
9494
}
9595
scope.backgroundWorkScope.cancel()
96-
scope.testScheduler.advanceUntilIdle(backgroundIsIdle = false)
96+
scope.testScheduler.advanceUntilIdleOr { false }
9797
throwable?.let {
9898
val exceptions = try {
9999
scope.leave()

kotlinx-coroutines-test/jvm/src/migration/TestCoroutineDispatcher.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public class TestCoroutineDispatcher(public override val scheduler: TestCoroutin
4141
override fun dispatch(context: CoroutineContext, block: Runnable) {
4242
checkSchedulerInContext(scheduler, context)
4343
if (dispatchImmediately) {
44-
scheduler.sendDispatchEvent()
44+
scheduler.sendDispatchEvent(context)
4545
block.run()
4646
} else {
4747
post(block, context)

kotlinx-coroutines-test/native/src/TestBuilders.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import kotlinx.coroutines.*
88
@Suppress("ACTUAL_WITHOUT_EXPECT")
99
public actual typealias TestResult = Unit
1010

11-
internal actual fun createTestResult(testProcedure: suspend () -> Unit) {
11+
internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit) {
1212
runBlocking {
1313
testProcedure()
1414
}

0 commit comments

Comments
 (0)