Locating elements in a WebView: a pattern-by-pattern playbook

Published on

WebView automation reads a projection of your HTML. The browser decides the role and the accessible name of each control, Android copies them into the accessibility tree, and Appium reads that tree. Playwright reads the DOM. What each tool can see depends on what the markup exposes.

When the markup exposes nothing, the tree has nothing to match. An icon button with no aria-label is what that looks like: the tree carries a rectangle with clickable="true", NAF="true" (the dumper’s mark for a node with no accessible name), and no text to match on, so the bounds are the only handle. When that node shows up in a flaky-locator report, the fix is a name on the button.

Reaching for a test id is tempting, but the tooling ranks it last. Testing Library’s query priority puts getByRole first and getByTestId last, because a user “cannot see (or hear)” a test id 1. Playwright’s locator guide ranks role locators as “the closest way to how users and assistive technology perceive the page” 2. The patterns below follow that order, and §6 covers what happens to a data-testid on the Android side.

The name matters most in the tree, which is the view you get on the build you approve for shipping. The tree is present on every build; the DOM needs the WebView debug socket, which a release build turns off by default. Putting the socket on takes a separate QA build.

This post covers both. Fourteen UI patterns, each with the markup that makes it findable, the node it becomes in the tree, and the locator in Appium and in Playwright. It also covers dynamic content, where locators written for a static page stop working, and a triage table that maps a symptom to its fix.

1. The tree and the DOM

Every locator below reads one of two views of the page. They describe the same page with different vocabularies, and each needs its own tool.

Accessibility tree (in-app)DOM (debug socket, or a browser)
Outputrole → class, accessible name → text/content-desc, states, boundsthe real markup: tags, attributes, CSS, data-*
Locator inputrole + name, resource-id, XPath over the page sourcegetByRole, labels, CSS, data-testid
Survivesrelease builds, no debug socket neededonly where the DevTools socket is turned on
  • Appium (left column). The examples stay in the native context and read the accessibility tree. driver.context('WEBVIEW_com.example.app') is the other route: it hands you the DOM through chromedriver, it needs the WebView debug socket on, and the bundled chromedriver has to match your installed WebView version; if the versions differ, the session fails.
  • Playwright (right column). Playwright has no driver for an Android WebView. These snippets run against the same page served standalone, or over chromium.connectOverCDP() into the debug socket if your build has it. To reach the socket that way, forward it first: adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>, then connect to http://localhost:9222.

You can reach the tree on any build, including a release build (a cold WebView may take a second read, see §3.3). The DOM is reachable only through the socket, so it depends on the build.

The accessible name computation is the shared part. Whatever the browser decides the name is, whether it came from text content, a <label>, aria-label, aria-labelledby or alt, is the string the tree stores and the screen reader reads. Ask whether the control has a name, and whether that name is unique. Use that answer to pick the selector.

Four authoring habits make locators reliable:

  • Use real elements. <button>, <input type="checkbox"> and <a href> give you a role and a name. A <div onclick> gives you neither.
  • One name per purpose. Two controls with the same name are indistinguishable in the tree. Rename one, or scope it under a named ancestor.
  • Do not write test ids into the accessible name. aria-label="user_id_42" makes the screen reader announce gibberish. The page should say aria-label="Account 42".
  • Put state in the markup. aria-expanded, aria-selected, aria-checked, aria-sort. State can be the only difference between two otherwise identical nodes.

2. The pattern catalogue

Every pattern has a live page you can open and dump:

adb exec-out uiautomator dump /dev/tty # first read of a cold WebView may be bare
adb exec-out uiautomator dump /dev/tty # second read has the page

I took each XML block from a real uiautomator dump on a OnePlus 6T (Android 15, WebView 140.0.7339.207), loaded through a WebView host app. The earlier mapping study on WebView 115 and Android 13 is in the previous post. The all-patterns fixture is one page you can dump in a single pass 3.

The fourteen patterns follow in the order you meet them in a form: markup, node, locator, trap.

2.1 Labelled text field (label + hint + error)

Open in a tab
<label for="email">Email</label>
<input id="email" type="email" required
       aria-describedby="email-hint" aria-invalid="false">
<p id="email-hint">We only use this for receipts.</p>
<p id="email-error" role="alert" hidden>Enter a valid email address.</p>
<node index="0" text="Forms: field, password, choice" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="field" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,326]" drawing-order="0" hint="">
    <node index="0" text="Email" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Email" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,204]" drawing-order="0" hint=""/>
    <node NAF="true" index="2" text="" resource-id="email" class="android.widget.EditText" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,210][724,281]" drawing-order="0" hint="Email We only use this for receipts."/>
    <node index="3" text="We only use this for receipts." resource-id="email-hint" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,288][1049,326]" drawing-order="0" hint=""/>
  </node>
</node>

The node. The field is an EditText, and its value lands in text. An unlabelled input with value="Type here" produced text="Type here". Add a <label> and WebView 140 does not use it for the field’s name. It produced a second node (android.view.View with text="Email"), and the field came out NAF="true", with the name and description in hint ("Email We only use this for receipts."). Both text and content-desc were empty. resource-id="email" came from the HTML id. The error has no Android role, but showing it does fire an accessibility event.

Locator.

Playwright
// Playwright: name from the <label>, description from aria-describedby
await page.getByRole('textbox', { name: 'Email' }).fill('a@b.com');
await expect(page.getByRole('textbox', { name: 'Email' }))
.toHaveAccessibleDescription(/receipts/i);
Appium
// The label is not the field's name in the tree, so use the id (from the HTML id)
driver.findElement(AppiumBy.xpath(
"//android.widget.EditText[@resource-id='email']"));
// If your build drops the id, fall back to the hint the bridge composed
driver.findElement(AppiumBy.xpath(
"//android.widget.EditText[@hint='Email We only use this for receipts.']"));

The trap. Locating by text="a@b.com" ties the selector to one value in your test data. Find the field by its label, and assert the value in a second step.

2.2 Password field with show/hide

Open in a tab
<label for="pw">Password</label>
<input id="pw" type="password" value="hunter2">
<button type="button" id="pw-toggle" aria-label="Show password" aria-pressed="false">👁</button>
<node index="0" text="Forms: field, password, choice" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="password" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,361]" drawing-order="0" hint="">
    <node index="0" text="Password" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Password" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,204]" drawing-order="0" hint=""/>
    <node index="2" text="•••••••" resource-id="pw" class="android.widget.EditText" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="true" selected="false" bounds="[29,210][724,281]" drawing-order="0" hint="Password"/>
    <node index="3" text="Show password" resource-id="pw-toggle" class="android.widget.ToggleButton" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,290][115,361]" drawing-order="0" hint=""/>
  </node>
</node>

The node. An EditText with password="true", masked, and the mask is as long as the value (••••••• for a seven-character secret). WebView renders the toggle as a ToggleButton, named by aria-label (text="Show password"). WebView maps an aria-pressed button to ToggleButton. The pressed state itself did not surface as checked, so read class and name, and do not expect a state flag.

Locator.

Playwright
await page.getByRole('button', { name: 'Show password' }).click();
await expect(page.getByRole('button', { name: 'Hide password' }))
.toHaveAttribute('aria-pressed', 'true');
Appium
// assert the flag, not the secret
WebElement pw = driver.findElement(AppiumBy.xpath(
"//android.widget.EditText[@password='true']"));
assert pw.getAttribute("password").equals("true");

The trap. Asserting the masked value only restates the mask, and the mask length equals the secret length, so the length leaks. Assert password="true", then toggle and assert that the field stopped being a password field.

2.3 Checkbox group, radio group, switch

Open in a tab
<fieldset><legend>Notifications</legend>
  <label><input type="checkbox" name="notify" value="email" checked> Email</label>
  <label><input type="checkbox" name="notify" value="sms"> SMS</label>
</fieldset>
<fieldset><legend>Plan</legend>
  <label><input type="radio" name="plan" value="basic" checked> Basic</label>
  <label><input type="radio" name="plan" value="plus"> Plus</label>
</fieldset>
<label><input type="checkbox" role="switch" id="wifi" checked> Wi-Fi</label>
<node index="0" text="Forms: field, password, choice" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="choice" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,630]" drawing-order="0" hint="">
    <node index="0" text="Notifications and plan" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Notifications" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,351]" drawing-order="0" hint="">
      <node index="0" text="Notifications" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[56,165][225,204]" drawing-order="0" hint=""/>
      <node index="1" text="Email" resource-id="" class="android.widget.CheckBox" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[64,226][98,261]" drawing-order="0" hint=""/>
      <node index="2" text="SMS" resource-id="" class="android.widget.CheckBox" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[64,281][98,314]" drawing-order="0" hint=""/>
    </node>
    <node index="2" text="Plan" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,379][1049,557]" drawing-order="0" hint="">
      <node index="0" text="Plan" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[56,379][131,420]" drawing-order="0" hint=""/>
      <node index="1" text="Basic" resource-id="" class="android.widget.RadioButton" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[66,442][100,477]" drawing-order="0" hint=""/>
      <node index="2" text="Plus" resource-id="" class="android.widget.RadioButton" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[66,491][100,526]" drawing-order="0" hint=""/>
    </node>
    <node index="3" text="Wi-Fi" resource-id="wifi" class="android.widget.ToggleButton" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[37,591][72,624]" drawing-order="0" hint=""/>
  </node>
</node>

The node. Each option is its own CheckBox or RadioButton, named by the wrapping label text. The checked state did not reach the 140 tree. Both options came out checkable="false" checked="false", though the HTML set checked. The earlier 115 study in the previous post recorded checkable="true" checked="true" for the same markup, so read the state through the effect on the page. role="switch" became a ToggleButton. The <fieldset><legend> does become a named ancestor (View text="Notifications") around its options, so you can scope to it.

Locator.

Playwright
await page.getByRole('checkbox', { name: 'Email' }).uncheck();
await expect(page.getByRole('checkbox', { name: 'Email' })).not.toBeChecked();
Appium
// the legend gives you no scope, so lean on unique option names
driver.findElement(AppiumBy.xpath("//android.widget.CheckBox[@text='Email']")).click();

The trap. Two groups with an “SMS” option produce two nodes named “SMS”, because the legend does not join the option’s name. Rename them (“SMS alerts”, “SMS receipts”) or scope to the named fieldset, //android.view.View[@text='Notifications']//android.widget.CheckBox[@text='SMS']. Since checked is missing from the tree, assert the effect of the selection (the page often re-renders something) instead of the checkbox state.

2.4 Native <select> vs custom combobox

Open in a tab
<label for="country">Country</label>
<select id="country"><option>Singapore</option><option>Malaysia</option></select>

<label for="combo">Fruit</label>
<input id="combo" role="combobox" aria-expanded="false" aria-controls="fruit-list"
       aria-activedescendant="" autocomplete="off" readonly>
<ul id="fruit-list" role="listbox" aria-label="Fruit" hidden>
  <li role="option" id="opt-apple" aria-selected="false">Apple</li>
  <li role="option" id="opt-banana" aria-selected="false">Banana</li>
</ul>
<node index="0" text="Native select and custom combobox" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,273]" drawing-order="0" hint="">
    <node index="0" text="Native select" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Country" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,204]" drawing-order="0" hint=""/>
    <node index="2" text="Singapore" resource-id="country" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,210][724,273]" drawing-order="0" hint="Country"/>
  </node>
  <node index="1" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,310][1049,491]" drawing-order="0" hint="">
    <node index="0" text="Custom combobox" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,351][1049,393]" drawing-order="0" hint=""/>
    <node index="1" text="Fruit" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,406][1049,447]" drawing-order="0" hint=""/>
    <node index="2" text="" resource-id="combo" class="android.widget.EditText" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="false" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,453][359,491]" drawing-order="0" hint="Fruit"/>
  </node>
</node>

The node. The native <select> collapses into a single android.view.View rather than a Button, with text="Singapore" (the current selection) and hint="Country" (the label again, in hint). Opening it hands you a picker in a separate window, which a default uiautomator dump does not cover; add --windows, or switch the Appium window, to reach the options. The custom combobox is an EditText (hint="Fruit") and reports enabled="false", because the demo marks it readonly; an Appium click() on it fails with “element not interactable” until you drop the attribute or click the element that owns the popup. Its options become separate nodes only while the list is open.

Locator.

Appium
// native select: the node's own text is the current selection
driver.findElement(AppiumBy.xpath("//android.view.View[@text='Singapore']")).click();
Playwright
// custom combobox: expand, then pick by option role and name
await page.getByRole('combobox', { name: 'Fruit' }).click();
await page.getByRole('option', { name: 'Apple' }).click();
await expect(page.getByRole('combobox', { name: 'Fruit' })).toHaveValue('Apple');

The trap. With a native select, the locator changes the moment the value changes, because the value is the node’s text. Find the control once by an available handle (its resource-id, derived from the HTML id, or its label), then read the text as an assertion. Do not bake the option into the selector.

2.5 Icon-only buttons

Open in a tab
<button type="button" class="icon-btn" aria-label="Close dialog">✕</button>
<button type="button" class="icon-btn" id="fav" aria-pressed="false" aria-label="Add to favourites">☆</button>
<node index="0" text="Icon buttons and row actions" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="icons" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,253]" drawing-order="0" hint="">
    <node index="0" text="Icon-only buttons" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Close dialog" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][119,253]" drawing-order="0" hint=""/>
    <node index="2" text="Add to favourites" resource-id="fav" class="android.widget.ToggleButton" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[121,165][211,253]" drawing-order="0" hint=""/>
  </node>
</node>

The node. A plain icon button is a Button named by aria-label (text="Close dialog"). Add aria-pressed and it becomes a ToggleButton instead, as the favourite star did (ToggleButton text="Add to favourites"). The node has no child elements. Watch for icon fonts leaking a glyph into the node as text, so the aria-label must be present and precise.

Locator.

Playwright
await page.getByRole('button', { name: 'Add to favourites' }).click();
await expect(page.getByRole('button', { name: 'Add to favourites' }))
.toHaveAttribute('aria-pressed', 'true');
Appium
driver.findElement(AppiumBy.xpath(
"//android.widget.Button[@text='Add to favourites'] | //android.widget.ToggleButton[@text='Add to favourites']"));

The trap. Strip the aria-label and you get a NAF="true" node: clickable, unnamed, reachable only by coordinates, so the test taps by position and can hit the wrong control.

2.6 Tabs

Open in a tab
<div role="tablist" aria-label="Account sections">
  <button role="tab" id="tab-summary" aria-selected="true" aria-controls="panel-summary">Summary</button>
  <button role="tab" id="tab-activity" aria-selected="false" tabindex="-1" aria-controls="panel-activity">Activity</button>
</div>
<div role="tabpanel" id="panel-summary" aria-labelledby="tab-summary">Your balance is 1,250.</div>
<div role="tabpanel" id="panel-activity" aria-labelledby="tab-activity" hidden>No recent activity.</div>
<node index="0" text="Tabs and disclosure" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="tabs" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,294]" drawing-order="0" hint="">
    <node index="0" text="Account" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Account sections" resource-id="" class="android.widget.TabWidget" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,239]" drawing-order="0" hint="">
      <node index="0" text="Summary" resource-id="tab-summary" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="true" bounds="[29,165][184,239]" drawing-order="0" hint=""/>
      <node index="1" text="Activity" resource-id="tab-activity" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[188,165][317,239]" drawing-order="0" hint=""/>
    </node>
    <node index="2" text="Summary" resource-id="panel-summary" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,253][1049,294]" drawing-order="0" hint="">
      <node index="0" text="Your balance is 1,250." resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,257][276,288]" drawing-order="0" hint=""/>
    </node>
  </node>
</node>

The node. role="tablist" did map: it became an android.widget.TabWidget named by aria-label (text="Account sections"). Each tab is a View named by its label, and aria-selected="true" survived as selected="true" on the active tab. The inactive panel is hidden, so it is not in the tree.

Locator.

Playwright
await page.getByRole('tab', { name: 'Activity' }).click();
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tabpanel')).toContainText('No recent activity');
Appium
// tabs are plain Views here, not Buttons
driver.findElement(AppiumBy.xpath("//android.view.View[@text='Activity']")).click();
// the panel content is the assertion that matters
driver.findElement(AppiumBy.xpath("//*[@text='No recent activity.']"));

The trap. Assert the panel content. Role state does not appear in these dumps, and the panel is the visible result.

2.7 Disclosure / accordion

Open in a tab
<button type="button" id="faq-1" aria-expanded="false" aria-controls="faq-1-panel">
  How long does a transfer take?
</button>
<div id="faq-1-panel" role="region" aria-labelledby="faq-1" hidden>Usually within one business day.</div>
<node index="0" text="Tabs and disclosure" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="faq" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,234]" drawing-order="0" hint="">
    <node index="0" text="FAQ" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="How long does a transfer take?" resource-id="faq-1" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][429,234]" drawing-order="0" hint=""/>
  </node>
</node>

The node. The trigger is a Button named by its text (resource-id="faq-1" from the HTML id). aria-expanded did not appear anywhere in the dump, so do not build an assertion on it. The panel is absent while hidden.

Locator.

Playwright
await page.getByRole('button', { name: /How long/ }).click();
await expect(page.getByRole('button', { name: /How long/ })).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByText('Usually within one business day')).toBeVisible();
Appium
// the trigger is a Button; the HTML id survives as resource-id
driver.findElement(AppiumBy.xpath("//android.widget.Button[@resource-id='faq-1']")).click();
// aria-expanded did not surface, so the revealed text is the assertion
driver.findElement(AppiumBy.xpath("//*[@text='Usually within one business day']"));

The trap. This is the same shape as the tabs. In Playwright, aria-expanded is reliable, because it is a DOM attribute. In the tree it did not surface, so assert the revealed content.

2.8 Dialog (custom aria-modal)

Open in a tab
<div class="scrim" id="scrim" data-open="false">
  <div class="modal" role="dialog" aria-modal="true" aria-labelledby="custom-title">
    <h2 id="custom-title">Delete recipient</h2>
    <p>This cannot be undone.</p>
    <button type="button" id="cancel-custom">Cancel</button>
    <button type="button" id="confirm-custom">Delete</button>
  </div>
</div>
<node index="0" text="Dialogs, a custom modal, and a toast" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="scrim" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
    <node index="0" text="Delete recipient" resource-id="" class="android.app.Dialog" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[162,1075][918,1344]" drawing-order="0" hint="">
      <node index="0" text="Delete recipient" resource-id="custom-title" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[198,1105][881,1148]" drawing-order="0" hint=""/>
      <node index="1" text="This cannot be undone." resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[198,1174][881,1215]" drawing-order="0" hint=""/>
      <node index="2" text="Cancel" resource-id="cancel-custom" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[610,1242][741,1313]" drawing-order="0" hint=""/>
      <node index="3" text="Delete" resource-id="confirm-custom" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[755,1242][881,1313]" drawing-order="0" hint=""/>
    </node>
  </node>
</node>

The node. I expected no special node here, and WebView 140 produced one. role="dialog" with aria-modal="true" produced an android.app.Dialog, named from aria-labelledby (text="Delete recipient"). It sits inside the scrim, which is a plain View (resource-id="scrim"). So the container’s role and label both made it through, and the inner text and buttons sit under the Dialog. The 115 study found no node for the container, a mapping change between WebView 115 and 140. The capture above is the custom modal; a native <dialog> is a separate case that I did not dump.

Locator.

Playwright
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Delete recipient');
await dialog.getByRole('button', { name: 'Delete' }).click();
Appium
// the modal is a Dialog node named by its label; scope the action to it
driver.findElement(AppiumBy.xpath("//android.app.Dialog[@text='Delete recipient']"));
driver.findElement(AppiumBy.xpath(
"//android.app.Dialog[@text='Delete recipient']//android.widget.Button[@text='Delete']"));

The trap. A custom scrim that leaves the background visible also leaves the background in the tree. A bare //android.widget.Button[@text='Delete'] can then match a button behind the modal. In the capture above the background is gone, because the demo sets inert and aria-hidden on it. Hiding the background removes the behind-modal buttons from the tree, so the screen reader and the locator stop seeing them. Do it, then scope locators to the dialog.

2.9 List with per-row actions

Open in a tab
<ul id="recipients">
  <li><span>Transfer to Alice</span>
    <button aria-label="Edit transfer to Alice">Edit</button>
    <button aria-label="Delete transfer to Alice">Delete</button></li>
  <li><span>Transfer to Bob</span>
    <button aria-label="Edit transfer to Bob">Edit</button>
    <button aria-label="Delete transfer to Bob">Delete</button></li>
</ul>
<p role="status" id="row-status"></p>
<node index="0" text="Icon buttons and row actions" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="rows" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,361]" drawing-order="0" hint="">
    <node index="0" text="Transfers" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="" resource-id="recipients" class="android.widget.ListView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,361]" drawing-order="0" hint="">
      <node index="0" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,265]" drawing-order="0" hint="">
        <node index="0" text="Transfer to Alice" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,198][215,230]" drawing-order="0" hint=""/>
        <node index="1" text="Edit transfer to Alice" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[814,179][912,249]" drawing-order="0" hint=""/>
        <node index="2" text="Delete transfer to Alice" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[924,179][1049,249]" drawing-order="0" hint=""/>
      </node>
      <node index="1" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,261][1049,361]" drawing-order="0" hint="">
        <node index="0" text="Transfer to Bob" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,294][204,326]" drawing-order="0" hint=""/>
        <node index="1" text="Edit transfer to Bob" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[814,275][912,347]" drawing-order="0" hint=""/>
        <node index="2" text="Delete transfer to Bob" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[924,275][1049,347]" drawing-order="0" hint=""/>
      </node>
    </node>
    <node index="2" text="" resource-id="row-status" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,369][1049,369]" drawing-order="0" hint=""/>
  </node>
</node>

The node. The <ul> became a ListView (resource-id="recipients"). Each row is a View, the row text a TextView, and each action a Button named by its unique aria-label. Give each action a unique name, and a locator can address it.

Locator.

Playwright
const row = page.getByRole('listitem').filter({ hasText: 'Alice' });
await row.getByRole('button', { name: 'Edit' }).click();
Appium
driver.findElement(AppiumBy.xpath(
"//android.widget.Button[@text='Edit transfer to Alice']"));

The trap. Drop the unique labels and every row has a button called “Edit”. Your only options left are index (findElements(...).get(0)) or an ancestor XPath, and both break on the next reorder. Give each action a unique name: the test works, and a screen-reader user hears which row the action affects.

2.10 Toast / live region

Open in a tab
<button type="button" id="save">Save</button>
<div role="status" aria-live="polite"></div>
<node index="0" text="Dialogs, a custom modal, and a toast" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="toast" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,273]" drawing-order="0" hint="">
    <node index="0" text="Toast" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Save" resource-id="save" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][139,234]" drawing-order="0" hint=""/>
    <node index="2" text="Saved" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,232][1049,273]" drawing-order="0" hint=""/>
  </node>
</node>

The node. The live-region attribute did not appear in the dump. aria-live="polite" left no marker in the XML; what you get is the message as a plain View with text="Saved", and it is gone once the toast clears. Appium’s attribute API documents live-region and may expose it on some builds, but do not count on seeing it in a raw dump. Updating the region does fire an AccessibilityEvent.

Locator.

Playwright
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');
Appium
// assert right after the action, then assert that it clears
driver.findElement(AppiumBy.xpath("//*[@text='Saved']"));

The trap. An auto-hiding toast can clear before your assertion runs. Assert the message soon after the action, and if you need to prove it vanished, assert the clearing too. sleep(3000) and a single check pass or fail with the animation.

2.11 Data table

Open in a tab
<table id="rates-table">
  <caption>Exchange rates (SGD)</caption>
  <thead>
    <tr>
      <th scope="col" aria-sort="ascending"><button type="button" data-key="currency">Currency</button></th>
      <th scope="col" aria-sort="none"><button type="button" data-key="rate">Rate</button></th>
    </tr>
  </thead>
  <tbody>
    <tr><td>USD</td><td>1.34</td></tr>
    <tr><td>MYR</td><td>0.29</td></tr>
    <tr><td>EUR</td><td>1.45</td></tr>
  </tbody>
</table>
<node index="0" text="Sortable table and a windowed list" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="rates" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,451]" drawing-order="0" hint="">
    <node index="0" text="Exchange rates" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Exchange rates (SGD)" resource-id="rates-table" class="android.widget.GridView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][912,451]" drawing-order="0" hint="">
      <node index="0" text="Exchange rates (SGD)" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][912,204]" drawing-order="0" hint=""/>
      <node index="1" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,200][912,265]" drawing-order="0" hint="">
        <node index="0" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,200][912,265]" drawing-order="0" hint="">
          <node index="0" text="Currency ↑" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,200][557,265]" drawing-order="0" hint="">
            <node index="0" text="Currency" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[45,212][145,251]" drawing-order="0" hint=""/>
            <node index="1" text=" ↑" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[143,216][164,247]" drawing-order="0" hint=""/>
          </node>
          <node index="1" text="Rate ↑" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[555,200][912,265]" drawing-order="0" hint="">
            <node index="0" text="Rate" resource-id="" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[571,212][622,251]" drawing-order="0" hint=""/>
            <node index="1" text=" ↑" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[620,216][641,247]" drawing-order="0" hint=""/>
          </node>
        </node>
      </node>
      <node index="2" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,261][912,326]" drawing-order="0" hint="">
        <node index="0" text="USD" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,261][557,326]" drawing-order="0" hint=""/>
        <node index="1" text="1.34" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[555,261][912,326]" drawing-order="0" hint=""/>
      </node>
      <node index="3" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,324][912,389]" drawing-order="0" hint="">
        <node index="0" text="MYR" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,324][557,389]" drawing-order="0" hint=""/>
        <node index="1" text="0.29" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[555,324][912,389]" drawing-order="0" hint=""/>
      </node>
      <node index="4" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,385][912,451]" drawing-order="0" hint="">
        <node index="0" text="EUR" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,385][557,451]" drawing-order="0" hint=""/>
        <node index="1" text="1.45" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[555,385][912,451]" drawing-order="0" hint=""/>
      </node>
    </node>
  </node>
</node>

The node. The <section> is a View (resource-id="rates") and the <table> inside it became a GridView (resource-id="rates-table") named by its caption (text="Exchange rates (SGD)"). Each <td> and <th> is a plain View with text. The header is a View (text="Currency ↑") wrapping a Button and a separate TextView holding the CSS arrow. scope and aria-sort have no Android equivalent I could find. The tree keeps node order and drops scope and aria-sort, so match cells by ordinal position.

Locator.

Playwright
const usd = page.getByRole('row', { name: /USD/ });
await expect(usd).toContainText('1.34');
await expect(page.getByRole('columnheader', { name: 'Currency' }))
.toHaveAttribute('aria-sort', 'ascending');
Appium
// the table is a GridView; cells are plain Views with text
driver.findElement(AppiumBy.xpath(
"//android.widget.GridView[@resource-id='rates-table']//*[@text='1.34']"));

The trap. The tree drops row and column relationships, so assert named cell values. Keep row and columnheader assertions in Playwright, where those roles exist.

2.12 Virtualised / infinite list

Open in a tab
<!-- 200 transactions. The script replaces these children on scroll, so only the
     visible slice is ever in the DOM. This is the slice from the capture; the
     count depends on the viewport height. -->
<div id="feed" class="scroller" aria-label="Transactions" tabindex="0">
  <p>TX0000</p>
  <p>TX0001</p>
  <p>TX0002</p>
  <p>TX0003</p>
  <p>TX0004</p>
  <p>TX0005</p>
  <p>TX0006</p>
  <p>TX0007</p>
  <p>TX0008</p>
  <p>TX0009</p>
  <p>TX0010</p>
  <p>TX0011</p>
  <p>TX0012</p>
</div>
<node index="0" text="Sortable table and a windowed list" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="feed-section" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,520]" drawing-order="0" hint="">
    <node index="0" text="Transactions" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Transactions" resource-id="feed" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,520]" drawing-order="0" hint="">
      <node index="0" text="TX0000" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,177][1036,236]" drawing-order="0" hint=""/>
      <node index="1" text="TX0001" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,232][1036,290]" drawing-order="0" hint=""/>
      <node index="2" text="TX0002" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,288][1036,345]" drawing-order="0" hint=""/>
      <node index="3" text="TX0003" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,343][1036,400]" drawing-order="0" hint=""/>
      <node index="4" text="TX0004" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,398][1036,455]" drawing-order="0" hint=""/>
      <node index="5" text="TX0005" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,453][1036,510]" drawing-order="0" hint=""/>
      <node index="6" text="TX0006" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,508][1036,565]" drawing-order="0" hint=""/>
      <node index="7" text="TX0007" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,563][1036,620]" drawing-order="0" hint=""/>
      <node index="8" text="TX0008" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,618][1036,675]" drawing-order="0" hint=""/>
      <node index="9" text="TX0009" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,673][1036,730]" drawing-order="0" hint=""/>
      <node index="10" text="TX0010" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,728][1036,785]" drawing-order="0" hint=""/>
      <node index="11" text="TX0011" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,781][1036,840]" drawing-order="0" hint=""/>
      <node index="12" text="TX0012" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[43,836][1036,895]" drawing-order="0" hint=""/>
    </node>
  </node>
</node>

The node. The capture shows TX0000 through TX0012 and nothing else. That is the realised window. The tree omits rows you scrolled past and rows that never loaded, so TX0042 does not exist yet.

Locator.

Playwright
const item = page.getByText('TX0042');
await item.scrollIntoViewIfNeeded();
await expect(item).toBeVisible();
Appium
driver.findElement(AppiumBy.xpath("//*[@text='TX0001']"));
// ... scroll the container ...
driver.findElement(AppiumBy.xpath("//*[@text='TX0042']"));

The trap. findElements(...).size() on a windowed list counts the realised window, so it reports “10 items” for a 200-row list. Assert specific items, and scroll before you look (§3.4 has the loop).

2.13 Canvas / chart

Open in a tab
<canvas id="chart" width="300" height="120"></canvas>
<p id="chart-alt">Balance trend: up 12% over 30 days.</p>
<details>
  <summary>Accessible data table</summary>
  <table>
    <thead><tr><th scope="col">Day</th><th scope="col">Balance</th></tr></thead>
    <tbody>
      <tr><td>1</td><td>1,120</td></tr>
      <tr><td>15</td><td>1,180</td></tr>
      <tr><td>30</td><td>1,254</td></tr>
    </tbody>
  </table>
</details>
<node index="0" text="Canvas with a text alternative" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,550]" drawing-order="0" hint="">
    <node index="0" text="Balance trend" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="" resource-id="chart" class="android.widget.Image" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][624,404]" drawing-order="0" hint=""/>
    <node index="2" text="Balance trend: up 12% over 30 days." resource-id="chart-alt" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,442][1049,483]" drawing-order="0" hint=""/>
    <node index="3" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,510][1049,550]" drawing-order="0" hint="">
      <node index="0" text="Accessible data table" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,510][1049,550]" drawing-order="0" hint=""/>
    </node>
  </node>
</node>

The node. A canvas does produce a node, but an empty one: android.widget.Image with resource-id="chart" and no text or content-desc. The node carries an id but no text to assert. The text alternative beside it (resource-id="chart-alt") is the only thing carrying the data. The canvas offers no markup to locate.

The trap. The canvas gives you nothing to match, so ship the data as text, as a hidden table, or as an aria-label on the canvas, and assert that. If the chart is the product, test the data layer behind it.

2.14 Async validation and delayed content

Open in a tab
<label for="ref">Reference code</label>
<input id="ref" aria-describedby="ref-status" autocomplete="off">
<p id="ref-status" role="status"></p>

<button id="load-details">Load details</button>
<div id="details"></div>

<button id="load-profile">Load profile</button>
<div id="profile" aria-busy="false"></div>
<node index="0" text="Async validation, delayed content, skeleton" resource-id="" class="android.webkit.WebView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,79][1080,2340]" drawing-order="0" hint="">
  <node index="0" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,294]" drawing-order="0" hint="">
    <node index="0" text="Reference code" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,108][1049,151]" drawing-order="0" hint=""/>
    <node index="1" text="Reference code" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,165][1049,204]" drawing-order="0" hint=""/>
    <node index="2" text="ABC" resource-id="ref" class="android.widget.EditText" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,210][359,249]" drawing-order="0" hint="Reference code Too short"/>
    <node index="3" text="Too short" resource-id="ref-status" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,257][1049,294]" drawing-order="0" hint=""/>
  </node>
  <node index="1" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,332][1049,497]" drawing-order="0" hint="">
    <node index="0" text="Details" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,371][1049,414]" drawing-order="0" hint=""/>
    <node index="1" text="Load details" resource-id="load-details" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,428][221,497]" drawing-order="0" hint=""/>
    <node index="2" text="" resource-id="details" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,495][1049,495]" drawing-order="0" hint=""/>
  </node>
  <node index="2" text="" resource-id="" class="android.view.View" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,534][1049,701]" drawing-order="0" hint="">
    <node index="0" text="Profile" resource-id="" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,575][1049,618]" drawing-order="0" hint=""/>
    <node index="1" text="Load profile" resource-id="load-profile" class="android.widget.Button" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,630][217,701]" drawing-order="0" hint=""/>
    <node index="2" text="" resource-id="profile" class="android.widget.TextView" package="com.example.androidapp" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[29,697][1049,697]" drawing-order="0" hint=""/>
  </node>
</node>

The node. The status changed in place. After the input event, the field’s hint read Reference code Too short, and the status node (resource-id="ref-status") read Too short. Delayed content arrives as new nodes after a round trip. Both break naive waits, so §3 covers them.

3. Dynamic content, where tests fail

Production pages load and re-render while your test runs, which causes the failures in this section.

3.1 Wait for a condition instead of a clock

Playwright auto-waits on actionability, and its web-first assertions retry. await expect(locator).toBeVisible() polls until it is true or the timeout expires. waitForTimeout skips the retry and asserts once.

Appium’s auto-wait is narrower. UiAutomator2 waits for you only on the -android uiautomator strategy, through the waitForSelectorTimeout setting (10 s by default). With id, accessibilityId, or xpath, it does not wait at all. Nothing in the driver waits for a state change, so write the wait yourself:

Appium
new WebDriverWait(driver, Duration.ofSeconds(10))
.pollingEvery(Duration.ofMillis(200))
.until(d -> d.findElement(AppiumBy.xpath("//*[@text='Transfer scheduled']")).isDisplayed());

The demo pages give you a calibration point. Their validation debounce is 700 ms and their delayed panel arrives at 900 ms, so 2 s covers in-place state and 5 s covers a round trip. The waits to start from:

SituationWait
Cold WebView warm-uptwo getPageSource() reads, then 500 ms
Navigation or full page load10 to 20 s explicit
In-place async status (validation)2 to 3 s
Delayed content (a details panel)5 s
Toast presence1 to 1.5 s, then a 3 s wait for absence
List scroll settle300 to 500 ms per swipe

The validation and delayed-panel numbers come from the demo code. The other rows are starting points; tune them against your app.

3.2 Re-find, do not cache elements

An Appium WebElement holds a node id from the last page-source snapshot. Re-render the page and that id stops resolving, so the call throws. Wrap the find in a retry that catches StaleElementReferenceException:

Appium
private WebElement refind(By by) {
return new WebDriverWait(driver, Duration.ofSeconds(5))
.pollingEvery(Duration.ofMillis(200))
.ignoring(StaleElementReferenceException.class)
.until(d -> d.findElement(by));
}

This re-finds an element that already exists; pair it with the wait in §3.1 when the element has not appeared yet. Playwright draws the same line with different names: a Locator re-resolves on every action, while an ElementHandle holds one resolution that can go stale. Prefer Locator.

3.3 The lazy tree

With no accessibility service bound, the first dump of a fresh WebView returns a bare WebView node, and the second returns the page. A suite that opens a fresh dump per action reads an empty tree once. Dump twice at session start, or bind an accessibility service. I measured this with uiautomator dump; check whether your Appium getPageSource() shows the same thing on a cold WebView before you rely on it.

3.4 Scroll a windowed list until the item is realised

A windowed list (§2.12) builds only the rows near the viewport, so finding an item is a loop: scroll, wait, re-find. The container reports scrollable="true" and carries its name, which is what you drive.

Appium
private WebElement scrollUntil(By container, By target, int maxSwipes)
throws InterruptedException {
for (int i = 0; i < maxSwipes; i++) {
List<WebElement> found = driver.findElements(target);
if (!found.isEmpty()) return found.get(0);
boolean canScrollMore = (Boolean) driver.executeScript("mobile: scrollGesture", Map.of(
"elementId", ((RemoteWebElement) driver.findElement(container)).getId(),
"direction", "down",
"percent", 0.8));
if (!canScrollMore) break;
Thread.sleep(400);
}
throw new NoSuchElementException("not realised after " + maxSwipes + " swipes: " + target);
}

Swipe about 80 percent of the container each time and cap the loop at 20 or 30 swipes. mobile: scrollGesture returns whether the container can still scroll in that direction, so the loop can stop when it returns false instead of burning every swipe.

3.5 Bounds move, or read zero

bounds read mid-transition describe a moving layout. Nodes outside the viewport can report bounds="[0,0][0,0]", including every node in a dump taken mid-scroll, so re-dump after the page settles before you act on bounds. Appium exposes waitForIdleTimeout. The UIAutomator test library added UiObject2.waitForStable() in 2.4.0 as a Kotlin extension, and the Appium driver does not expose it, so poll the bounds yourself:

Appium
private Rectangle stableRect(WebElement el) throws InterruptedException {
Rectangle previous = el.getRect();
for (int i = 0; i < 8; i++) {
Thread.sleep(250);
Rectangle current = el.getRect();
if (current.equals(previous)) return current;
previous = current;
}
throw new IllegalStateException("bounds never stabilised: " + previous);
}

3.6 Skeletons and optimistic UI

The demo marks its skeleton aria-hidden, so the skeleton never reaches the tree. If your app shows a skeleton with the same role and name as the final content, a role locator matches the placeholder instead of the result. Hide the skeleton from the tree, or assert on content that exists only in the final state.

3.7 Time and randomness

A relative timestamp, a generated id, or a counter can change the value your assertion expects, so the assertion passes on today’s data and fails on next week’s. You cannot freeze the clock inside a WebView through Appium. Normalise the value with a pattern like /Updated \d+ minutes? ago/, or have the app read the time from a test hook you can set. Assert only values the app controls.

4. Triage

Find your symptom, then read the section it points at.

Nothing is in the tree

SymptomCauseFix
The first dump is a bare WebView, or a handful of nodesWebView builds the tree on first access, and this is that first readRead twice, or bind an accessibility service (§3.3)
A list item is “missing”A windowed list has not realised that rowScroll and re-find; assert rows you scrolled to (§3.4)
The native select’s options are not in the treeThe open picker is a separate windowDump with --windows, or switch the Appium window (§2.4)

The locator finds nothing, or the wrong thing

SymptomCauseFix
Two nodes match, or the tap lands on the wrong controlDuplicate names, or a modal that leaves the background in the treeHide the background with inert and aria-hidden (§2.8); rename duplicate controls (§2.9)
The node is clickable, with empty text and NAF="true"An icon button with no aria-labelAdd aria-label instead of tapping by coordinates (§2.5)
A role locator matches the placeholderA skeleton shares the final content’s roleHide the skeleton from the tree, or assert final-only content (§3.6)
resource-id is emptyYour WebView version does not pass the HTML id throughLocate by role and name; treat resource-id as a bonus (§5, §6)
A data-testid locator finds nothing in NATIVE_APPTest ids never enter the accessibility treeLocate by role and name, or copy the value into an id for a tree handle (§6)
A Playwright role locator fails on the devicePlaywright cannot drive the WebViewRun it against the standalone page or over CDP; use Appium for the tree (§1)
The locator works in debug and fails in releaseIt depends on the DOM path, which needs the debug socketLocate by role and name in the native context (§1)

The action does not land

SymptomCauseFix
The tap does nothing, or “element not interactable”A readonly control, or a node off screen with bounds="[0,0][0,0]"Drop readonly or click the popup owner (§2.4); scroll into view, re-dump, then tap (§3.4, §3.5)
Every bounds reads [0,0][0,0]Someone took the dump mid-scrollSettle, re-dump, then read bounds (§3.5)
A locator works, then throws on the next actionThe element holds a node id from an old snapshotRe-find inside a wait (§3.2)

You cannot assert the result

SymptomCauseFix
You cannot assert a checkbox or radio stateWebView 140 does not expose checkable/checkedAssert the effect the selection has on the page (§2.3)
The toast assertion is flakyThe toast clears itself while the assertion runsAssert the message within 1.5 s, then assert it clears (§2.10)

5. Locator mechanics

Appium and UiAutomator2 strategies (4):

StrategyMaps toWhen to use it
accessibilityIdcontent-desc (Android), name (iOS)native controls that carry a content description
idresource-idweb controls whose HTML id came through. It did on WebView 140, it did not on some earlier versions, so do not rely on it as the primary handle.
classNamethe Android node class, such as android.widget.Buttonpair it with text. It matches the node class, NOT the HTML class or React’s className, which never enter the tree
-android uiautomatorUiSelectortext and textContains matching, though Google is deprecating UiSelector
xpaththe page-source XMLrole + name when nothing else fits; slower, snapshot-based

Appium element attributes to assert: text/name, content-desc, resource-id, bounds, password, clickable, enabled, focused, selected, hint, and label on iOS. The driver also documents checkable/checked, content-invalid, live-region and heading. The WebView 140 dumps above carry password and resource-id but not checked or live-region, so verify before you assert them.

Playwright locators (2): reach for getByRole first, then getByLabel, getByPlaceholder, getByText, and getByTestId last. .filter({ hasText }) and chained locators handle scoping. Strict mode is on by default, so a call that matches two elements fails. expect(locator) waits for the assertion to pass, and toMatchAriaSnapshot pins structure.

React Testing Library (1) queries the DOM the same way Playwright does, because both use the same accessible-name computation. The queries and the scoping map one to one:

IntentReact Testing LibraryPlaywright
role + namescreen.getByRole('textbox', { name: 'Email' })page.getByRole('textbox', { name: 'Email' })
labelscreen.getByLabelText('Email')page.getByLabel('Email')
descriptionscreen.getByRole('textbox', { description: /receipts/i })same options
scope to a rowwithin(screen.getByText('Transfer to Alice').closest('li')!).filter({ hasText: 'Alice' })
inside a dialogwithin(screen.getByRole('dialog'))page.getByRole('dialog')

Two caveats apply. RTL runs in jsdom by default, so there is no layout and no real browser. toBeVisible reads only attributes and inline styles, and the accessible-name computation comes from dom-accessibility-api, which implements a subset of ARIA. Use it to pin a component’s role-and-name contract; it cannot reach the Android tree.

6. Test ids, and how far they travel

A test id is a DOM handle. The major tools read it, each with its own attribute name, and none of them enters the accessibility tree.

Who reads which attribute

AttributeMarkupReader
data-testid<button data-testid="submit-order">Testing Library getByTestId('submit-order'), Playwright page.getByTestId('submit-order')
data-cy<button data-cy="submit">Cypress cy.get('[data-cy="submit"]')
data-test<button data-test="submit">Cypress apps (getBySel helper)
data-qa<button data-qa="submit">any CSS selector in a DOM tool
data-pw<button data-pw="submit">Playwright, after testIdAttribute: 'data-pw'

The name is a convention, not a capability. Each tool reads whichever attribute you configure: Testing Library through testIdAttribute, Playwright through testIdAttribute or selectors.setTestIdAttribute().

What it buys, what it costs

A test id survives changes to copy and roles, holds across locales, and records in the markup that a test depends on this element. Cypress frames the decision: if you would want the test to fail when the element’s text changes, match the text; if not, use a data attribute.

The cost is that it is not user facing. Playwright calls test ids “the most resilient way of testing”, because “even if your text or role … changes, the test will still pass”, and then warns they are “not user facing” 2. The same resilience hides breakage: replace a <button> with a clickable <div> and the test stays green while the control loses its role and its keyboard access. Testing Library’s lint rule names the other half: it couples the test “to the implementation details of your components, and not to how they behave” 5.

Where it belongs. Use one where nothing semantic can name the control: a wrapper with no role, a custom widget, or markup you do not own. A per-record label is greyer: §2.9 gives each row’s action an accessible name, and a name-based locator still matches it per record, so reach for a test id there only when the copy is translated or redrawn. Do not use one for an icon button with no name; that is a bug, and the fix is a name.

Who uses them, and with what format

ProjectAttributeFormatExample
GitLabdata-testidkebab-case; value must match the page-object namedata-testid="edit-button"
Grafanadata-testidhuman-readable phrasedata-testid="Username input field"
HMCTS (UK courts)data-testid{component}-{element}-{type}case-list-jurisdiction-select
Appsmithdata-testidlint-enforced regex ^t--[a-zA-Z0-9_.-]*$data-testid="t--submit"
Primer (GitHub)data-componentPascalCase, ComponentName.Partdata-component="Button.Content"
Sentrydata-test-idno value conventionconfigureRtl({ testIdAttribute: 'data-test-id' })
Atlassian AtlaskittestId prop{string}--{descriptor}testId="testmodal--dismissButton"
Shopify (mobile)React Native testIDn/a, opt-inUNSAFE_testID

Read that column carefully: only the first four use data-testid. Primer ships data-component, Sentry spells it data-test-id, and Shopify’s is a React Native prop, not an HTML attribute at all. Enforce whichever you pick with consistent-data-testid (a regex you write; Appsmith’s is the only public one I found), or ban test-id queries outright with no-test-id-queries.

Who skips them

ProjectPractice, with its source
YouTubezero data-testid attributes on the logged-in page, 6; the reason is unknown, so this proves absence, not method
Sentryforced real <button>s; the engineer behind the change has “not used a data-testid attribute in over a decade” (7)
Shopify (mobile)moved E2E to computer vision; test ids are opt-in through an UNSAFE_testID field, “the naming itself discourages using them” (8)
Cash App (Block)snapshot-tests the accessibility tree itself, on iOS (AccessibilitySnapshot) and Android (Paparazzi): “these properties are what would be read out by screen readers your customers might use” (9)
GOV.UK / GDScomponents are tested against accessibility acceptance criteria and assistive technology; test ids appear nowhere in the guidance (10)
Angular Materialcomponent harnesses: a test uses “a supported API”, so it is insulated from DOM-structure changes instead of selecting nodes (11)
Microsoftthe platform’s stable locator is the accessibility layer: AutomationId, not an HTML test attribute (12)
Android guidancecontentDescription is for users; test tags belong in testTag, “if you add tags that aren’t important to humans, you effectively make your app harder to use for those who rely on accessibility” (13)

The honest caveat: zero test ids does not prove a team locates by role and name. It can also mean internal tooling. The two examples you can copy are the stated policies, Sentry’s and Shopify’s, and both pair the move with dedicated accessibility testing, because no locator strategy is an accessibility test.

Why the scale tips this way anyway. A name is copy. A translator rewrites it, and a label generated per record differs per record; a test id survives both. Grafana moved off aria-label selectors because the labels were “not that useful, or may be out of date”, and labelling everything “makes translating Grafana difficult” 14. The scale is visible: Facebook’s logged-in page carries 116 data-testid attributes, Twitter’s 74.

In NATIVE_APP a test id does nothing. Everything above lives in the DOM context, where the tools do the matching. Android is different: Blink keeps only semantic properties in the accessibility tree, so data-testid never reaches it. Four routes put a test id to work:

  • DOM context: driver.context('WEBVIEW_<package>') plus chromedriver, then By.cssSelector('[data-testid="x"]'). Needs the debug socket and a matching chromedriver.
  • Playwright over CDP, or against the standalone page (§1).
  • Copy the value into an id at build time. Chromium feeds the HTML id into the node’s resource-id, so the value arrives as resource-id="x" in the dump. This is the only route that works in NATIVE_APP, and §5 already said what it is: a handle, not the primary handle.
  • On Compose, not a WebView: Modifier.testTag with testTagsAsResourceId, or Appium’s mapTestTagToResourceId.

The copy-to-id trap. The third route is the fragile one, because it puts an unchecked value where HTML checks one: HTML requires an id to be unique in its tree, data-testid has no such rule, and a component rendered once per row repeats its test id by construction. Copy the values as they are and the ids collide. When they do, getElementById, querySelector, label for, fragment scrolling and the ARIA reference attributes all resolve to the first match, and WCAG’s H93 technique records the consequence: a duplicate id leaves the second field with no accessible name. On Android the collision is quieter. UiSelector.resourceId picks “the first widget in the tree”, W3C WebDriver returns the first match, and Appium documents its find as “the first element that matches”. A duplicate does not throw; the test taps the wrong node and keeps passing. If you take the route, namespace the generated value (test-<value>) so it cannot meet a hand-written id, keep the value a valid CSS identifier, and pin a WebView version: WebView 122 to 126 stopped exporting resource-id, and 127 restored it.

That trap is about uniqueness, not about a11y. Copying a value into id is accessibility-neutral: an id adds no role and no name and serves only as a reference target. Putting the same value into aria-label is not neutral, which is why the tree side must not be faked.

Systems people have built. Some teams treat a naming convention seriously, but no standard emerged. The solo data-testid-guidelines repo proposes a dot-separated component hierarchy (MyComponent.NameList.NameListItem.0), with a shared TypeScript testId prop to thread the hierarchy through components 15; seven GitHub stars and no adoption. Mailgun and InboxReady draft a namespace-anchored structure (mg.dashboard.deliveredStatCard.percentage) in an internal gist whose comments show the convention mid-debate 16. DHIS2, a large health-data platform, was prompted to build ids after an external test team’s entire audit “complained about dhis2 being not designed to be testable” 17; the practitioners settled on “unique (per app) and constant”. Appsmith’s consistent-data-testid regex is the one public lint config I found 18. Cypress, the maker of the tool, governs its own ids in reverse, limiting data-cy (“we should rarely, if ever, rely on data-cy alone”) 19. Vaadin ships setDataTestId, and Atlassian Atlaskit ships a first-class testId prop 20; the design-systems route is real, because it is the component library, not the page, that guarantees the id. Whoever you follow, the honest accounting is that none of these published adoption numbers; the closest to numeric practitioner evidence is Skai’s migration of ~310 E2E tests to Playwright across 22 squads 21.

So should your suite use them? Both, in different places. The accessible name is the contract your users and your release build depend on, and it is what an axe-core accessibility scan reads. Test ids keep a DOM-path suite stable across locales and refactors, which pays for itself in a large localized app. Keep them out of NATIVE_APP; where a native handle is unavoidable, the build-time id copy works with a namespace and a pinned WebView version, and it stays a fallback: a locator that has only that handle fails on any WebView version that drops the mapping.

7. Putting it together

Both halves run one flow. The Playwright half runs against the page served standalone; the Appium half reads the same page through the accessibility tree. Start from the all-patterns fixture.

Playwright
// Playwright: one flow, dynamic bits included
test('transfer from a saved recipient', async ({ page }) => {
await page.goto('/fixture.html');
await page.getByLabel('Email').fill('qa@example.com');
await page.getByLabel('Password').fill('hunter2');
await page.getByRole('button', { name: 'Show password' }).click();
// duplicate row actions: scope to the row
await page.getByRole('listitem').filter({ hasText: 'Alice' })
.getByRole('button', { name: 'Edit' }).click();
// async validation: wait for the message, not a timer
await page.getByLabel('Reference code').fill('ABC123');
await expect(page.getByRole('status')).toHaveText('Valid');
await page.getByRole('button', { name: 'Load details' }).click();
await expect(page.getByText('Recipient: Alice')).toBeVisible();
});
Appium
// Appium (NATIVE_APP): the same flow, accessibility tree only
driver.getPageSource(); // warm it, the first read can be incomplete
driver.getPageSource();
// the label is in hint, not text, so match the id (from the HTML id)
driver.findElement(AppiumBy.xpath("//android.widget.EditText[@resource-id='pw']"));
// aria-pressed makes this a ToggleButton, not a Button
driver.findElement(AppiumBy.xpath("//android.widget.ToggleButton[@text='Show password']")).click();
// the unique aria-label is what makes this row addressable
driver.findElement(AppiumBy.xpath("//android.widget.Button[@text='Edit transfer to Alice']")).click();
React Testing Library
// The same flow as a component test. No tree, no bounds, just roles and names.
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('transfer from a saved recipient', async () => {
const user = userEvent.setup();
render(<Transfer />);
await user.type(screen.getByLabelText('Email'), 'qa@example.com');
await user.type(screen.getByLabelText('Password'), 'hunter2');
await user.click(screen.getByRole('button', { name: 'Show password' }));
const row = screen.getByText('Transfer to Alice').closest('li')!;
await user.click(within(row).getByRole('button', { name: 'Edit' }));
await user.type(screen.getByLabelText('Reference code'), 'ABC123');
expect(await screen.findByRole('status')).toHaveTextContent('Valid');
});

8. Further reading

Sources I read but did not cite inline, grouped by what they support.

Testing tool and lint documentation

Team conventions

Standards

Android and Chromium

Footnotes

  1. Testing Library: query priority ↩ ↩2

  2. Playwright: locators ↩ ↩2 ↩3

  3. Captured on a OnePlus 6T (ONEPLUS A6013), Android 15 (API 35), AOSP WebView 140.0.7339.207, loaded through a minimal WebView host app. Each XML block is the second of two adb exec-out uiautomator dump /dev/tty runs on a freshly loaded page. Regeneration notes live in this post’s AGENTS.md in the repository. ↩

  4. Appium UiAutomator2 driver README, covering locator strategies, element attributes (live-region, content-invalid, heading), mapTestTagToResourceId, and the UiSelector deprecation. ↩

  5. eslint rule: no-test-id-queries ↩

  6. Testing Library issue #479, the measured production-page counts: Facebook 116 data-testid attributes, Twitter 74, YouTube 0. ↩

  7. TkDodo: Test IDs are an a11y smell ↩

  8. Shopify engineering: mobile E2E ↩

  9. Cash App: AccessibilitySnapshot ↩

  10. GOV.UK service manual: accessibility for developers ↩

  11. Angular Material: component harnesses ↩

  12. Microsoft: Using UI Automation for Automated Testing ↩

  13. Android Developers: composables from UiAutomator ↩

  14. Grafana: aria-label migration issue ↩

  15. data-testid-guidelines ↩

  16. Mailgun/InboxReady test-id gist ↩

  17. DHIS2 issue #54 ↩

  18. Appsmith’s consistent-data-testid regex ↩

  19. Cypress testing strategy and styleguide ↩

  20. Atlaskit form changelog (testId prop) ↩

  21. Skai: migrating 300 E2E tests to Playwright ↩

back