Why Your Compose Tests Started Failing: The v2 Test Dispatcher Explained

How a one-line import change exposes timing assumptions your test suite has quietly relied on for years
1. “My tests passed yesterday and nothing changed”
The diff that turned your CI red usually looks harmless. Maybe it’s one line per file:
- import androidx.compose.ui.test.junit4.createComposeRule + import androidx.compose.ui.test.junit4.v2.createComposeRule Someone accepted an IDE quick-fix for a deprecation warning. Or a Compose BOM bump plus allWarningsAsErrors = true made the deprecation fail the build, and a teammate ran a find-and-replace across the test source set. The production code didn't change, and neither did any assertions. Even so, 140 UI tests that had been green for two years now fail with messages like these:
java.lang.AssertionError: Expected: [paywall_viewed] but was: [] java.lang.AssertionError: Failed to assert the following: (Text + EditableText contains 'Saved') expected:<Ready(...)> but was:<Loading> The thing that changed is when your coroutines run. Starting in androidx.compose.ui:ui-test-junit4 and ui-test 1.11.0-alpha03, Compose has "v2" versions of its test-environment entry points: createComposeRule, createAndroidComposeRule, runComposeUiTest, runAndroidComposeUiTest and their empty variants. The v1 versions are deprecated. The v2 versions run composition on a StandardTestDispatcher instead of an UnconfinedTestDispatcher.
Only the setup APIs moved. Finders, assertions, actions and mainClock are unchanged. That's why the diff is so small and the blast radius is so large.
The one case where “nothing changed” is literally true: if you construct or subclass
AndroidComposeUiTestEnvironmentdirectly (common in custom JUnit rules and in-house test harnesses), its default composition dispatcher switched from unconfined to standard. A dependency bump alone can flip behavior there, without touching a single import.
2. v1 vs v2: two very different schedulers
Both dispatchers come from kotlinx-coroutines-test, and both are backed by a TestCoroutineScheduler with a virtual clock. They differ in what happens at the moment a coroutine is launched.
UnconfinedTestDispatcher (v1) runs a new coroutine eagerly, on the current thread, until its first real suspension point. A LaunchedEffect body, or a scope.launch { } inside a click handler, often finished before the next line of your test ran. Your test was reading results that existed only because of that eager execution.
StandardTestDispatcher (v2) puts the coroutine in a queue keyed by virtual time. Nothing runs until something drives the scheduler: a Compose synchronization point (waitForIdle, runOnIdle, any finder or assertion that syncs), or an explicit clock call.
There are three ideas to keep in mind:
- Queued, not dropped. Your coroutine will run. It just won’t run now.
- Virtual time is shared state.
delay(300)doesn't sleep. It schedules a resumption atcurrentTime + 300, which happens only when someone moves the clock that far. - There is one clock per scheduler. If two dispatchers don’t share a
TestCoroutineScheduler, advancing one does nothing for the other. The "two clocks" problem drives the hardest failures below.
v2 also enforces InputMode.Touch at the start of every test. This is unrelated to dispatchers, but it shows up in the same migration: focus and keyboard tests that relied on ambient device state can break too.
3. Why Google switched: the race conditions v1 hid
Eager execution makes tests deterministic in a way production never is. On a device, a coroutine dispatched to Main waits its turn behind the frame that's currently drawing, input events and other posted work. Under v1, it jumped the queue. A whole class of ordering bugs could never reproduce in tests.
Here’s a concrete case: a search screen that launches a query on every keystroke without cancelling the previous one.
class SearchViewModel(private val repo: SearchRepository) : ViewModel() { private val _results = MutableStateFlow<List<Hit>>(emptyList()) val results: StateFlow<List<Hit>> = _results.asStateFlow() fun onQueryChanged(query: String) { viewModelScope.launch { // BUG: no cancellation of in-flight work _results.value = repo.search(query) // slow for short queries } } } On a device, the request for "kot" (broad, slow) can return after the one for "kotl" (narrow, fast), and the user ends up looking at results for a query they've already edited. Under v1, each launch ran to its first suspension, the fake repository returned right away, and the test saw results in perfect keystroke order. With v2 and a fake that uses delay() to model latency, both requests are queued, both resume in virtual-time order, and the test sees what users see:
import androidx.compose.ui.test.v2.runComposeUiTest import kotlin.time.Duration.Companion.milliseconds @Test fun latestQueryWins() = runComposeUiTest { val repo = FakeSearchRepository(latencyFor = { q -> if (q.length < 4) 800.milliseconds else 100.milliseconds }) val vm = SearchViewModel(repo) setContent { SearchScreen(vm) } onNodeWithTag("query").performTextInput("kot") onNodeWithTag("query").performTextInput("l") mainClock.advanceTimeBy(1_000) onNodeWithTag("results").assert(hasAnyDescendant(hasText("kotlinx.coroutines"))) // v2: FAILS, and the failure is a real bug } The fix is in production code: mapLatest/collectLatest on a query StateFlow, or keep a Job and cancel it. The v2 test is now the regression guard. Google's stated goal for the change is to align Compose tests with plain runTest semantics and make execution order explicit. In practice that means tests that fail for the same reasons your app does.
4. A catalog of real failures
Nearly every v2 failure comes down to a coroutine that was queued but never given a turn before the assertion ran.
4.1 LaunchedEffect side effects not executed
A plan picker reports an analytics event whenever the selected plan changes:
@Composable fun PlanPicker(plans: List<Plan>, analytics: Analytics) { var selected by rememberSaveable { mutableStateOf(plans.first().id) } LaunchedEffect(selected) { analytics.log("plan_viewed", mapOf("plan" to selected)) } plans.forEach { plan -> PlanRow(plan, isSelected = plan.id == selected, onClick = { selected = plan.id }) } } Before: passed on v1, fails on v2
@Test fun selectingPlanLogsImpression() { composeRule.setContent { PlanPicker(samplePlans, fakeAnalytics) } composeRule.onNodeWithText("Annual").performClick() // Reads a plain Kotlin object: no Compose sync happens here assertThat(fakeAnalytics.events.last().params["plan"]).isEqualTo("annual") } performClick() writes the snapshot state. The recomposition that relaunches the effect, and the effect body itself, are now queued work. fakeAnalytics isn't a semantics node, so reading it doesn't trigger any synchronization. You're reading a list nobody has written to yet.
After
@Test fun selectingPlanLogsImpression() { composeRule.setContent { PlanPicker(samplePlans, fakeAnalytics) } composeRule.onNodeWithText("Annual").performClick() composeRule.runOnIdle { assertThat(fakeAnalytics.events.last().params["plan"]).isEqualTo("annual") } } Rule of thumb: any assertion against something that isn’t the semantics tree (fakes, mocks, ViewModel fields, files, DAOs) belongs in runOnIdle { } or after an explicit waitForIdle().
4.2 Flow collection never emitting in the test
A form ViewModel exposes one-shot events through a hot SharedFlow with no replay, which is the standard way to model "show a snackbar once":
class ProfileViewModel(private val repo: ProfileRepository) : ViewModel() { private val _events = MutableSharedFlow<ProfileEvent>() // replay = 0 val events: SharedFlow<ProfileEvent> = _events.asSharedFlow() fun save(profile: Profile) { viewModelScope.launch { repo.save(profile) _events.emit(ProfileEvent.Saved) } } } The runComposeUiTest block is a suspend lambda that runs with the test's StandardTestDispatcher, and that dispatcher is synchronized with mainClock. Its receiver is ComposeUiTest, not TestScope, so backgroundScope isn't available. A coroutineScope { } gives you the same structured guarantee: children inherit the test dispatcher, and they're cancelled automatically if an assertion throws.
Before: passed on v1, received is empty on v2
@Test fun saveEmitsSavedEvent() = runComposeUiTest { val vm = ProfileViewModel(FakeProfileRepository()) val received = mutableListOf<ProfileEvent>() coroutineScope { val collector = launch { vm.events.collect { received += it } } vm.save(sampleProfile) mainClock.scheduler.runCurrent() assertThat(received).containsExactly(ProfileEvent.Saved) // fails: [] collector.cancel() } } The collector’s launch is queued, so it hasn't subscribed yet. When save() emits, the SharedFlow has zero subscribers and zero replay, and the event is gone. Under eager dispatch, the collector subscribed during launch and the bug was invisible.
After: subscribe first, then act
@Test fun saveEmitsSavedEvent() = runComposeUiTest { val vm = ProfileViewModel(FakeProfileRepository()) val received = mutableListOf<ProfileEvent>() coroutineScope { val collector = launch { vm.events.collect { received += it } } mainClock.scheduler.runCurrent() // collector is now actually subscribed vm.save(sampleProfile) mainClock.scheduler.runCurrent() // let save() and its emit() run assertThat(received).containsExactly(ProfileEvent.Saved) collector.cancel() // on failure, coroutineScope cancels it for us } } If you use Turbine, vm.events.test { … } handles both subscription order and cleanup for you.
Two things are worth carrying into production code review:
- If the event matters even when nobody is listening yet, a
SharedFlow(replay = 0)is the wrong type. Use aChannel(BUFFERED)exposed viareceiveAsFlow(), or model the event as state that gets consumed. - The same race happens inside the UI. A
LaunchedEffect(Unit) { vm.events.collect { … } }subscribes on the first frame. If the ViewModel emits frominit, that emission can arrive before the composable is listening, on a device as well as in tests.
4.3 Animations stuck at their start value
A download indicator animates toward its target progress:
@Composable fun DownloadRing(progress: Float, modifier: Modifier = Modifier) { val animated by animateFloatAsState( targetValue = progress, animationSpec = tween(durationMillis = 600, easing = LinearEasing), label = "download" ) CircularProgressIndicator( progress = { animated }, modifier = modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(animated, 0f..1f) } ) } Frame-precise animation tests turn off auto-advance and step the clock by hand. Many were written against v1 timing, and they count on the animation coroutine having already started when the first frame is requested.
Before: passed on v1, reports 0.0 on v2
@Test fun ringIsHalfwayAt300ms() { var progress by mutableFloatStateOf(0f) composeRule.setContent { DownloadRing(progress, Modifier.testTag("ring")) } composeRule.mainClock.autoAdvance = false progress = 1f composeRule.mainClock.advanceTimeByFrame() // "start" the animation composeRule.mainClock.advanceTimeBy(300) composeRule.onNodeWithTag("ring") .assertRangeInfoEquals(ProgressBarRangeInfo(0.5f, 0f..1f)) } Under v2, the first frame only gets you a recomposition. The animation’s internal launch is queued behind it, so the 600 ms tween starts later than the test assumes. Depending on the exact sequence, you’ll see the start value or a value one or two frames behind. With autoAdvance = false, nothing else will move the clock for you.
After: assert in terms of durations and make the start explicit
@Test fun ringReachesTargetAfterDuration() { var progress by mutableFloatStateOf(0f) composeRule.setContent { DownloadRing(progress, Modifier.testTag("ring")) } composeRule.mainClock.autoAdvance = false progress = 1f composeRule.mainClock.advanceTimeByFrame() // recompose with new target composeRule.mainClock.scheduler.runCurrent() // let the animation coroutine start composeRule.mainClock.advanceTimeBy(300) composeRule.onNodeWithTag("ring").assert(progressBetween(0.35f, 0.65f)) composeRule.mainClock.advanceTimeBy(300 + 16) // full duration plus one frame composeRule.onNodeWithTag("ring") .assertRangeInfoEquals(ProgressBarRangeInfo(1f, 0f..1f)) } private fun progressBetween(min: Float, max: Float) = SemanticsMatcher("progress in $min..$max") { val info = it.config.getOrNull(SemanticsProperties.ProgressBarRangeInfo) ?: return@SemanticsMatcher false info.current in min..max } Notice that the midpoint check uses a tolerance band, not assertRangeInfoEquals(0.5f). Interpolated floats sampled on 16 ms frame boundaries almost never land exactly on 0.5f, so an exact-equality assertion mid-animation is flaky even on v1. The final assertion can be exact because a finished animation snaps to its target value.
For the steady-state check (“does it end at 100%?”), leave autoAdvance on and let the assertion's own sync finish the animation. Frame-exact intermediate assertions were always fragile. v2 just makes that fragility visible.
4.4 ViewModel init work not finished
class OrdersViewModel(private val repo: OrdersRepository) : ViewModel() { private val _state = MutableStateFlow<OrdersState>(OrdersState.Loading) val state: StateFlow<OrdersState> = _state.asStateFlow() init { viewModelScope.launch { _state.value = OrdersState.Ready(repo.recentOrders()) } } } viewModelScope runs on Dispatchers.Main.immediate. In a local (Robolectric) or instrumented test, most teams replace Main with a MainDispatcherRule. Under v1, that rule usually wrapped an UnconfinedTestDispatcher, so init work finished inside the constructor. When teams "go standard" to match v2, they often create a StandardTestDispatcher() with its own scheduler. Now init is queued on a clock that Compose never advances.
Before: two clocks, permanently Loading
class OrdersScreenTest { @get:Rule(order = 0) val mainRule = MainDispatcherRule(StandardTestDispatcher()) // separate scheduler @get:Rule(order = 1) val composeRule = createComposeRule() @Test fun showsOrders() { val vm = OrdersViewModel(FakeOrdersRepository(listOf(sampleOrder))) composeRule.setContent { OrdersScreen(vm) } composeRule.onNodeWithText(sampleOrder.title).assertIsDisplayed() // fails: still Loading } } After: one scheduler, owned by the outermost rule
Turn the ownership around. Don’t borrow Compose’s scheduler in @Before, because that runs after every rule has started. With createAndroidComposeRule<MyActivity>(), the activity, and any Main work it does in onCreate, would already be running against the real looper. Instead, have the Main rule create the scheduler and hand it to Compose. When effectContext contains a TestDispatcher, Compose uses that dispatcher for composition and for mainClock, so both sides tick off one clock.
import androidx.compose.ui.test.ComposeUiTestConfig import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule class MainDispatcherRule( val dispatcher: TestDispatcher = StandardTestDispatcher(TestCoroutineScheduler()) ) : TestWatcher() { override fun starting(description: Description) = Dispatchers.setMain(dispatcher) override fun finished(description: Description) = Dispatchers.resetMain() } class OrdersScreenTest { @get:Rule(order = 0) val mainRule = MainDispatcherRule() // Main replaced before anything launches @get:Rule(order 


