Skip to content

Commit a4d5061

Browse files
committed
Make UncompletedTestCoroutines more verbose
Fixes #3066 Fixes #3069
1 parent a6b17dc commit a4d5061

File tree

5 files changed

+87
-7
lines changed

5 files changed

+87
-7
lines changed

kotlinx-coroutines-core/common/src/internal/StackTraceRecovery.common.kt

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ internal expect suspend inline fun recoverAndThrow(exception: Throwable): Nothin
4040
* The opposite of [recoverStackTrace].
4141
* It is guaranteed that `unwrap(recoverStackTrace(e)) === e`
4242
*/
43+
@PublishedApi
4344
internal expect fun <E: Throwable> unwrap(exception: E): E
4445

4546
internal expect class StackTraceElement

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

+22-2
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public fun TestScope.runTest(
164164
): TestResult = asSpecificImplementation().let {
165165
it.enter()
166166
createTestResult {
167-
runTestCoroutine(it, dispatchTimeoutMs, testBody) { it.leave() }
167+
runTestCoroutine(it, dispatchTimeoutMs, TestScopeImpl::tryGetCompletionCause, testBody) { it.leave() }
168168
}
169169
}
170170

@@ -196,6 +196,7 @@ internal const val DEFAULT_DISPATCH_TIMEOUT_MS = 60_000L
196196
internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
197197
coroutine: T,
198198
dispatchTimeoutMs: Long,
199+
tryGetCompletionCause: T.() -> Throwable?,
199200
testBody: suspend T.() -> Unit,
200201
cleanup: () -> List<Throwable>,
201202
) {
@@ -228,7 +229,26 @@ internal suspend fun <T: AbstractCoroutine<Unit>> runTestCoroutine(
228229
// we expect these and will instead throw a more informative exception just below.
229230
emptyList()
230231
}.throwAll()
231-
throw UncompletedCoroutinesError("The test coroutine was not completed after waiting for $dispatchTimeoutMs ms")
232+
var completing: Boolean
233+
val completionCause = try {
234+
coroutine.tryGetCompletionCause().also { completing = true }
235+
} catch (e: Throwable) {
236+
completing = false
237+
null
238+
}
239+
var message = "After waiting for $dispatchTimeoutMs ms"
240+
if (!completing)
241+
message += ", the test coroutine is not completing"
242+
val activeChildren = coroutine.children.filter { it.isActive }.toList()
243+
if (activeChildren.isNotEmpty())
244+
message += ", there were active child jobs: $activeChildren"
245+
if (completing && activeChildren.isEmpty()) {
246+
// some sort of race condition? write something generic.
247+
message += ", the test coroutine was not completed"
248+
}
249+
val error = UncompletedCoroutinesError(message)
250+
completionCause?.let { cause -> error.addSuppressed(cause) }
251+
throw error
232252
}
233253
}
234254
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ internal class TestScopeImpl(context: CoroutineContext) :
216216
}
217217
}
218218

219+
/** Throws an exception if the coroutine is not completing. */
220+
fun tryGetCompletionCause(): Throwable? = completionCause
221+
219222
override fun toString(): String =
220223
"TestScope[" + (if (finished) "test ended" else if (entered) "test started" else "test not started") + "]"
221224
}

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

+54
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package kotlinx.coroutines.test
66

77
import kotlinx.coroutines.*
8+
import kotlinx.coroutines.internal.*
89
import kotlinx.coroutines.flow.*
910
import kotlin.coroutines.*
1011
import kotlin.test.*
@@ -97,6 +98,59 @@ class RunTestTest {
9798
}
9899
}
99100

101+
/** Tests that, on timeout, the names of the active coroutines are listed,
102+
* whereas the names of the completed ones are not. */
103+
@Test
104+
@NoJs
105+
@NoNative
106+
fun testListingActiveCoroutinesOnTimeout(): TestResult {
107+
val name1 = "GoodUniqueName"
108+
val name2 = "BadUniqueName"
109+
return testResultMap({
110+
try {
111+
it()
112+
fail("unreached")
113+
} catch (e: UncompletedCoroutinesError) {
114+
assertTrue((e.message ?: "").contains(name1))
115+
assertFalse((e.message ?: "").contains(name2))
116+
}
117+
}) {
118+
runTest(dispatchTimeoutMs = 10) {
119+
launch(CoroutineName(name1)) {
120+
CompletableDeferred<Unit>().await()
121+
}
122+
launch(CoroutineName(name2)) {
123+
}
124+
}
125+
}
126+
}
127+
128+
/** Tests that the [UncompletedCoroutinesError] suppresses an exception with which the coroutine is completing. */
129+
@Test
130+
fun testFailureWithPendingCoroutine() = testResultMap({
131+
try {
132+
it()
133+
fail("unreached")
134+
} catch (e: UncompletedCoroutinesError) {
135+
@Suppress("INVISIBLE_MEMBER")
136+
val suppressed = unwrap(e).suppressedExceptions
137+
assertEquals(1, suppressed.size)
138+
assertIs<TestException>(suppressed[0]).also {
139+
assertEquals("A", it.message)
140+
}
141+
}
142+
}) {
143+
runTest(dispatchTimeoutMs = 10) {
144+
launch {
145+
withContext(NonCancellable) {
146+
awaitCancellation()
147+
}
148+
}
149+
yield()
150+
throw TestException("A")
151+
}
152+
}
153+
100154
/** Tests that real delays can be accounted for with a large enough dispatch timeout. */
101155
@Test
102156
fun testRunTestWithLargeTimeout() = runTest(dispatchTimeoutMs = 5000) {

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

+7-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
package kotlinx.coroutines.test
99

1010
import kotlinx.coroutines.*
11-
import kotlinx.coroutines.selects.*
1211
import kotlin.coroutines.*
1312
import kotlin.jvm.*
1413

@@ -137,9 +136,9 @@ public fun runTestWithLegacyScope(
137136
): TestResult {
138137
if (context[RunningInRunTest] != null)
139138
throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")
140-
val testScope = TestBodyCoroutine<Unit>(createTestCoroutineScope(context + RunningInRunTest))
139+
val testScope = TestBodyCoroutine(createTestCoroutineScope(context + RunningInRunTest))
141140
return createTestResult {
142-
runTestCoroutine(testScope, dispatchTimeoutMs, testBody) {
141+
runTestCoroutine(testScope, dispatchTimeoutMs, TestBodyCoroutine::tryGetCompletionCause, testBody) {
143142
try {
144143
testScope.cleanup()
145144
emptyList()
@@ -169,9 +168,9 @@ public fun TestCoroutineScope.runTest(
169168
block: suspend TestCoroutineScope.() -> Unit
170169
): TestResult = runTestWithLegacyScope(coroutineContext, dispatchTimeoutMs, block)
171170

172-
private class TestBodyCoroutine<T>(
171+
private class TestBodyCoroutine(
173172
private val testScope: TestCoroutineScope,
174-
) : AbstractCoroutine<T>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
173+
) : AbstractCoroutine<Unit>(testScope.coroutineContext, initParentJob = true, active = true), TestCoroutineScope {
175174

176175
override val testScheduler get() = testScope.testScheduler
177176

@@ -187,4 +186,7 @@ private class TestBodyCoroutine<T>(
187186
)
188187

189188
fun cleanup() = testScope.cleanupTestCoroutines()
189+
190+
/** Throws an exception if the coroutine is not completing. */
191+
fun tryGetCompletionCause(): Throwable? = completionCause
190192
}

0 commit comments

Comments
 (0)