Debug Appium Selector Failures That Differ on Android and iOS
A selector that taps the wrong row on Android can throw NoSuchElement on iOS for the same product list. This guide dumps the page source from the failing step on both platforms and separates four causes: rows a LazyColumn or lazy SwiftUI stack never created, duplicate matches, identifiers that never reach the accessibility tree (including the UiAutomator2 id prefix and the iOS label fallback), and stale handles after scrolling. Each gets a discriminating check and a WebdriverIO fix built on Compose testTag and SwiftUI accessibilityIdentifier.

testTag exposed as a resource id, SwiftUI accessibilityIdentifier), a lookup scoped inside that row, and a fresh lookup after every scroll.The test is three lines long. Find the product, tap its "Add to cart" button, assert the badge says one. On the Android emulator the badge says one, but the item in the cart is the wrong product. On the iOS simulator the same test fails at the first line with NoSuchElementError, even though Appium Inspector shows the product sitting right there on screen. Nobody changed the selector. The two platforms are simply answering different questions with it.
Mobile selectors fail differently from web ones because the tree you query is not the UI you see. It is an accessibility snapshot produced by UiAutomator on Android and by XCTest on iOS, each with its own rules about what gets included, what an identifier is called, and when a node stops existing.
latest tag on npm; 4.0.0 is still in beta), UiAutomator2 driver 8.7.0, XCUITest driver 12.13.3, WebdriverIO 9.32.0. UiAutomator2 driver 5.0.0 and later, and XCUITest driver 10.0.0 and later, run only on Appium 3. Setting defaults below are quoted from the driver documentation at those versions, and several of them (mapTestTagToResourceId in particular) do not exist in older drivers.What each error is actually reporting
Three signatures cover almost every case, and one of them is not an error at all.
no such element: An element could not be located on the page using the given search parameters.
stale element reference: The element '<locator>' does not exist in DOM anymore
stale element reference: The element identified by "6A00..." is either not present or it has expired from the internal cache. Try to find it again
The first line is the W3C WebDriver wording, which Appium's base driver reuses as its default message. It means the lookup ran against the current snapshot and matched nothing. That is a statement about the snapshot, not about the screen.
The two stale messages come from the device-side servers. The first is from the UiAutomator2 server's element cache on Android, the second from WebDriverAgent on iOS. Both mean the handle you are using points at a node that the platform has since discarded, which in a scrolling list is routine: rows are recycled or recomposed as they leave the viewport.
The wrong-element tap produces no error. The WebDriver specification's Find Element steps end with "return the first element of result", so a selector that matches six rows succeeds and hands you the first one in tree order. Which row is first depends on how far the list has scrolled, so the test passes on a fresh launch and taps the wrong product after any earlier step moved the list.
The same product list, rendered twice
Here is the kind of screen that produces all three symptoms. Each row shows a product name and an "Add to cart" button, and the list is long enough to scroll. The Android version uses a LazyColumn:
@Composable
fun ProductList(products: List<Product>, onAdd: (Product) -> Unit) {
LazyColumn {
items(products, key = { it.id }) { product ->
Row(Modifier.fillMaxWidth().padding(16.dp)) {
Text(product.name, Modifier.weight(1f))
Button(onClick = { onAdd(product) }) {
Text(stringResource(R.string.add_to_cart))
}
}
}
}
}
The iOS version uses a SwiftUI List:
struct ProductList: View {
let products: [Product]
let onAdd: (Product) -> Void
var body: some View {
List(products) { product in
HStack {
Text(product.name)
Spacer()
Button("Add to cart") { onAdd(product) }
}
}
}
}
Neither version gives a test anything stable to hold. Every button has the same visible text, which is also the only thing the accessibility tree knows it by, and that text will change the day the app ships in German.
Dump the hierarchy from inside the failing step
Appium Inspector is useful for exploring, but it is a poor witness for a failure. Its documentation says the source XML "is only updated when a refresh is requested", and that interacting with the app outside the Inspector "will not cause the source XML to refresh". What you see there is the tree as of your last click, not the tree your test queried.
Capture the source from the test itself, at the step that fails, and count matches for the selector you used while you are there:
import { writeFile } from 'node:fs/promises';
export async function snapshotFor(selector, label) {
const platform = driver.isAndroid ? 'android' : 'ios';
const source = await driver.getPageSource();
await writeFile(`artifacts/${label}-${platform}.xml`, source);
const matches = await $$(selector);
console.log(`${platform} ${selector}: ${matches.length} match(es)`);
return matches.length;
}
// in the failing test, before the lookup that fails
await snapshotFor('//*[@text="Add to cart" or @label="Add to cart"]', 'add-to-cart');
Run it on both platforms and keep both files. The two XML documents use different vocabularies: Android nodes carry resource-id, content-desc and text, while iOS nodes carry name, label and value on element types such as XCUIElementTypeButton.
One trap before you trust either file: both drivers cap the snapshot depth. UiAutomator2's snapshotMaxDepth defaults to 70, and the driver warns that "a part of the elements source tree might be lost if the value is too low". XCUITest's defaults to 50 and accepts only 1 to 62, because deeper values "are not supported by XCTest". A deeply nested Compose or SwiftUI screen can put your row below the cut-off, so if a node you can see on screen is missing from the dump, raise the setting once and dump again before concluding anything else.
Four causes, told apart by what the dump shows
The row has not been created yet
Android's Compose documentation is explicit that LazyColumn and LazyRow "only compose and lay out items which are visible in the component's viewport". Product 1042, forty rows down, is not hidden. It does not exist, and no selector can find it.
On iOS, Apple documents the same behaviour for LazyVStack, which "doesn't create items until it needs to render them onscreen". The List page makes no such statement either way, so treat the dump as the authority: scroll the row out of view and see whether its cell is still in the source.
The check that separates "not created" from "created but filtered out" on Android is the allowInvisibleElements setting, which is false by default and controls whether nodes whose displayed attribute is false appear in the source at all:
await driver.updateSettings({ allowInvisibleElements: true });
const withHidden = await $$('id=product-1042');
await driver.updateSettings({ allowInvisibleElements: false });
// 0 here as well: the row was never composed. Scroll to it.
// 1 here but 0 normally: it exists off screen. Scroll it into view before acting.
The fix is to scroll by identifier and look the row up again afterwards. On Android, mobile: scrollGesture returns true while the container can still scroll in that direction, which gives you a loop with a natural end:
async function scrollToRowAndroid(rowId, maxScrolls = 20) {
const list = await $('id=products');
for (let i = 0; i < maxScrolls; i++) {
const rows = await $$(`id=${rowId}`);
if (rows.length === 1) return rows[0];
if (rows.length > 1) throw new Error(`${rowId} matched ${rows.length} nodes`);
const canScrollMore = await driver.execute('mobile: scrollGesture', {
elementId: list.elementId, direction: 'down', percent: 0.8,
});
if (!canScrollMore) break;
}
throw new Error(`${rowId} not found after scrolling the list to its end`);
}
You will see older answers do this in one line with new UiScrollable(...).scrollIntoView(...) through the -android uiautomator strategy. It works today, but Google's UI Automator page says UiScrollable and UiSelector "are being deprecated in a future release", and the UiAutomator2 driver warns that their removal "will render all -android uiautomator-based locators invalid". The gesture loop does not depend on them.
On iOS, mobile: scroll accepts a predicateString for the child to scroll to, provided you pass the container as elementId:
async function scrollToRowIos(rowId) {
const list = await $('~products');
await driver.execute('mobile: scroll', {
elementId: list.elementId,
predicateString: `name == '${rowId}'`,
});
return $(`~${rowId}`);
}
The XCUITest documentation is candid about its limits: the extension "relies on several undocumented XCTest features", and it may fail when reaching the child needs "tens of" scroll gestures. For a very long catalogue, filter or search the list down first instead of scrolling through it.
Several nodes answer the same selector
This is the wrong-product tap. The dump shows six or seven nodes with text="Add to cart" on Android, or label="Add to cart" on iOS, and the match count from the snippet above is greater than one. Positional XPath such as (//android.widget.Button)[3] is the same bug with a number attached: position three is a different product after every scroll.
The fix belongs in the app. Give each row an identifier derived from the data, give the button a fixed identifier, and on Android expose test tags to UiAutomator once, high in the tree:
Scaffold(modifier = Modifier.semantics { testTagsAsResourceId = true }) { padding ->
LazyColumn(Modifier.padding(padding).testTag("products")) {
items(products, key = { it.id }) { product ->
Row(Modifier.fillMaxWidth().testTag("product-${product.id}")) {
Text(product.name, Modifier.weight(1f))
Button(onClick = { onAdd(product) }, Modifier.testTag("add-to-cart")) {
Text(stringResource(R.string.add_to_cart))
}
}
}
}
}
List(products) { product in
HStack {
Text(product.name)
Spacer()
Button("Add to cart") { onAdd(product) }
.accessibilityIdentifier("add-to-cart")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("product-\(product.id)")
}
.accessibilityIdentifier("products")
The .contain behaviour matters for the lookup that follows. Apple describes it as making the view "an accessibility container" whose child elements become its children, so the row gets its own identifier and the button stays a separate element inside it. Open the dump after the change and confirm the nesting rather than assuming it.
Now the button identifier is deliberately not unique across the screen. It only has to be unique inside a row, and the test finds the row first:
// Wrong: first "Add to cart" in tree order, whichever row that is right now
await $('//*[@text="Add to cart"]').click();
// Right: find the row, then search only inside it
const rowSelector = driver.isAndroid ? 'id=product-1042' : '~product-1042';
const buttonSelector = driver.isAndroid ? 'id=add-to-cart' : '~add-to-cart';
const row = driver.isAndroid
? await scrollToRowAndroid('product-1042')
: await scrollToRowIos('product-1042');
const buttons = await row.$$(buttonSelector);
if (buttons.length !== 1) throw new Error(`expected one button in the row, got ${buttons.length}`);
await buttons[0].click();
The identifier is in the code but not in the tree
This presents as NoSuchElementError for an identifier that is plainly in the source code. The dump says which of three things happened.
Android, no resource-id on the row at all. Compose does not expose test tags to UiAutomator by default. Its testing documentation says composables are reachable "only by their convenient descriptors (displayed text, content description, etc.)" until testTagsAsResourceId is enabled for the subtree, a property available from Compose 1.2.0-alpha08. If the app team cannot ship that change yet, UiAutomator2 driver 8.4.0 added a mapTestTagToResourceId setting that performs the same mapping from the test side, applied to attributes, page source and id lookups.
Android, resource-id="product-1042" is in the dump but id=product-1042 still fails. The driver rewrote your selector. Its id strategy adds the package prefix "automatically if unset", so the lookup actually searched for com.example.shop:id/product-1042. Compose's documentation warns about the same mismatch from the other side: a package-qualified resource name "is different from Modifier.testTag". Turn the rewriting off:
// Either: stop the driver prefixing bare ids with the app package
await driver.updateSettings({ disableIdLocatorAutocompletion: true });
// Or, on UiAutomator2 driver 8.4.0+, map testTag to resource-id without an app change.
// Bare tags are matched as-is under this setting, with no package prefix.
await driver.updateSettings({ mapTestTagToResourceId: true });
iOS, the row's name equals its visible text. The XCUITest driver's accessibility id, id and name strategies are all aliases for the same name attribute, and that attribute "corresponds to the element's XCTest identifier or label property, depending on which one is available first". When no identifier reaches the element, the name silently falls back to the label. So ~Add to cart works on iOS without any identifier, which is exactly how a suite ends up full of label lookups that break under localization. If the dump shows name="Add to cart" where you expected name="add-to-cart", the modifier did not land on the element you think it did.
That fallback is also why sharing ~ across platforms is a poor bargain. On Android the accessibility id strategy maps to UiAutomator's By.desc, an exact match on content description. Content description is what TalkBack reads aloud, and Android's own guidance is to "use a localized string, as it is visible to the user". Putting product-1042 there to satisfy a test makes the screen reader announce it. Apple's identifier has the opposite property: the XCUITest driver notes it is "supposed to stay constant under different locales, and does not affect accessibility services such as VoiceOver". The two platforms have a stable, silent identifier each, and they are reached by different strategies.
The element was found, then the list moved
Here the dump looks fine and a fresh lookup succeeds, but the reference the test was holding does not. The selector is innocent; the test kept a handle too long.
The Android server's behaviour is worth knowing in detail. When a cached element goes stale, the UiAutomator2 server tries to restore it by re-running the locator that found it. It refuses when that locator matched more than one node, with a source comment that reads "we cannot restore a single element from a locator that matches multiple items", and it throws "is not linked to the same object in DOM anymore" when the re-run finds a different node. So an element picked out of an ambiguous $$ result cannot be recovered, and an element found by a unique identifier sometimes can. WebDriverAgent on iOS makes no such attempt and tells you to "try to find it again".
The rule that follows is simple: never carry an element handle across a scroll, a navigation, or a list refresh. Keep selectors in variables and elements in local scope, and look the row up again after anything that could have recycled it. The scrollToRow helpers above return a fresh element for exactly this reason.
A related timing symptom is a tap that lands on the neighbouring row while the list is still settling. Both drivers wait for the app to go idle before acting, and both default that wait to ten seconds (waitForIdleTimeout, in milliseconds on Android and seconds on iOS). Setting it to zero is the common way to "speed up" a slow suite. The UiAutomator2 documentation says plainly what it costs: with no wait, "clicks and other actions might happen at wrong places of the application UI". If lookups are slow because an animation never stops, disable animations in the test build rather than disabling the wait.
XPath on visible text fails on both counts
The selector at the start of this article, an XPath on the button text, combines the two weakest choices. It depends on copy that changes with locale, and it runs against the page-source XML rather than the platform's native query engine. The XCUITest driver ranks XPath lowest of its strategies and says such locators "can be much slower compared to all other ones - sometimes up to 10x slower". On Android, the UiAutomator2 driver evaluates XPath against "the same XML tree that is generated by page source API", which is why it inherits every depth limit and visibility filter described above.
XPath still has a place for elements that can only be told apart by their neighbours. If you have to keep one, count its matches with the snapshot helper on both platforms before committing it. On iOS, a class chain query such as **/XCUIElementTypeCell[`name == 'product-1042'`]/**/XCUIElementTypeButton expresses the same parent-then-child relationship through XCTest's own predicate engine.
Capture both hierarchies before the next fix
Pick the flakiest list interaction in your suite and run the snapshot helper at its lookup on one Android device and one iOS simulator. Keep the two XML files and the match counts with the bug. From those, write down which of the four causes applies on each platform. Do not assume it is the same one: a duplicate match on Android and a label fallback on iOS can sit behind a single failing step.
Then take the per-row identifier request to the app team with the dumps attached. Ask for three things: a data-derived identifier on each row, a fixed identifier on each control inside a row, and testTagsAsResourceId enabled once near the root of the Compose tree. Until that ships, the mapTestTagToResourceId setting covers Android if the tags already exist in code.
Finally, make the suite refuse ambiguity. Route every row interaction through a helper that asserts exactly one match for the row and exactly one for the control inside it, so the next duplicate fails loudly at the lookup instead of passing by tapping the wrong product.
Questions mobile teams ask about these failures
Inspector shows the element, so why does the test say it does not exist?
Inspector shows the tree from its last refresh, possibly on a different screen state, and it does not refresh when the app changes outside its own actions. Compare against a source dump taken by the test at the failing step. If the node is on screen but absent from both, raise snapshotMaxDepth before investigating further.
Can one selector work on Android and iOS?
Only if you put the same string into Android's content description, which TalkBack reads to users. Keeping the identifier strings identical across platforms and switching the strategy (id= on Android with exposed test tags, ~ on iOS) gets you shared test code without shipping identifiers to screen-reader users.
What changes for a React Native app?
The mapping moves. The XCUITest driver documents that the iOS name attribute corresponds to React Native's testID, and the UiAutomator2 driver documents that on Android the content description reflects accessibilityLabel. Dump the source on both platforms before assuming either maps to the same strategy.
Does this apply to RecyclerView rather than Compose?
The diagnosis does. Android's RecyclerView guide says that when an item scrolls off the screen the view is not destroyed but reused for new items scrolling on, so the dump and the allowInvisibleElements check are still how you tell "not created" from "filtered". The identifier fix is simpler there, because View resource ids follow the <packageName>:id/ convention the driver's prefixing assumes, so neither setting above is needed. What you cannot do is set a per-row resource id from data, so per-row identity usually comes from content description or a tag the team exposes deliberately.
Primary references
- Appium UiAutomator2 Driver README. The location strategies and what they map to (
idtoBy.reswith the package prefix "added automatically if unset", accessibility id toBy.desc, XPath against the page-source XML), the deprecation warning for-android uiautomator, and the settingsallowInvisibleElements,snapshotMaxDepth(default 70),waitForIdleTimeout(default 10000 ms, and the cost of setting it to zero),disableIdLocatorAutocompletionandmapTestTagToResourceId(since driver 8.4.0). Also that driver 5.0.0+ runs only on Appium 3. Driver 8.7.0. - UiAutomator2 Driver — Automating Mobile Gestures. The
mobile: scrollGesturearguments and its boolean return, "true if the object can still scroll in the given direction". - Appium 3.8.0 release. That 3.8.0, published 2026-09-24, is the current stable Appium, with 4.0.0 only at beta.
- Appium XCUITest Driver README. That driver 10.0.0 and later runs only on Appium 3. Driver 12.13.3.
- XCUITest Driver — Locator Strategies. That
idandaccessibility idare aliases for thenamestrategy, when to use predicate strings and class chains, and that XPath "can be much slower compared to all other ones - sometimes up to 10x slower". - XCUITest Driver — Element Attributes. That
nameis the XCTest identifier or label "depending on which one is available first", the recommendation to preferaccessibilityIdentifierbecause it stays constant across locales and does not affect VoiceOver, and the React NativetestIDmapping. - XCUITest Driver — Settings.
snapshotMaxDepthdefault 50 and range 1..62, andwaitForIdleTimeoutdefault 10 seconds. - XCUITest Driver — Execute Methods. The
mobile: scrollargumentselementId,nameandpredicateString, and its stated reliance on undocumented XCTest features. - Appium Inspector — Source. That the source XML only updates when a refresh is requested and not when the app changes outside the Inspector.
- W3C WebDriver. The Find Element steps that "return the first element of result", and the error texts for
no such elementandstale element reference. - appium-uiautomator2-server ElementsCache.java at v10.6.6. The stale-element restore logic: re-running the original locator, refusing when it matched multiple items, and the exact stale messages.
- WebDriverAgent FBElementCache.m at v16.13.5. The iOS stale-element message, "either not present or it has expired from the internal cache".
- Android Developers — Interoperability with UiAutomator. That composables are reachable only by text and content description until
testTagsAsResourceIdis enabled, the Compose 1.2.0-alpha08 minimum, enabling it once high in the hierarchy, and the warning that a package-qualified resource name differs fromModifier.testTag. - Android Developers — Lists and grids. That
LazyColumnandLazyRow"only compose and lay out items which are visible", and stable item keys. - Android Developers — Compose accessibility API defaults. That
contentDescriptionshould be a localized string because it is presented to the user. - Android Developers — UI Automator (legacy APIs). That
UiObject,UiScrollableandUiSelectorare being deprecated in a future release. - Apple — accessibilityIdentifier(_:). That the identifier is for testing and "isn't visible to the user".
- Apple — AccessibilityChildBehavior.contain. That child accessibility elements become children of a new container element.
- Apple — LazyVStack. That the stack "doesn't create items until it needs to render them onscreen".
- Android Developers — Create dynamic lists with RecyclerView. That an item scrolling off screen has its view reused for new items rather than destroyed.
- WebdriverIO — Selectors. The
~,id=,android=,-ios predicate string:and-ios class chain:selector syntaxes used in the snippets.