Drain the escaped coroutine error before the next test starts

`ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed`
deliberately lets a real error escape `viewModelScope.launch`, which has no
exception handler by design -- the ViewModel's KDoc says an OOM raised in the
probe should reach the thread's handler and take the process down.

On the JVM something else takes it. kotlinx-coroutines-test installs a
process-wide collector for uncaught coroutine errors, keeps whatever it catches,
and hands the backlog to the next `runTest` that starts, which throws
UncaughtExceptionsBeforeTest. Every Compose test is a `runTest`: that is how
`createComposeRule` runs a composition. So the error lands on an unrelated test
in an unrelated file, and the message names neither the test that caused it nor
the error's origin.

Nothing has hit it yet only because the sole Compose test in the repo happens to
run before the ViewModel one. R38 adds six more Compose classes in exactly the
two packages that surround it, and the first two of them made the suite fail in
two different files on two consecutive runs of identical, green code -- the
throw is on a real Dispatchers.IO thread, delivered after the state assertion
that ends the test responsible, so which class catches it is a race.

Drain it where a Compose rule is built. A @Before cannot: the rule's `runTest`
wraps the statement that calls it, so it has already thrown. @BeforeClass cannot
either, because Robolectric runs it outside the sandbox classloader, where the
collector is a different object. Constructing the rule is early enough, since
JUnit builds a fresh test-class instance -- and every @get:Rule field on it --
before evaluating any rule.

This is containment, not the cure. The cure is a seam: give the probe hop an
injectable dispatcher the way the constructor already does for
cleanupDispatcher, so the error has somewhere to land. That is a production
change and deserves its own commit.

kotlinx-coroutines-test was already on the unit-test classpath through
compose-ui-test-junit4; it is declared now because a file imports it. Pinned,
like robolectric and the linters: org.jetbrains.kotlinx is not one of the groups
the prerelease guard covers, so a float here would be free to take a milestone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:30:32 -05:00
co-authored by Claude Opus 5
parent 1779f20a03
commit 64c1a60a97
4 changed files with 76 additions and 2 deletions
@@ -8,7 +8,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -42,8 +41,11 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AppRootRestorationTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors]. Every Compose test
// class in this source set starts there, whether or not it is the one that happens to be
// running when another test's escaped coroutine error is delivered.
@get:Rule
val composeRule = createComposeRule()
val composeRule = createDrainedComposeRule()
private val restoration = StateRestorationTester(composeRule)
@@ -0,0 +1,53 @@
package org.libremediaconverter
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.test.runTest
/**
* Clears coroutine errors this module's tests deliberately let escape, so they land on the test
* that caused them instead of on the next one to start.
*
* **Every Compose test class in `src/test` has to start here.** `createComposeRule` runs the
* composition inside `runTest`, and `runTest` opens by throwing `UncaughtExceptionsBeforeTest` for
* anything already sitting in kotlinx-coroutines-test's collector -- a process-wide
* `CoroutineExceptionHandler` it installs once and never removes.
*
* There is one deposit into that collector here, and it is not a mistake:
* `ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed` proves an OOM raised
* inside the probe is rethrown rather than reported as an unreadable file. `onInputPicked` runs it
* in `viewModelScope.launch`, which has no exception handler by design -- the ViewModel's own KDoc
* says a real OOM should reach the thread's handler and take the process down. On the JVM the
* collector takes it instead, holds it, and hands it to whichever `runTest` starts next.
*
* It surfaced as two *different* Compose test classes failing on two consecutive runs of the same,
* green, code, with a message naming neither the test nor the error's origin. Which class catches
* it moves because the throw happens on a real `Dispatchers.IO` thread, after the state assertion
* that ends the test that caused it -- so it can be delivered long after that class is done.
*
* A `@Before` method cannot do this: the compose rule's `runTest` wraps the statement that calls
* `@Before`, so it has already thrown. `@BeforeClass` cannot either -- Robolectric runs it outside
* the sandbox classloader, where the collector is a different object. Draining while the rule is
* being *constructed* is early enough, because JUnit builds a fresh test-class instance, and with
* it every `@get:Rule` field, before evaluating any rule.
*
* The real fix is a seam: give the probe hop an injectable dispatcher the way
* `ConversionViewModel`'s constructor already does for `cleanupDispatcher`, and the error would
* have somewhere to land. That is a production change, so it belongs in its own commit.
*/
fun drainEscapedCoroutineErrors() {
// Entering a test scope is what flushes the collector; the flush is reported as this
// throwing, and there is nothing to assert about an error another test already asserted on.
runCatching { runTest {} }
}
/**
* [createComposeRule], with [drainEscapedCoroutineErrors] run first. Use this rather than
* `createComposeRule` directly in `src/test`.
*
* It also keeps the one mixed import in one place: the rule comes from the **v2** package
* (`androidx.compose.ui.test.junit4.v2`) while `StateRestorationTester`, which takes it, does not.
*/
fun createDrainedComposeRule() = run {
drainEscapedCoroutineErrors()
createComposeRule()
}