Skip to content

Locators

Locators are how you tell Tapsmith which UI element to interact with. Tapsmith exposes them as Playwright-style getBy* methods on Device and ElementHandle. The guiding principle: tests should interact with the app the way users do.

Locators that reflect what users see and what assistive technologies read are preferred over locators that depend on implementation details. This makes your tests more resilient to refactors and ensures your app remains accessible.

Priority Methods When to use
1 (preferred) getByRole(), getByText(), getByDescription() Default choice. Reflects what users see.
2 (acceptable) getByPlaceholder() For text inputs without an associated label.
3 (escape hatch) getByTestId(), locator({ id }), locator({ className }) When no user-visible attribute works.
4 (discouraged) locator({ xpath }) Last resort, Android-only.

Tapsmith supports both Android and iOS. Most locators work identically on both platforms, but there is one important difference to be aware of when writing cross-platform tests.

On iOS, when a component has an explicit accessibilityLabel, child text elements are hidden from the accessibility tree. The parent becomes a single accessible element with only its label visible. On Android, child text remains individually queryable through UIAutomator.

// React Native component
<Pressable accessibilityLabel="Login Form" accessibilityRole="button">
<Text>Login Form</Text>
<Text>Text inputs, buttons, focus/blur, keyboard</Text> {/* ← hidden on iOS */}
</Pressable>

On Android, device.getByText("Text inputs, buttons, focus/blur, keyboard") finds the child Text element. On iOS, this text does not exist in the accessibility tree — only the parent with description "Login Form" is visible.

Recommended cross-platform patterns:

  1. Target the parent by role or description, not the child text:

    await device.getByRole("button", { name: "Login Form" }).tap()
    await device.getByDescription("Login Form").tap()
  2. Use getByTestId() for elements that must be individually addressable:

    await expect(device.getByTestId("login-description")).toBeVisible()
  3. Use a separately accessible status element for verifying state changes:

    // Instead of checking child text inside a labeled container:
    // ✗ await expect(device.getByText("Long pressed!")).toBeVisible()
    // Check a dedicated status element with its own testID:
    // ✓ await expect(device.locator({ id: "last-gesture" })).toHaveText("Last gesture: Long press")

Priority 1 — Accessible Locators (Preferred)

Section titled “Priority 1 — Accessible Locators (Preferred)”

These match what real users and assistive technologies see. They should be your default choice.

Find an element by its accessibility role, optionally filtered by its accessible name. This is the top recommended locator because it verifies your app is accessible while also being resilient to implementation changes.

// Find a button labeled "Submit"
await device.getByRole("button", { name: "Submit" }).tap()
// Find a text field labeled "Email"
await device.getByRole("textfield", { name: "Email" }).type("user@example.com")
// Find a checkbox
await device.getByRole("checkbox", { name: "Remember me" }).tap()
// Find a switch toggle
await device.getByRole("switch", { name: "Dark mode" }).tap()

As in Playwright, name is a case-insensitive substring match by default, so { name: "sign in" } finds a button labelled SIGN IN (how Android renders React Native’s <Button title="Sign In">) or Sign in now. Pass exact: true for a case-sensitive, whole-string match — needed when one name is part of another on the same screen:

await device.getByRole("button", { name: "Show overlay", exact: true }).tap() // not "Show overlay briefly"

Supported roles map to platform-native element types. On iOS, many React Native components render as XCUIElementTypeOther — Tapsmith identifies roles from accessibility traits (e.g. a Pressable with accessibilityRole="button" gets the button trait) and, for roles iOS has no trait for, from the role description React Native puts in the element’s accessibility value ("checkbox, unchecked"). A plain .other element with neither is never matched by an unnamed getByRole(); a named one (getByRole("alert", { name: "…" })) still finds it by its name, unless a role trait or another of those descriptions marks it as a different role. Native iOS and Android apps use typed elements that map directly.

Role Android classes iOS types
button Button, ImageButton, ToggleButton (also RN accessibilityRole="togglebutton"), Material/AppCompat variants XCUIElementTypeButton, .other with button trait (RN togglebutton too)
textfield EditText XCUIElementTypeTextField, XCUIElementTypeSecureTextField
checkbox CheckBox XCUIElementTypeCheckBox, .other described as a checkbox (RN), or a named .other
switch Switch XCUIElementTypeSwitch, XCUIElementTypeToggle
radiobutton / radio RadioButton XCUIElementTypeRadioButton, .other described as a radio button (RN), or a named .other
heading RN accessibilityRole="header" / native isHeading any element with the header trait (plain static text is text, not a heading)
link RN accessibilityRole="link" XCUIElementTypeLink, .other with link trait
image ImageView or RN accessibilityRole="image" XCUIElementTypeImage, .other with image trait
text TextView XCUIElementTypeStaticText
alert RN accessibilityRole="alert" .other described as an alert (RN old architecture), or a named .other
progressbar ProgressBar or RN accessibilityRole="progressbar" XCUIElementTypeProgressIndicator
seekbar / slider SeekBar XCUIElementTypeSlider, .other with adjustable trait
combobox RN accessibilityRole="combobox" .other described as a combo box (RN old architecture), or a named .other
searchfield / search SearchView (RN accessibilityRole="search" is a plain EditText on Android, so a textfield there) XCUIElementTypeSearchField, .other with search trait
spinner Spinner XCUIElementTypePicker, XCUIElementTypeActivityIndicator
toolbar Toolbar or RN accessibilityRole="toolbar" XCUIElementTypeToolbar
tab a tab item publishing the “Tab” role description: RN accessibilityRole="tab" (e.g. React Navigation bottom tabs), Material TabLayout and BottomNavigationView items, Compose Role.Tab XCUIElementTypeTab, or a button inside an XCUIElementTypeTabBar (a UITabBar’s items)
tablist TabWidget, TabLayout, or RN accessibilityRole="tablist" XCUIElementTypeTabBar, or a named .other (RN)
list ListView, GridView, RecyclerView XCUIElementTypeTable, XCUIElementTypeCollectionView
listitem LinearLayout, RelativeLayout, FrameLayout XCUIElementTypeCell
scrollview ScrollView, HorizontalScrollView, NestedScrollView XCUIElementTypeScrollView
menu, menubar, menuitem, radiogroup, scrollbar, spinbutton, summary, timer RN accessibilityRole of the same name a named .other

Role names are case-insensitive. Any other role throws as soon as the locator is built — Unknown role "row". Supported: alert, button, … — instead of waiting out the timeout. Playwright’s ARIA names that differ here get a hint (textbox → textfield, img → image, searchbox → searchfield), and so does React Native’s togglebutton, which is a button on both platforms (as an ARIA toggle button is) — check its state with toBeChecked(). WebView locators (webview.getByRole()) take the page’s ARIA roles instead.

Lists in React Native: list and listitem match native list widgets. A React Native FlatList, SectionList or ScrollView and its rows are not those widgets, so getByRole("list") finds nothing in them. Give the list a testID and target it with getByTestId(); target a row by its text, or by getByRole("button") when the row is a Pressable or TouchableOpacity with accessibilityRole="button".

React Native’s generic-view roles: menu, menubar, menuitem, radiogroup, scrollbar, spinbutton, summary, timer and (in React Native) tablist have no native widget. On Android, React Native publishes each as a role description, so getByRole("menuitem") finds every menu item. On iOS React Native publishes nothing Tapsmith reads for them, so give the query a name — getByRole("menuitem", { name: "Copy" }) — which finds a generic view with that label, as for alert and combobox. iOS reports no role for those views, so toHaveRole() can’t confirm one there, and it reports a React Native Text as static text whatever its role: put the role on an accessible View (<View accessible accessibilityRole="timer" accessibilityLabel="Elapsed time">) for iOS to find it. React Native publishes the descriptions in the device’s language and Tapsmith reads only the English ones, so on Android run these queries on a device set to English (the same holds for alert, combobox, tab, progressbar and toolbar).

Tabs: tab matches a tab item, and tablist the bar that holds the items. On iOS a UITabBar’s items — expo-router NativeTabs (the create-expo-app template), UIKit’s UITabBarController, SwiftUI’s TabView — are buttons inside the bar, so getByRole("tab", { name: "Explore" }) finds the item and getByRole("tablist") the bar. Those items still match getByRole("button") too, but toHaveRole() reports them as tab. On Android a tab item is known by the “Tab” role description React Native, Material’s TabLayout and BottomNavigationView, and Compose publish. Each publishes it in the device’s language and Tapsmith reads only the English word, so run tab queries on an Android device set to English. React Navigation’s bottom tabs are tabs on Android, but on iOS React Navigation gives them the button role and a label like "Settings, tab, 2 of 4", so getByRole("button", { name: "Settings" }) finds them there. A tab bar whose items publish no role at all (a custom tab bar built from plain views) has no tab to find: use getByText() or getByDescription().

React Native note: RN components that set accessibilityRole (e.g. accessibilityRole="button") expose the corresponding iOS accessibility trait, and getByRole will find them. Components that don’t set accessibilityRole (common in many RN apps) render as generic XCUIElementTypeOther with no role — use getByText, getByPlaceholder, or getByDescription instead.

Making React Native apps testable (and accessible)

Section titled “Making React Native apps testable (and accessible)”

Adding accessibility props to your RN components makes them both testable with getByRole and accessible to screen readers — a single change that improves both. The key props:

// ✗ Invisible to getByRole — renders as generic XCUIElementTypeOther
<TouchableOpacity onPress={handleSubmit}>
<Text>Sign in</Text>
</TouchableOpacity>
// ✓ Found by getByRole("button", { name: "Sign in" })
<TouchableOpacity
onPress={handleSubmit}
accessibilityRole="button"
accessibilityLabel="Sign in"
>
<Text>Sign in</Text>
</TouchableOpacity>

Common components and their recommended props:

Component Props to add Tapsmith locator
TouchableOpacity / Pressable accessibilityRole="button" getByRole("button", { name: "..." })
TextInput accessibilityLabel="Email" getByRole("textfield", { name: "Email" }) or getByPlaceholder(...)
Switch accessibilityRole="switch", accessibilityLabel="..." getByRole("switch", { name: "..." })
Image accessibilityRole="image", accessibilityLabel="..." getByRole("image", { name: "..." })
Section headers accessibilityRole="header" getByRole("heading", { name: "..." })
Links accessibilityRole="link" getByRole("link", { name: "..." })

Tip: If you can’t modify the app, getByText and getByPlaceholder work without any accessibility props. getByRole is preferred when the props are available because it verifies both the element’s function and its text, making tests more precise.

Find an element by its visible text content. Substring match by default, like Playwright. Pass { exact: true } for an exact match.

// Substring match — finds elements containing "Welcome"
await expect(device.getByText("Welcome")).toBeVisible()
// Exact match
await device.getByText("Sign In", { exact: true }).tap()
// Useful for dynamic content
await expect(device.getByText("3 items")).toBeVisible()
// A regular expression, for patterns
await expect(device.getByText(/^\d+ items?$/)).toBeVisible()

Whitespace differences don’t matter for a string: non-breaking spaces, line breaks and runs of spaces in the element’s text (or in your query) are treated as a single space, and leading and trailing whitespace is ignored — the same normalization Playwright applies. getByText("Welcome to Expo") finds a heading rendered as Welcome to&nbsp;Expo.

A RegExp (also accepted by getByLabel and getByRole’s name) is matched with JavaScript’s semantics against the text as it is: \s covers non-breaking spaces, and ^/$ anchor the whole text unless you pass the m flag. See Regular expressions for the details.

Strict mode: because of the substring default, getByText("Sign in") also matches longer text such as "Sign in to continue". A locator that resolves to more than one element throws a strict mode violation when you act or assert on it (instead of silently using the first match). Disambiguate with { exact: true }, getByRole(role, { name, exact: true }), getByTestId(), or .first()/.nth()/.last(). Absence checks (toBeHidden, not.toBeVisible, waitFor({ state: "hidden" })) and multi-element APIs (count(), all(), toHaveCount) are exempt. See the API reference for the full rules. WebView locators (webview.getBy*, webview.locator(css)) enforce the same rules.

Find an element by its accessibility description. On Android this matches contentDescription; on iOS it matches accessibilityLabel. Use this for icon buttons, images, and elements where the accessible label differs from visible text.

On iOS, getByText also matches accessibilityLabel, so both work for text-bearing elements. Prefer getByDescription for elements that have an explicit accessibility description but no visible text (e.g. icon-only buttons).

// Tap an icon button with a description
await device.getByDescription("Close menu").tap()
// Verify an image is present
await expect(device.getByDescription("Profile photo")).toBeVisible()

Find an input by its placeholder / hint text. Useful for text fields that do not have a separate visible label.

await device.getByPlaceholder("Enter your email").type("user@example.com")
await device.getByPlaceholder("Password").type("secret123")

Priority 3 — Test IDs and Native Locators (Escape Hatch)

Section titled “Priority 3 — Test IDs and Native Locators (Escape Hatch)”

These are invisible to users. Use them only when no accessible attribute uniquely identifies the element. The ESLint plugin will warn when you reach for them.

Find an element by a dedicated test identifier.

await device.getByTestId("submit-button").tap()

On Android, getByTestId matches React Native’s testID prop, which becomes the element’s whole resource ID; a native view’s package-qualified ID (com.myapp:id/submit) is found with locator({ id }) instead. On iOS, it matches the accessibilityIdentifier.

Find an element by its native resource id. On Android this is the R.id.foo resource id; on iOS, the accessibilityIdentifier.

await device.locator({ id: "com.myapp:id/email_input" }).type("user@example.com")
// Short form also works if the package prefix is unambiguous
await device.locator({ id: "email_input" }).type("user@example.com")

Warning: Resource IDs are implementation details. They break when views are refactored, renamed, or replaced. Prefer accessible locators whenever possible.

Find an element by its native widget class name. Use this when no role mapping covers a custom widget.

await device.locator({ className: "com.myapp.widget.ColorPicker" }).tap()

Tip: For standard Android widgets, prefer getByRole(). The ESLint plugin warns when locator({ className }) is used for widgets that have well-known roles.

Priority 4 — XPath (Discouraged, Android-only)

Section titled “Priority 4 — XPath (Discouraged, Android-only)”

Find an element using an XPath expression on the view hierarchy. Fragile, verbose, tightly coupled to the view structure, and Android-only (iOS does not support XPath). Use only as a last resort.

// Custom compound view with no accessible attributes
await device.locator({
xpath: "//android.widget.LinearLayout[@index='2']/android.widget.Button[1]",
}).tap()

Warning: The ESLint plugin requires an explanatory comment on the same or preceding line whenever locator({ xpath }) is used. If you find yourself reaching for XPath, consider whether adding accessibility attributes to the app would be a better long-term solution.

An XPath locator is for use on its own. and()/or() refuse an XPath operand with an error, because the agent reports an XPath match’s text and bounds from a different read path than every other selector, so it could never match the other operand. Narrowing (filter(), first()/nth()) and scoping a getBy* off one still answer queries, but actions through them fail at the agent, which cannot act on an XPath match by id.

getBy* and locator() are also available on every ElementHandle. Calling them on a parent locator scopes the search to its descendants — exactly like Playwright’s locator.locator(...).

// Find "Item 3" inside a specific list (a FlatList with testID="cart-list")
const item = device.getByTestId("cart-list").getByText("Item 3", { exact: true })
await expect(item).toBeVisible()
// Tap the delete button inside a specific row
await device.getByTestId("row-5").getByRole("button", { name: "Delete" }).tap()

Scoping is lazy — no queries are made until you call an action or assertion.

Follow this decision process:

  1. Can you identify the element by its role and name? Use getByRole().
  2. Does the element have unique visible text? Use getByText().
  3. Is it an icon or image with a description? Use getByDescription().
  4. Is it a text input with a placeholder? Use getByPlaceholder().
  5. None of the above work? Use getByTestId() or locator({ id }).
  6. Custom widget with no standard role? Use locator({ className }).
  7. Nothing else works on Android? Use locator({ xpath }) with an explanatory comment.

Tapsmith includes an ESLint plugin that enforces locator best practices in your test files.

// eslint.config.js (flat config)
import { eslintPlugin } from "tapsmith"
export default [
{
plugins: {
tapsmith: eslintPlugin,
},
rules: {
...eslintPlugin.configs.recommended.rules,
},
},
]
Rule Default Description
tapsmith/prefer-role warn Suggests getByRole() instead of locator({ className }) for standard Android widgets.
tapsmith/no-bare-locator-xpath error Requires an explanatory comment when using locator({ xpath }).
tapsmith/prefer-accessible-locators warn Suggests accessible getters instead of getByTestId() or locator({ id }).
tapsmith/prefer-app-reset-option warn Suggests test.use({ appReset, appResetScope }) when a beforeEach only restarts or clears the app.