All posts

guides · · 7 min read · Filip Gajić

Why your mobile XPath breaks: a practical ranking of locator strategies for Android, iOS and Flutter

A ranked guide to mobile locators (accessibility ID, resource-id, text, iOS predicate and class chain, XPath, Flutter keys) with before/after examples.

A lot of what gets filed as "flaky mobile test" is a locator that was never stable in the first place. The test passed for three weeks, a developer wrapped a view in a FrameLayout, and an absolute XPath that encoded the entire hierarchy fell over. Nobody touched the test. It still broke.

This is the order I pick locators in, why, and what each one looks like when it fails. Android and iOS first, then a short Flutter section, then the part that actually fixes the problem long-term: getting IDs added where the feature is written.

The rule underneath all of it

Pick the attribute a developer set on purpose. Avoid the one the render tree happened to produce.

An accessibilityIdentifier, an android:id, a testID, a Flutter Key: someone typed those. They change when a human decides to change them. Position in a hierarchy, sibling index, the class name of a wrapper: those change whenever the layout is refactored, a design system component is swapped, or an OS update renders a control differently. Every tier below is a version of that rule.

1. Accessibility ID

It's the same strategy on both platforms and both drivers index it, so lookups are fast. By convention it's unique per screen. On Android it's content-desc (set with contentDescription). On iOS it's accessibilityIdentifier. Appium calls both accessibility id.

# Python (Appium)
driver.find_element(AppiumBy.ACCESSIBILITY_ID, "checkout_button")
// Java
driver.findElement(AppiumBy.accessibilityId("checkout_button"));
// WebdriverIO
await $("~checkout_button");

It's also what screen-reader users rely on, at least on Android where content-desc is read aloud. That makes it the last thing a developer quietly deletes, and it means the automation team and the accessibility team want the same thing.

When it breaks: the ID was generated (button_3f9a), or the same ID was reused across a list, or on Android someone put a user-facing sentence in contentDescription for TalkBack and it now changes per locale. If your app has that last problem, ask for a resource-id and leave content-desc to accessibility.

2. resource-id (Android)

Set in the layout as android:id, never shown to a user, and changed only on a deliberate rename. In the tree it appears as com.example.app:id/checkout_button.

driver.find_element(AppiumBy.ID, "com.example.app:id/checkout_button")

If you're on React Native, testID surfaces as resource-id on Android and accessibilityIdentifier on iOS in current versions, so a single prop gives you both platforms.

When it breaks: the build obfuscates or strips IDs, the app was migrated to Compose without testTag plus testTagsAsResourceId, or an ID lives inside a reused component so twenty rows share it. In the last case combine it with something else rather than falling back to XPath by index; the UiSelector note in tier 5 covers that.

3. Text, with care

Fine for a static label in a single-language app with no near-duplicates on screen. Borrowed stability everywhere else.

driver.find_element(AppiumBy.XPATH, "//*[@text='Continue']")

The day someone ships localisation, "Continue" becomes "Nastavi" for the Serbian locale and every text locator dies at once. A/B copy tests do the same thing on a smaller scale. Dynamic strings ("3 items", "Hi, Filip") change per user. If you have a translations file in the repo, you already know text is a temporary anchor.

Text is still useful as a secondary condition: an ID that's shared across a list plus the row's label narrows to one element without touching the hierarchy.

4. iOS predicate string and class chain

This is where iOS pays you back for not having resource-id. Both are native XCUITest queries and both are materially faster than XPath on a deep tree.

Predicate string filters on attributes and supports partial matching without dropping to XPath:

driver.find_element(AppiumBy.IOS_PREDICATE, 'label CONTAINS "Continue" AND visible == 1')

Class chain adds hierarchy and indexing on top of predicates:

driver.find_element(AppiumBy.IOS_CLASS_CHAIN,
    '**/XCUIElementTypeCell[`name == "cart_row"`]/XCUIElementTypeButton[1]')

The backticks are part of the class chain syntax. The way I keep them apart: if I'm filtering, predicate; if I'm navigating, class chain.

When they break: a predicate on label breaks with localisation just like text does, so prefer name (which is the accessibility identifier). Class chain with an index breaks when a cell gains a child, same as XPath, only cheaper.

5. XPath, last, and relative only

Absolute paths break the moment someone wraps a view in another layout. This is the one that comes out of an inspector when you click "copy XPath" without thinking:

/hierarchy/android.widget.FrameLayout[1]/android.widget.LinearLayout[2]/android.widget.FrameLayout[1]/android.view.ViewGroup[1]/android.widget.Button[2]

Every segment is a dependency on the current layout. One wrapper view, one reordered sibling, and it points at nothing.

If you need XPath, anchor it to a real attribute and move one hop:

//*[@resource-id='com.example.app:id/cart']/following-sibling::*[1]

Nothing breaks that except deleting the anchor. It's still slower than the tiers above (XPath means the driver has to build the whole tree as XML and evaluate the query against it on every lookup), so on iOS especially I reach for class chain before this.

An Android footnote that deserves more attention than it gets: -android uiautomator with UiSelector handles a lot of what people reach for XPath for, and it runs through UiAutomator rather than the XML tree.

driver.findElement(AppiumBy.androidUIAutomator(
    "new UiSelector().resourceIdMatches(\".*:id/cart_row\").textContains(\"Shoes\")"));

I'd rank it between resource-id and text on Android when you need a partial match or a combination.

One element, five locators

Here is one checkout button on an Android screen, as it might come out of an inspector, worst to best:

/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout[2]/android.widget.Button[1]
//android.widget.Button[@text='Continue to payment']
//*[@resource-id='com.example.app:id/summary_card']/following-sibling::android.widget.Button[1]
com.example.app:id/checkout_button
checkout_button          (accessibility id)

Line one breaks on any layout change. Line two breaks when marketing renames the button or the app gets a second language. Line three survives both and breaks only if the summary card goes away. The last two survive everything short of a deliberate rename, and the accessibility ID also works on iOS if the developer used the same identifier there. When I open a screen the question is only "which of these does this element actually have", and the ranking answers itself.

Flutter

Flutter draws its own widgets, so out of the box the native tree sees one big canvas with whatever the app's semantics layer exposes. Newer Flutter versions push semantics labels through to the platform accessibility APIs, so some elements are reachable with plain accessibility id through UiAutomator2 or XCUITest. For the rest you want the Appium Flutter integration driver, which talks to the running app through the Dart VM.

Its locators, best first: key (a ValueKey on the widget), semantics label, type (widget class name), and text.

// In the app
ElevatedButton(key: const ValueKey('checkout_button'), ...)
Semantics(label: 'cart_row', child: ...)
// Java, appium-flutter-integration-driver
driver.findElement(FlutterBy.key("checkout_button"));
driver.findElement(FlutterBy.semanticsLabel("cart_row"));

Same reasoning as native: key and semantics label are things a developer typed, type is a class name that survives most refactors but not a component swap, and text inherits every localisation problem from tier 3. One caveat from the driver's own README: semanticsLabel lookups on real iOS devices have a known Flutter issue, so verify on hardware before you standardise on it.

How to talk to developers about adding test IDs

The thing that fixes locator stability long-term is a conversation, and it's easier than most QA people expect. What has worked for me:

Ask for something specific and small: "these six controls on the checkout screen need stable IDs; here's the list and the names I'd like." A ten-line PR gets merged. A policy discussion doesn't.

Name the mechanism per stack so nobody has to look it up: android:id in XML or Modifier.testTag (with testTagsAsResourceId) in Compose, accessibilityIdentifier in UIKit and SwiftUI, testID in React Native, Key in Flutter.

Agree on a naming convention once (screen_element_role, all lowercase, no spaces) and put it in the PR template. Half the value is that IDs stop being invented ad hoc.

Point at the accessibility overlap. On Android, content-desc serves TalkBack users and your tests at the same time, so the work counts twice.

Show the cost. One example of an absolute XPath from your suite next to the ID version, with the git blame of the layout change that broke it, does more than any argument.

Every hour spent here is worth ten spent hardening XPath.

Summary

StrategyPlatformStabilityWhen it breaks
Accessibility ID (content-desc, accessibilityIdentifier)Android, iOSHighGenerated or reused IDs; localised contentDescription on Android
resource-idAndroidHighObfuscated builds; Compose without testTag; shared IDs in lists
TextAndroid, iOSLow to mediumLocalisation, copy edits, dynamic strings
iOS predicate stringiOSHigh on name, low on labelSame as text when filtering on label
iOS class chainiOSMediumIndexed hops break when hierarchy changes, but cheaper than XPath
-android uiautomator (UiSelector)AndroidMedium to highSame inputs as the attributes it matches on
Relative XPath (anchored, one hop)Android, iOSMediumAnchor removed
Absolute XPathAndroid, iOSVery lowAny wrapper, reorder or component swap
Flutter key / semantics labelFlutterHighKey removed or renamed; semanticsLabel on real iOS devices (known issue)
Flutter type / textFlutterLow to mediumComponent swap; localisation

This hierarchy is why Spectra grades every generated locator A to D: same logic, made visible next to each candidate instead of living in your head. The grading is in the free tier at https://usespectra.dev.

Spectra is free for inspection. Download at https://usespectra.dev.

Spectra is free for inspection: usespectra.dev