DEV Community

Android Expert
Android Expert

Posted on Originally published at Medium

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

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

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 AndroidComposeUiTestEnvironment directly (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.

v1-vs-v2-timeline

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.

standard-dispatcher-queue

There are three ideas to keep in mind:

  1. Queued, not dropped. Your coroutine will run. It just won’t run now.
  2. Virtual time is shared state. delay(300) doesn't sleep. It schedules a resumption at currentTime + 300, which happens only when someone moves the clock that far.
  3. 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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

hidden-race-condition

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
}
Enter fullscreen mode Exit fullscreen mode

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.

failure-catalog

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 })
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 a Channel(BUFFERED) exposed via receiveAsFlow(), 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 from init, 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) }
    )
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 = 1)
    val composeRule = createAndroidComposeRule<OrdersActivity>(
        config = ComposeUiTestConfig(effectContext = mainRule.dispatcher)  // same scheduler → one clock
    )

    @Test
    fun showsOrders() {
        val vm = OrdersViewModel(FakeOrdersRepository(listOf(sampleOrder)))
        composeRule.setContent { OrdersScreen(vm) }

        composeRule.waitUntil(timeoutMillis = 2_000) { vm.state.value is OrdersState.Ready }
        composeRule.onNodeWithText(sampleOrder.title).assertIsDisplayed()
    }
}
Enter fullscreen mode Exit fullscreen mode

ComposeUiTestConfig lives in androidx.compose.ui.test and requires 1.13.0-alpha01 or later. On 1.11/1.12, pass the dispatcher through the effectContext parameter instead, which was deprecated in 1.13. Since ComposeUiTestConfig moved during the alpha cycle, keep the import explicit so an artifact bump fails loudly at compile time and not somewhere confusing.

Three details matter here:

  • Rule order. order = 0 guarantees Main is swapped before the compose rule launches the activity. @Before can't guarantee that.
  • Construction order. viewModelScope captures the dispatcher when the ViewModel is created. If you build the ViewModel in a field initializer, it can bind to the wrong Main. Create it inside the test, or in a @Before, which now runs safely after the Main swap.
  • Inject dispatchers into repositories. A repository that hard-codes Dispatchers.IO runs on real threads, outside any virtual clock. Pass a CoroutineDispatcher in through the constructor and give it a StandardTestDispatcher(composeRule.mainClock.scheduler) in tests. Then delay() in fakes is virtual, and waitForIdle sees all of it.

5. The tools: which clock control to reach for

clock-control-picker

waitForIdle() / runOnIdle { }. The default choice. It advances the clock and recomposes until Compose reports idle, meaning no pending recompositions, measure or layout passes, or finite animations. Finders and assertions on the semantics tree already do this implicitly, which is why most tests survive migration. Call it explicitly only before non-UI assertions, or use runOnIdle, which also moves your block onto the UI thread.

composeRule.onNodeWithText("Retry").performClick()
composeRule.runOnIdle { verify(exactly = 1) { repo.refresh() } }
Enter fullscreen mode Exit fullscreen mode

mainClock.advanceTimeBy(ms). Moves virtual time forward by a precise amount and runs whatever falls due, including frames. Use it to watch an intermediate state such as a debounce window, a toast timeout or mid-animation. Combine it with mainClock.autoAdvance = false so that intervening syncs don't skip past the moment you care about. Remember that frames are about 16 ms apart: to land after a 1,000 ms delay, advance 1_000 + 16.

composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithTag("search").performTextInput("ko")
composeRule.mainClock.advanceTimeBy(299)
composeRule.runOnIdle { assertThat(repo.queries).isEmpty() }          // still debouncing
composeRule.mainClock.advanceTimeBy(1 + 16)
composeRule.runOnIdle { assertThat(repo.queries).containsExactly("ko") }
Enter fullscreen mode Exit fullscreen mode

mainClock.scheduler.runCurrent(). Runs every task that's due right now and doesn't move time. This is the tool for "I just launched a collector or effect and need it to reach its first suspension before I do the next thing" (see 4.2). It also lets you see intermediate states that waitForIdle would rush past.

advanceUntilIdle(). A TestCoroutineScheduler/TestScope function that drains the entire queue and jumps virtual time as far as it has to. It's a good fit for pure coroutine tests with no Compose frames. Be careful with it in UI tests: an infinite transition, a polling loop or a while (isActive) { delay(…) } ticker keeps adding work, so "until idle" may never arrive. Prefer waitForIdle (which handles infinite animations) or a bounded advanceTimeBy.

runComposeUiTest { } instead of createComposeRule() + runTest. If you combine the rule with runTest, you get two schedulers. advanceTimeBy inside runTest doesn't move the Compose clock. runComposeUiTest runs your block inside its own runTest, with one clock synchronized to Compose. For new suspend-heavy tests, use it.

@Test
fun bannerDismissesAfterFiveSeconds() = runComposeUiTest {
    setContent { PromoBanner(autoDismissAfter = 5.seconds) }
    onNodeWithTag("promo").assertIsDisplayed()

    mainClock.advanceTimeBy(5_000 + 16)
    onNodeWithTag("promo").assertDoesNotExist()
}
Enter fullscreen mode Exit fullscreen mode

On 1.13.0-alpha01 and later, environment knobs (effectContext, runTestContext, testTimeout, inputMode) move into a single ComposeUiTestConfig. The older overloads that take them individually are deprecated:

import androidx.compose.ui.input.InputMode
import androidx.compose.ui.test.ComposeUiTestConfig
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlin.time.Duration.Companion.seconds

@get:Rule
val composeRule = createComposeRule(
    config = ComposeUiTestConfig(testTimeout = 30.seconds, inputMode = InputMode.Keyboard)
)
Enter fullscreen mode Exit fullscreen mode

6. Migration checklist for a large test suite

migration-roadmap

Phase 1: baseline. Run the entire UI suite three times on v1, on the same CI agents you’ll use later. Quarantine anything that’s already flaky. Otherwise you’ll blame v2 for failures you had all along.

Phase 2: inventory. Find out what you’re dealing with before changing anything.

# v1 entry points (excluding already-migrated .v2 imports)
rg -l 'import androidx\.compose\.ui\.test\.(junit4\.)?(create(Android|Empty)?ComposeRule|run(Android|Empty)?ComposeUiTest|AndroidComposeUiTestEnvironment|junit4\.AndroidComposeTestRule)$' --type kotlin

# The two-clocks smell: rule + runTest in the same file
rg -l 'createComposeRule' --type kotlin | xargs rg -l 'runTest\s*\{'

# Custom environments: these flip even WITHOUT an import change
rg -n 'AndroidComposeUiTestEnvironment' --type kotlin

# Thread.sleep in UI tests is always a smell; under v2 it does nothing for virtual time
rg -n 'Thread\.sleep' --type kotlin src/*Test*
Enter fullscreen mode Exit fullscreen mode

Phase 3: swap imports one module at a time. It’s a mechanical find-and-replace into the .v2 packages (androidx.compose.ui.test.junit4.v2.* and androidx.compose.ui.test.v2.*). Open one PR per Gradle module so each red build has a small, bisectable diff.

Phase 4: triage. Sort every failure into one of the four patterns from section 4. In practice the large majority fall under 4.1 (non-UI assertion with no sync) and 4.4 (separate schedulers). Track anything that fits none of them separately. Those are your real bugs, like the search race in section 3.

Phase 5: fix synchronization, not symptoms.

  • Non-UI assertions → runOnIdle { }.
  • Shared scheduler for Main, injected IO dispatchers, and test collector scopes.
  • createComposeRule + runTest → runComposeUiTest.
  • Thread.sleep/SystemClock.sleep → mainClock.advanceTimeBy.
  • Tests that set non-touch input modes through instrumentation → ComposeUiTestConfig(inputMode = …).
  • Production bugs → fix the production code, not the test.

Phase 6: lock it in. Make regression impossible:

// build.gradle.kts (test source sets)
kotlin {
    compilerOptions { allWarningsAsErrors.set(true) } // deprecated v1 imports now fail the build
}
Enter fullscreen mode Exit fullscreen mode
# detekt.yml: belt and braces for modules that can't enable warnings-as-errors yet
style:
  ForbiddenImport:
    active: true
    imports:
      - value: 'androidx.compose.ui.test.junit4.createComposeRule'
        reason: 'Use androidx.compose.ui.test.junit4.v2.createComposeRule'
      - value: 'androidx.compose.ui.test.runComposeUiTest'
        reason: 'Use androidx.compose.ui.test.v2.runComposeUiTest'
Enter fullscreen mode Exit fullscreen mode

Also add one paragraph to your testing guidelines: “Coroutines are queued. Assert on non-UI state inside runOnIdle. Share one scheduler." That one line prevents most of the next year's flaky tests.


7. When (rarely) to opt back temporarily, and the risks

opt-back-risks

The documented way back is narrow: keep using the deprecated v1 entry points, which still run on UnconfinedTestDispatcher. There's no official "v2 but unconfined" switch. You can pass an unconfined dispatcher as the effectContext, but the migration guide doesn't endorse that, so treat it as unsupported and verify its behavior on your version before you rely on it.

If you do need a loan, make it visible and give it an expiry date:

/**
 * TEMPORARY v1 opt-out. Owner: @payments-android. Ticket: AND-4821. Remove by 2027-01-31.
 * Reason: golden screenshots baselined on v1 frame timing; re-record pending.
 */
@Suppress("DEPRECATION")
class CheckoutScreenshotTest {
    @get:Rule
    val composeRule = androidx.compose.ui.test.junit4.createComposeRule()
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Reasons that hold up in code review:

  • A release branch that ships this week. Revert the import swap on the branch and keep v2 on main.
  • A third-party or in-house harness that subclasses AndroidComposeUiTestEnvironment and can't be updated yet.
  • Screenshot suites whose goldens encode v1 frame timing and need re-recording.
  • Bisecting a regression that straddles the migration commit.

What you’re signing up for:

  • Invisible races. Every day on v1 is another day the stale-result class of bug ships untested.
  • Removal risk. Deprecated APIs in androidx eventually go away. The longer the opt-out lasts, the bigger the eventual forced migration.
  • Two mental models. Engineers who move between modules have to remember which scheduler each file uses. That’s where new flaky tests come from.
  • Tests that pass while encoding the wrong order. A green v1 test can actively assert behavior your production code doesn’t have.

The goal is not to make v2 go green as fast as possible. A red v2 test is the scheduler telling you something your v1 suite couldn’t. Usually it’s a missing sync point in the test. Sometimes it’s a real bug, and catching those is the reason for the switch.


Sources


🔐 Master Android Architecture: Volume 1, Foundations and Core Architecture

Most Android architecture advice is a diagram and a list of rules. This book teaches the judgment behind them. You build NowReading from an empty project into an offline-first app, one decision at a time. It covers the principles behind Google’s layered architecture, then every layer in depth: Compose and state holders, MVI, navigation, the domain layer, Room and DataStore, networking, offline-first sync, Hilt, coroutines and Flow, background work and error handling. Every chapter opens with a realistic failure and ends with pitfalls, trade-offs, review questions and an exercise.

Top comments (0)