← all articles

Why Android UI tests are flaky, and the usual fixes

Android ships with all three animation scales at 1.0, so a tap in your test can land while a screen is still sliding into place. That is the first of seven causes below.

A flaky test goes green on your laptop, red on the CI emulator, green on the rerun, and nobody touched anything. This article covers why that happens and the fix I reach for first for each cause. Scope, as of September 2026: Android 13 to 16 (API 33 to 36), Espresso 3.x, UI Automator 2.x, and the UiAutomator2 driver that Appium uses.

What it is

Google’s testing blog defines a flaky test as one that shows both a passing and a failing result with the same code, in Flaky Tests at Google and How We Mitigate Them.

Espresso and Compose tests run inside your app’s process. UI Automator runs outside it and talks to the system through the accessibility layer. Appium sits further out and sends commands to a server on the device. All three share one problem: the test has to guess when the app is ready, and sometimes it guesses wrong. I compared the first two in Espresso vs UI Automator.

How it works

The test says “tap Login, then check for Home”. The app is inflating views, running a transition, waiting on a server. The test passes only when the app happens to be ready by the time the test looks.

Espresso tries to close that gap for you. Before each action it waits for the main thread’s message queue and the AsyncTask thread pool to go quiet, plus any registered idling resource. It cannot see your OkHttp dispatcher, coroutine dispatchers or RxJava schedulers unless you register them. UI Automator and Appium work from outside instead. They poll the accessibility tree with timeouts, see the whole screen including system dialogs, and know nothing about what your app is doing.

Slow the machine down, boot a cold emulator, or pack four jobs onto one host, and the timing shifts.

The other half is state. A test assumes a logged-out app or an empty cart, and the previous run left something behind. Almost every cause below is a timing race or leftover state. Pop-ups and keyboards are the two where something else takes over the screen.

Animations

A click can hit a view that is mid-slide, or an assertion can run against the outgoing screen. The Espresso setup docs tell you to switch all three scales off on the test device:

adb shell settings put global window_animation_scale 0.0
adb shell settings put global transition_animation_scale 0.0
adb shell settings put global animator_duration_scale 0.0

Recent Android Gradle Plugin versions also have testOptions { animationsDisabled = true } for tests Gradle launches. Put the scales back to 1.0 on any phone a human also uses. An indeterminate progress bar that spins forever can keep Espresso waiting, so use a static drawable in debug builds.

Async work the test can’t see

Your app fetches a list on a background thread Espresso doesn’t know about, so the assertion runs against an empty screen. Register a CountingIdlingResource, increment it when work starts, decrement it when work ends, and Espresso waits for zero. From outside the app, wait for a condition instead of a duration. In UI Automator that is device.wait(Until.hasObject(By.text("Welcome")), 5000). In Appium it is an explicit wait on the element. Compose tests have composeTestRule.waitUntil.

System pop-ups

Permission dialogs, the notification prompt that arrived with Android 13 (API 33, POST_NOTIFICATIONS), crash and “not responding” dialogs. All are drawn by another process, so Espresso can’t touch them, and your app’s window loses focus while one is up.

Grant permissions before the test starts:

adb install -g app-debug.apk
adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS

-g grants every runtime permission the app declares at install. pm grant does one at a time. AndroidX Test’s GrantPermissionRule does the same inside a test, and Appium has the appium:autoGrantPermissions capability. For prompts you can’t pre-grant, use UI Automator to find and dismiss the dialog. More pm and am commands are in the pm and am guide. Phone makers add prompts of their own, so expect surprises on real hardware.

The soft keyboard

Type into a field and the keyboard rises over the Submit button. The next tap lands on the keyboard, or the screen scrolls and the target moves. In Espresso, call closeSoftKeyboard() after typeText(), or use replaceText() when typing itself isn’t under test. Appium’s UiAutomator2 driver has a hide-keyboard command too.

AVDs are commonly set up with hardware keyboard input on (hw.keyboard=yes in the AVD’s config.ini), which tends to keep the on-screen keyboard hidden until you move to a real phone. If you type through adb shell input text, spaces have to be written as %s, and the adb input guide covers the other quirks.

Network

Tests that call a live backend inherit its latency and outages. Make them hermetic. Run OkHttp’s MockWebServer or WireMock on your machine and point the test build at it. An emulator reaches the host’s loopback at 10.0.2.2. A real phone can use adb’s reverse port forwarding:

adb reverse tcp:8080 tcp:8080

Now port 8080 on the phone reaches port 8080 on your computer. Keep a few true end-to-end tests against the real backend, run separately, with explicit waits and generous timeouts.

Device sleep and lock screens

A real phone’s screen times out mid-suite, the keyguard appears, and the next tap goes nowhere. Keep it awake and wake it before a run:

adb shell svc power stayon true
adb shell input keyevent KEYCODE_WAKEUP
adb shell wm dismiss-keyguard

stayon accepts true, false, usb, ac or wireless, and only holds while the phone is charging. dismiss-keyguard won’t get past a PIN, so set the lock screen to none on a dedicated test phone you own. Wireless adb sessions can also drop when the phone sleeps (more in adb over Wi-Fi).

Shared devices and leftover state

Two jobs on one phone, or one test polluting the next with a logged-in session, database rows or preferences. Give each job its own device, lock it in CI, and reset app data between tests. Android Test Orchestrator runs every test in its own instrumentation run and can clear app data in between. The AndroidJUnitRunner docs cover it. In Gradle (Groovy DSL):

android {
    defaultConfig {
        testInstrumentationRunnerArguments clearPackageData: 'true'
    }
    testOptions {
        execution 'ANDROIDX_TEST_ORCHESTRATOR'
    }
}
dependencies {
    androidTestUtil 'androidx.test:orchestrator:1.5.1'
}

Check for a newer orchestrator than 1.5.1. By hand, adb shell pm clear com.example.app wipes an app’s data. Appium resets app state at session start unless you set appium:noReset.

If the shared thing is the phones themselves and you don’t own spares, cloudf.one rents real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP, controlled from the browser. There is no self-service adb: adb and Appium reach a rented phone only through a WireGuard tunnel that we set up on request (email [email protected]). So I’d use it to reproduce a device-specific failure by hand, not as a self-serve CI farm. Only automate apps and devices you own or are authorised to test.

Why it matters

  • People stop trusting red. Once a suite fails for no reason, developers rerun instead of reading the failure, and a real regression gets the same shrug.
  • Real bugs hide in the noise. A race that only shows under load can be a genuine app bug, like a double tap handled twice. Check the failing run’s logs before you blame the test, and the logcat guide shows how to filter by tag and priority.
  • Reruns spend CI minutes and device time, and I don’t have a figure for what that costs a typical team.
  • Matrices multiply it. If each device in a release-gating suite has a small chance of flaking, at least one of ten flaking is far likelier than one device flaking.

Common misconceptions

  • “A sleep fixes it.” A fixed sleep guesses at the slowest case. Too short and it flakes again on a slow host, too long and the suite crawls. I treat Thread.sleep() in a UI test as a code review comment every time.
  • “Espresso syncs automatically, so it can’t flake.” It syncs with the main thread, the AsyncTask pool and the idling resources you register. Everything else is on you.
  • “Retries are enough.” A retry hides the problem. As of September 2026, gcloud firebase test android run has a --num-flaky-test-attempts flag and it can keep a release moving, but log every test that needed one.
  • “Emulators are the flaky ones”, or the reverse. They fail differently. Emulators suffer from shared CI CPU and keyboard config, phones from sleep and vendor prompts. I don’t think either is flakier in general, and emulator vs real device testing goes through the trade-offs.

Where to go from here

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-19.

free download
The adb and scrcpy command cheat sheet

The commands we reach for on a rack of real phones, on one printable page. Leave your email and we will also tell you when we publish a new guide, a few times a month at most.

from the team behind cloudf.one
Passes on the emulator, fails on a real phone?

cloudf.one rents real Android phones in Singapore that you control from the browser, each on a persistent Singapore mobile IP. Real hardware, a real OEM build and a real carrier network, without buying and racking the phones yourself.

test on a real phone →
read on
More from ADB Handbook

adb commands, scrcpy flags, automation frameworks, and why a test passes on one phone and fails on the next.

browse all articles →