
Product news and testing tips.
Espresso tests flake when work happens outside what Espresso's AtomicUiController can see — untracked network calls, coroutines, custom animations, and RecyclerView/ViewPager2 diffing all let a test proceed before the UI actually settles. Fix it by registering that work as an IdlingResource, disabling animations in CI, and validating beyond one emulator profile — not Thread.sleep(), which Google's own docs warn "might still fail sometimes when executed on slower devices."
Key takeaways
- Espresso only synchronizes automatically with work routed through AtomicUiController and the main thread's message queue — network callbacks, coroutines, custom animators, and RecyclerView diffing are all invisible to it by default.
- IdlingResource closes that gap by registering async work so loopMainThreadUntilIdle() waits on it — Google's own docs warn Thread.sleep() 'might still fail sometimes when executed on slower devices.'
- Google's official fix for animation-driven flakiness is disabling Window/Transition/Animator animation scale; in CI that's adb shell settings put global *_scale 0, since there's no Developer-options UI to click.
- AndroidJUnitRunner shards natively via -e numShards / -e shardIndex, but produces no built-in merge step for the resulting JUnit-XML files.
- RecyclerView and ViewPager2 are a disproportionate flake source because DiffUtil calculates diffs on a background thread and the item animator finishes asynchronously to whatever Espresso currently sees.
- Bitrise's analysis of 10M+ mobile builds found the share of teams hitting flakiness rose from 10% in 2022 to 26% in 2025; teams using observability tooling saw about 25% fewer flaky reruns.
Espresso tests flake when something happens outside what Espresso’s synchronization can actually see. Its AtomicUiController calls UiController.loopMainThreadUntilIdle() before every action and assertion, draining the main thread’s message queue and waiting for the app to go idle — but that guarantee only covers work routed through the tracked queue. Network callbacks, coroutines running on a background dispatcher, custom animators, and RecyclerView’s asynchronous diffing all sit outside it, so a test can tap a button, scroll a list, or assert on text while real work the developer wrote is still in flight. The fix is to register that work as an IdlingResource, disable animations in CI, and validate beyond a single emulator profile — not Thread.sleep(), which Google’s own documentation warns “might still fail sometimes when executed on slower devices.”
This is the Espresso-specific companion to Flaky Mobile Tests: Why Android & iOS Tests Fail Randomly, which covers device fragmentation, permission dialogs, and causes shared across Espresso, XCUITest, and Appium — this post goes deep on the mechanism unique to Espresso: its synchronization model, and exactly where it breaks. For CI setup and getting Espresso’s native JUnit-XML into a results pipeline, see Espresso test reporting; this post is about diagnosing why a specific test is flaky in the first place, using the same evidence-first approach as root-causing a flaky test. It’s one spoke in the complete guide to mobile testing.
Why does Espresso’s synchronization model break down?
Espresso’s whole pitch is that you shouldn’t need manual waits — AtomicUiController synchronizes automatically with the main thread’s Looper and any work Espresso is explicitly told about (an AsyncTask, a tracked IdlingResource). Before Espresso performs the next action, it loops the main thread until every tracked source reports idle. That’s a real synchronization guarantee, not a polling hack, and it’s why Espresso doesn’t need the manual sleep()/retry loops other frameworks lean on by default.
The guarantee has a hard edge, though: Espresso only knows about work it’s told about. Four categories routinely fall outside it:
- Network calls — a Retrofit/OkHttp callback returning on its own executor doesn’t touch Espresso’s tracked queue until the app code that handles it happens to post back to the main thread, and even then Espresso has no way to know the request is still outstanding.
- Coroutines — a
viewModelScope.launchblock dispatched toDispatchers.IOruns entirely outside Espresso’s view until it switches back toDispatchers.Main, and even that switch isn’t automatically tracked. - Custom animations —
ValueAnimator, Lottie animations, and hand-rolledChoreographercallbacks aren’t part of the system animation scale and keep running after Espresso considers the UI idle. - RecyclerView diffing —
DiffUtil.calculateDiff()runs on a background thread by default, and the resulting adapter update and item-animator playback both happen asynchronously to whatever Espresso currently sees.
Every fix below is a variation on the same move: telling Espresso about work it can’t see on its own.
What is an IdlingResource, and how do you implement one?
An IdlingResource is an object that represents an in-flight asynchronous operation, registered so Espresso’s loopMainThreadUntilIdle() waits on it in addition to the main thread’s message queue. Google’s testing documentation notes that when an app performs work Espresso can’t see, “Espresso can’t provide its synchronization guarantees in those situations” — an IdlingResource closes exactly that gap.
The interface is three methods: getName() for debugging output, isIdleNow() for Espresso to poll the current state, and registerIdleTransitionCallback() for Espresso to hand you a callback to invoke once, when the resource becomes idle. Google’s docs are explicit about one rule: never call the transition callback from inside isIdleNow() — only call it from wherever the async work actually finishes.
Here’s a custom IdlingResource wrapping a network call, following that rule:
class NetworkCallIdlingResource(
private val resourceName: String,
) : IdlingResource {
@Volatile private var callback: IdlingResource.ResourceCallback? = null
private val idle = AtomicBoolean(true)
override fun getName(): String = resourceName
override fun isIdleNow(): Boolean = idle.get()
override fun registerIdleTransitionCallback(
resourceCallback: IdlingResource.ResourceCallback,
) {
callback = resourceCallback
}
fun setBusy() {
idle.set(false)
}
fun setIdle() {
idle.set(true)
callback?.onTransitionToIdle() // called from the call site, not isIdleNow()
}
}
class UserRepository(private val api: ApiService) {
val idlingResource = NetworkCallIdlingResource("UserRepository")
fun fetchUser(id: String, onResult: (User) -> Unit) {
idlingResource.setBusy()
api.getUser(id).enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
onResult(response.body()!!)
idlingResource.setIdle()
}
override fun onFailure(call: Call<User>, t: Throwable) {
idlingResource.setIdle()
}
})
}
}
Register and unregister it around the test, not globally — a stale registration leaks into unrelated tests and causes its own flakiness:
@Before
fun registerIdlingResource() {
IdlingRegistry.getInstance().register(repository.idlingResource)
}
@After
fun unregisterIdlingResource() {
IdlingRegistry.getInstance().unregister(repository.idlingResource)
}
For the common case — counting how many async operations are outstanding rather than tracking one boolean — AndroidX Test ships CountingIdlingResource, which needs no custom class at all:
val idlingResource = CountingIdlingResource("NetworkCalls")
fun fetchUser(id: String, onResult: (User) -> Unit) {
idlingResource.increment()
api.getUser(id).enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
onResult(response.body()!!)
idlingResource.decrement()
}
override fun onFailure(call: Call<User>, t: Throwable) {
idlingResource.decrement()
}
})
}
Two other prebuilt implementations cover specific cases: UriIdlingResource, which requires the counter to stay at zero for a set quiet period before declaring idle — useful when one request’s completion immediately triggers another — and IdlingThreadPoolExecutor, a drop-in ThreadPoolExecutor replacement that tracks its own running-task count. Register whichever one before the test needs it — Espresso only starts honoring a resource after its first isIdleNow() poll, so late registration doesn’t retroactively cover work that already started.
Why doesn’t Thread.sleep() fix Espresso flakiness?
Because it doesn’t make the wait correct, it just makes it longer. Google’s own guidance names this directly: adding Thread.sleep() calls “takes longer for your test suite to finish executing, and your tests might still fail sometimes when executed on slower devices,” and the delays “don’t scale well, as your app might have to perform more time-consuming asynchronous work in a future release.” A CI runner under load is exactly the “slower device” that guidance warns about — a sleep tuned against a fast local emulator is a coin flip on a contended CI box. The same documentation is equally unenthusiastic about the other manual workarounds teams reach for: retry wrappers (“each re-execution consumes system resources, particularly the CPU”) and CountDownLatch (“require you to specify a timeout length… add unnecessary complexity”). An IdlingResource isn’t the safe option among several — it’s the one that scales with actual work instead of a duration guess.
How do you stop animations from causing Espresso flakiness?
An animating view exists in the accessibility tree before it’s finished moving, so an Espresso action that fires the instant a view appears can tap the wrong coordinates or assert against a mid-transition state. Google’s Espresso setup guide addresses this directly: “To avoid flakiness, we highly recommend that you turn off system animations on the virtual or physical devices used for testing,” specifically the three toggles under Settings > Developer options — Window animation scale, Transition animation scale, and Animator duration scale.
There’s no UI to click on a CI runner, so the equivalent most pipelines use is the adb shell settings command each toggle maps to:
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
Run these once after the emulator boots and before the test task starts — most CI emulator actions (GitHub’s reactivecircus/android-emulator-runner, for example) expose a hook for exactly this. It’s a blanket fix for system-driven transitions, but it doesn’t reach everything: a ValueAnimator or Lottie animation that a developer drives manually with Choreographer callbacks ignores the animator duration scale entirely, because it isn’t a system animation — that still needs its own IdlingResource keyed to the animation’s running state.
Emulator variance and sharding: where CI amplifies Espresso flakiness
Espresso tests validated against one emulator image inherit whatever gap exists between that image and the device population your app actually runs on — a broader mechanism covered in depth in why Android and iOS tests fail randomly. What’s specific to Espresso is how CI usually scales past one emulator: AndroidJUnitRunner supports native sharding through -e numShards and -e shardIndex instrumentation arguments, splitting the suite across parallel jobs instead of running it serially on one device:
# Split into 10 shards, run shard index 2
adb shell am instrument -w -e numShards 10 -e shardIndex 2 \
com.myapp.test/androidx.test.runner.AndroidJUnitRunner
Each shard produces its own JUnit-XML file — Espresso has no built-in merge step, so aggregating results across shards is left to whatever collects them afterward. Sharding also changes the flakiness profile itself: more parallel emulator instances on a fixed CI box mean less CPU and RAM per instance, which is exactly the condition that turns a marginal timing assumption into a visible flake. A suite that’s stable at four shards can start flaking the same week shard count goes to eight, with no change to the test code.
Why do RecyclerView and ViewPager2 tests flake more than static views?
List and pager UIs are Espresso’s most reliable flake source, and both trace back to work that happens off the main thread’s tracked queue. ListAdapter’s DiffUtil.calculateDiff() runs on a background thread by default, and the adapter update it produces — plus whatever RecyclerView.ItemAnimator plays for the change — both land asynchronously relative to whatever Espresso is currently watching. A test that scrolls to a position and immediately asserts can run while the item animator is still mid-flight, or before a diff-driven update has actually applied. ViewPager2 adds its own version of the same problem: a swipe or setCurrentItem() call triggers a settle animation, and interacting with the new page before that animation finishes hits the outgoing page’s stale layout.
The fix is the same pattern as everything else in this post — make the async state visible to Espresso instead of guessing at its duration. A small IdlingResource polling the RecyclerView’s own state covers most cases:
class RecyclerViewIdlingResource(
private val recyclerView: RecyclerView,
) : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = "RecyclerViewIdlingResource"
override fun isIdleNow(): Boolean {
val idle = recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE &&
recyclerView.itemAnimator?.isRunning != true
if (idle) callback?.onTransitionToIdle()
return idle
}
override fun registerIdleTransitionCallback(cb: IdlingResource.ResourceCallback) {
callback = cb
}
}
This is a case where calling the callback from isIdleNow() is correct — the state itself is what’s being polled, not a one-shot async event — but register it right before the scroll or page action, not for the whole test class, so Espresso isn’t silently waiting on animation state that isn’t relevant yet.
Espresso’s IdlingResource vs Compose’s testing synchronization
Apps mid-migration to Jetpack Compose run both models at once, and Google built Compose’s testing API as a deliberate parallel to Espresso’s, not a replacement with different rules. Compose tests synchronize by default through a MainTestClock that drives recomposition, animations, and gestures, so composeTestRule.onNodeWithText(...).performClick() already waits for the UI tree to settle before acting — no manual waitForIdle() needed for anything Compose itself controls. Google’s own docs describe the parallel directly: Compose’s synchronization “is very similar to Espresso’s Idling Resources to indicate whether the subject under test is idle or busy,” and for exactly the same category of work Espresso can’t see — a network call, a coroutine on a background dispatcher — you register an IdlingResource on the ComposeTestRule with registerIdlingResource(), the same object type Espresso registers through IdlingRegistry. For work outside Compose’s own tree — an Android view interop boundary, a data load — composeTestRule.waitUntil(timeoutMs) { condition } fills the same role a fixed sleep would elsewhere, without becoming one.
Espresso flaky-test root causes at a glance
| Root cause | Why it flakes | Fix |
|---|---|---|
| Untracked background thread / coroutine | Outside AtomicUiController’s view; Espresso proceeds before the work finishes | Register an IdlingResource (or CountingIdlingResource) around it |
| Network calls | Same visibility gap, plus run-to-run latency variance | IdlingResource on the call, or stub the network layer in test builds |
| System/window animations | View exists in the tree before reaching its final state | Disable Window/Transition/Animator animation scale (adb shell settings put global *_scale 0) |
| Custom animators, Lottie, infinite loops | Don’t quiesce even with system animations off | A dedicated IdlingResource keyed to the animation’s running state |
| RecyclerView diffing | DiffUtil runs on a background thread; item animator finishes asynchronously | Poll itemAnimator?.isRunning and SCROLL_STATE_IDLE via an IdlingResource |
| ViewPager2 page transitions | Settle animation outlasts the default action | Same animation-scale fix, or an idling check on the incoming page settling |
| Emulator-only validation | One CI emulator profile ≠ the real device population | Validate critical flows on real devices before release |
| Sharded runs under CPU contention | Parallel emulators starve each other of CPU/RAM | Match shard count to available cores; enable hardware acceleration |
How does Qualflare help with flaky Espresso tests?
Qualflare doesn’t run Espresso tests, provision emulators, or touch a device — it’s a results and observability layer that ingests what your CI already produces. ./gradlew connectedAndroidTest writes JUnit-XML automatically through AndroidJUnitRunner, with no reporter to configure and no conversion step, unlike XCTest’s .xcresult bundles. Point the CLI at the file it writes (qf myapp collect app/build/outputs/androidTest-results/connected/*.xml --format espresso) and every test’s pass/fail outcome is tracked across runs from there — see Espresso test reporting for the full CI setup.
Standard JUnit-XML carries no flaky-status field, so Qualflare scores flakiness the same way for Espresso as for every other framework it supports: by watching each test’s pass/fail pattern across historical launches, not by trusting any single red run. What it explicitly doesn’t do is fix any row in the table above — registering an IdlingResource, disabling animation scale, or polling a RecyclerView’s animator state is a test-authoring change only a developer can make. What Qualflare adds is the evidence that change needs: which Espresso tests are actually flaky, how often, and whether several failures cluster around one shared root cause, so the fix starts from data instead of a hunch.
Start free with Qualflare — upload your Espresso JUnit-XML and see which tests are actually flaky, not just red today.
Frequently asked questions
What is an IdlingResource in Espresso and when do I need one?
An IdlingResource is an object you register with Espresso to represent an in-flight asynchronous operation — a network call, a background thread, a custom animation — so Espresso’s synchronization waits for it to finish before the next test action. You need one whenever work happens outside Espresso’s default view of the main thread’s message queue; without it, Espresso can act while that work is still running.
Why doesn’t Thread.sleep() fix flaky Espresso tests?
Because a fixed sleep is a duration guess, not a synchronization guarantee. Google’s own Espresso documentation warns that added sleeps “might still fail sometimes when executed on slower devices” — exactly the profile of a loaded CI runner — and that the delays don’t scale as the app’s async work grows. An IdlingResource waits on the actual state of the work instead of a guessed duration.
How do I disable animations for Espresso tests in CI?
Google’s Espresso setup guide recommends turning off Window animation scale, Transition animation scale, and Animator duration scale under Developer options. On a CI emulator with no UI to click, the equivalent is adb shell settings put global window_animation_scale 0, transition_animation_scale 0, and animator_duration_scale 0, run once after boot and before the test task starts.
Why do RecyclerView and ViewPager2 tests flake more than other Espresso tests?
Because both involve work that runs asynchronously to whatever Espresso currently sees: DiffUtil.calculateDiff() runs on a background thread by default, and both RecyclerView’s item animator and ViewPager2’s page-settle animation finish independently of the action that triggered them. A test that scrolls or swipes and immediately asserts can run mid-animation. Polling the animator’s running state and scroll state through a small custom IdlingResource closes that gap.
How does sharding work with AndroidJUnitRunner?
AndroidJUnitRunner accepts -e numShards and -e shardIndex instrumentation arguments to split a suite across parallel jobs, each running a subset of tests on its own emulator. There’s no built-in merge step for the resulting JUnit-XML files — each shard’s results are collected separately.
Does Qualflare fix flaky Espresso tests automatically?
No. Root causes like missing IdlingResource registration, animation timing, and RecyclerView diffing are test-authoring fixes a platform can’t make for you. Qualflare ingests Espresso’s native JUnit-XML and tracks each test’s pass/fail history across runs, so you know which tests are actually flaky, how often, and whether they cluster around a shared cause — the evidence a fix needs, not the fix itself.


