Why Your Appium Suite Goes Red Every Redesign
Your tests passed on Friday. Design shipped a new nav on Monday. Now forty tests are red, none of the underlying features are broken, and someone spends two days rewriting selectors.
This isn't flakiness — a flaky test fails randomly. These fail deterministically, every run, until a human edits them. It's a design problem in how the tests find things, and it has a fix list.
Triage first: selector break or real regression?
Separate the two before touching anything:
- Failed at
findElement, screenshot shows the right screen → selector problem. The app is fine; the test can't see it. - Failed on an assertion, or the screenshot shows the wrong screen → real regression.
If your suite doesn't capture a screenshot on failure, fix that first. Without it every failure looks identical and you re-triage the same forty tests by hand every release.
Capture the page source too, not just the screenshot
A screenshot tells you what the screen looked like. The page source tells you what locators existed at that moment — which is the thing you actually need to write the fix. Attach it to the report and most selector breaks become a one-pass fix with no re-run:
public class DumpOnFailure implements TestWatcher {
@Override
public void testFailed(ExtensionContext ctx, Throwable cause) {
AppiumDriver driver = DriverHolder.get();
Allure.addAttachment("page-source.xml", "text/xml", driver.getPageSource());
Allure.addAttachment("screen.png", new ByteArrayInputStream(
driver.getScreenshotAs(OutputType.BYTES)));
}
}Without it the loop is: read failure → re-run locally → reproduce → inspect → fix. With it the loop is: open report → read the tree → fix. On a suite that breaks forty tests per redesign, that difference is the whole afternoon.
On a failed flow replay here you get a stronger version of the same thing: the tree as recorded and the tree as it was live, plus a similarity score, so the diff points straight at what moved rather than leaving you to eyeball two XML dumps.
1. Stop hand-deriving locators
Most brittle selectors exist because someone read a UI dump and guessed. That guess is usually positional:
// A bet that nobody ever inserts a view above this one
driver.findElement(By.xpath("//android.widget.LinearLayout[2]/android.widget.Button[1]"));RobotActions inspects the element for you and returns a ranked list of strategies —
id, text, content-desc, accessibility selector, then XPath as the last resort. The
inspector in the UI shows that list, and the same ranking is available to an agent over MCP:
device_locators_for(udid, text: "Check out")
→ id: com.myapp:id/checkout_button
content-desc: checkout-button
text: Check out
xpath: //android.widget.Button[@resource-id='com.myapp:id/checkout_button']
You pick from the top of that list rather than deriving from the bottom. The ordering is the advice: anything below content-desc is a locator you'll be editing again.
"Element not found" often means "not scrolled to yet"
An Android-specific gotcha that sends people straight to bad XPath: the page source only
contains rendered nodes. An element below the fold is genuinely absent from the dump, so a
findElement fails and it looks like the locator is wrong. It isn't — the element hasn't been
laid out yet.
Most suites answer this with a hand-rolled loop: swipe up, poll, swipe again, give up after N tries. That loop is itself a source of flakiness — it depends on swipe distance, list height and scroll physics, all of which change with the device and the redesign.
device_scroll_to_element does it as one step on Android and iOS: name the target, and the
device scrolls until it's on screen, then acts on it. Delete the loops.
Give the important elements a fallback chain
For the handful of elements every suite depends on — checkout, login, the primary CTA — don't bet on a single strategy. Try them in stability order and take the first that resolves:
private WebElement findFirst(By... strategies) {
for (By by : strategies) {
try { return driver.findElement(by); }
catch (NoSuchElementException ignored) { /* try the next strategy */ }
}
throw new NoSuchElementException("no strategy matched: " + Arrays.toString(strategies));
}
// Ordered exactly like the ranked list above
WebElement checkout = findFirst(
AppiumBy.accessibilityId("checkout-button"),
AppiumBy.id("com.myapp:id/checkout_button"),
AppiumBy.androidUIAutomator("new UiSelector().text(\"Check out\")"));Now a redesign that renames the resource ID falls through to the accessibility ID, and a screen that loses both still resolves by visible text. The test goes red only when the button is genuinely gone.
Two cautions. Log which strategy matched — silent fallback hides decay until every element is running on its last resort. And put text last, because it's the one that breaks under translation.
2. What to ask the app team for
Locators can only be as stable as what the app exposes. This is the part QA can't do alone, and it's a small ask:
Unique, stable resource IDs. Not button1 in nine screens. Duplicated IDs force positional
XPath back into your suite, which is exactly the thing you're removing.
Accessibility IDs on every interactive element. One locator then works on both platforms —
accessibility_id maps to contentDescription on Android and accessibilityIdentifier on iOS:
// Jetpack Compose
Button(onClick = ::checkout,
modifier = Modifier.semantics { contentDescription = "checkout-button" }
) { Text("Check out") }// SwiftUI
Button("Check out") { checkout() }.accessibilityIdentifier("checkout-button")// React Native — testID maps to both platforms
<Pressable testID="checkout-button" onPress={checkout}><Text>Check out</Text></Pressable>A naming convention, agreed before you have three hundred of them. Name the role, not the
design: checkout-button, never green-cta-v2. A design-derived name is guaranteed to be wrong
after the next redesign.
Treat those strings as API surface. Renaming one is a breaking change and goes through review like any other. Without that rule they get "tidied up" and you're back where you started.
The side benefit is not small: a screen you can't locate by accessibility ID is usually a screen that's hard to use with a screen reader.
3. Found is not the same as hittable
A large class of "the test clicked it but nothing happened" comes from elements that exist in the tree but cannot actually be tapped:
- bounds partially or wholly outside the viewport
- a zero-size or collapsed node
- something drawn on top — a sticky footer, a toast, a bottom sheet
- a button-class element that exposes no click action at all
findElement succeeds in every one of those cases. The tap "succeeds" too. The failure surfaces
three steps later as a missing screen, and you go looking in the wrong place.
Scope your query to what's actually on screen — device_elements_in_region returns the elements
within a region rather than the whole tree — and check the bounds before acting rather than
after.
There's a shortcut most teams miss. Run the accessibility audit on the screen and read it as a flakiness report, because these checks are the same defects:
| Audit finding | Why your test is flaky |
|---|---|
DuplicateClickableBounds | Several tappable elements share identical bounds — your locator can match the wrong one, non-deterministically |
TouchTargetSize | Tap target below the recommended minimum, so a tap at the centroid can land beside it |
MissingClickAction | Button-class element exposing no click action — found, tapped, does nothing |
SpeakableTextPresent | Actionable control with no text or content description — nothing stable to locate it by, which is why someone reached for XPath |
That last row is the loop this whole article is about: a control with no accessible name forces a positional locator, and a positional locator breaks on the next redesign. Fixing the audit finding fixes the test and the screen reader experience in one change.
4. One script, both platforms
Maintaining parallel iOS and Android suites doubles the redesign cost for no benefit. Two things make a single script practical:
Use accessibility IDs as the primary strategy. accessibility_id maps to
contentDescription on Android and accessibilityIdentifier on iOS, so the same line resolves
on both — provided the app team uses the same string on both platforms. Agree that in the
naming convention, or you get two conventions and no reuse.
Branch only where the platforms genuinely differ, and keep the branch tiny:
private static final boolean IOS =
Platform.fromCapabilities(driver.getCapabilities()) == Platform.IOS;
// Same intent, different system affordance — back navigation
if (IOS) driver.navigate().back(); // swipe / nav bar
else ((AndroidDriver) driver).pressKey(new KeyEvent(AndroidKey.BACK));Keep those branches to system chrome — back, permissions, keyboard dismissal, share sheets. If you find yourself branching on business flow, that's a product divergence worth raising rather than a testing problem to paper over.
The device-driving verbs here are deliberately symmetric — find, wait, scroll-to, tap-by-label exist on both platforms with the same semantics — so the parts of the script that aren't system chrome stay identical.
5. Replace sleeps with waits that describe a condition
Thread.sleep(3000) is a bet that the device is as fast today as when you wrote it. It fails on
a cold start, a slow network, or a busy CI runner — and it wastes three seconds on every run
where the app was ready immediately.
// Explicit wait — fails fast, passes fast
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(checkoutButton));
// Fluent wait — poll interval and ignored exceptions under your control
new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(15))
.pollingEvery(Duration.ofMillis(250))
.ignoring(NoSuchElementException.class, StaleElementReferenceException.class)
.until(d -> d.findElement(checkout).isDisplayed());Ignoring StaleElementReferenceException matters more than people expect: during an animation
the element can be found, then replaced mid-poll. Without it you get a failure that looks like a
missing element and isn't.
The same condition is available as a first-class step when driving a device here —
device_wait_for_element waits out the animation rather than guessing at its duration.
6. Remove the interruptions
A large share of "random" mobile failures are the OS interrupting: a permission sheet, a system update banner, a rating prompt. Handle them structurally instead of adding a try/catch per screen. RobotActions' browser-driven sessions set these on the underlying session:
{ "appium:autoAcceptAlerts": true, "appium:autoDismissAlerts": true }Choose deliberately — autoAccept grants permissions, which is usually what you want for a
happy-path suite, but it also means you are no longer testing the deny path. Keep at least
one test with both off that exercises permission-denied behaviour.
7. Record the flow, export the page object
The most reliable way to stop hand-writing locators is to not write them. Walk the flow once on
a real device and export it: you get a Page Object with @FindBy annotations already filled from
the ranked locators, a WebDriverWait, and waitAndClick / waitAndSendKeys helpers — waits
wired in from the start rather than retrofitted after the first flaky run.
That's the difference worth measuring. Most device clouds give you a remote screen and leave locator strategy, waits and interruption handling as your problem. Here the inspector, the locator ranking, the recorder and the exported code are the same system, so the selectors in your suite are the ones the platform already judged most stable.
8. When there is no stable handle
Sometimes there's nothing to hold: a third-party checkout view, a WebView you don't control, a screen where adding IDs isn't on anyone's roadmap. Then describe intent instead of structure:
Open the app, add a product to the cart, and complete checkout with the saved card.
An AI agent takes that user story, drives a real Android or iOS device, and finds elements
by what's on screen rather than by an internal identifier. A renamed ID or a new wrapper view
doesn't change what the screen looks like, so the run keeps working — and when it genuinely
can't proceed you get a screenshot and a trace of where it stopped, not a
NoSuchElementException pointing at a selector.
Nothing needs rewriting to try it. Point your existing Appium suite at the grid, keep every test you've written, and let the agent take the handful of flows that break every redesign.
The checklist
- Screenshots and page source on failure — everything else depends on being able to triage without a re-run.
grep -rn "XPath.*\[[0-9]" tests/— that list is your backlog, ordered by edit frequency.- Convert the top ten using the ranked locator list, top-down.
- One PR to the app for the missing accessibility IDs, plus the naming convention.
- Delete every
Thread.sleepand replace with an explicit or fluent wait. - Turn on alert auto-handling, and keep one test that deliberately doesn't.
- Give your five most-depended-on elements a fallback chain, and log which strategy matched.
- Delete hand-rolled scroll-until-visible loops in favour of a single scroll-to-element step.
- Run the accessibility audit on your three flakiest screens and treat
DuplicateClickableBoundsandSpeakableTextPresentas test bugs, not a11y nice-to-haves.
Ready to test on real devices?
Sign in with Google or GitHub and get real iOS and Android devices in your browser — free to try.