Selectors
Selectors 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.
Selectors that reflect what users see and what assistive technologies read are preferred over selectors that depend on implementation details. This makes your tests more resilient to refactors and ensures your app remains accessible.
Priority Hierarchy
Section titled “Priority Hierarchy”| 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. |
Cross-Platform Considerations
Section titled “Cross-Platform Considerations”Tapsmith supports both Android and iOS. Most selectors work identically on both platforms, but there is one important difference to be aware of when writing cross-platform tests.
Child text inside labeled containers
Section titled “Child text inside labeled containers”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:
-
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() -
Use
getByTestId()for elements that must be individually addressable:await expect(device.getByTestId("login-description")).toBeVisible() -
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.
getByRole(role, { name? })
Section titled “getByRole(role, { name? })”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"
// Find a checkboxawait device.getByRole("checkbox", { name: "Remember me" }).tap()
// Find a switch toggleawait device.getByRole("switch", { name: "Dark mode" }).tap()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 falls back to matching .other elements by name. Native iOS and Android apps use typed elements that map directly.
| Role | Android classes | iOS types |
|---|---|---|
button | Button, ImageButton, Material/AppCompat variants | XCUIElementTypeButton, .other with button trait |
textfield | EditText | XCUIElementTypeTextField, XCUIElementTypeSecureTextField |
checkbox | CheckBox | XCUIElementTypeCheckBox, .other (RN fallback) |
switch | Switch | XCUIElementTypeSwitch, XCUIElementTypeToggle |
radiobutton | RadioButton | XCUIElementTypeRadioButton, .other (RN fallback) |
heading | RN accessibilityRole="header" / native isHeading | .other with header trait, XCUIElementTypeStaticText with header trait |
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 (RN — matched by name) |
progressbar | ProgressBar | XCUIElementTypeProgressIndicator |
seekbar / slider | SeekBar | XCUIElementTypeSlider, .other with adjustable trait |
combobox | RN accessibilityRole="combobox" | .other (RN — matched by name) |
searchfield | SearchView | XCUIElementTypeSearchField, .other with search trait |
spinner | Spinner | XCUIElementTypePicker, XCUIElementTypeActivityIndicator |
toolbar | Toolbar | XCUIElementTypeToolbar |
tab | TabLayout | XCUIElementTypeTab, XCUIElementTypeTabBar |
React Native note: RN components that set
accessibilityRole(e.g.accessibilityRole="button") expose the corresponding iOS accessibility trait, andgetByRolewill find them. Components that don’t setaccessibilityRole(common in many RN apps) render as genericXCUIElementTypeOtherwith no role — usegetByText,getByPlaceholder, orgetByDescriptioninstead.
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 selector |
|---|---|---|
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,
getByTextandgetByPlaceholderwork without any accessibility props.getByRoleis preferred when the props are available because it verifies both the element’s function and its text, making tests more precise.
getByText(text, { exact? })
Section titled “getByText(text, { exact? })”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 matchawait device.getByText("Sign In", { exact: true }).tap()
// Useful for dynamic contentawait expect(device.getByText("3 items")).toBeVisible()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 astrict mode violationwhen you act or assert on it (instead of silently using the first match). Disambiguate with{ exact: true },getByRole(role, { name }),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 do not enforce strict mode yet.
getByDescription(text)
Section titled “getByDescription(text)”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 descriptionawait device.getByDescription("Close menu").tap()
// Verify an image is presentawait expect(device.getByDescription("Profile photo")).toBeVisible()Priority 2 — Placeholder Locator
Section titled “Priority 2 — Placeholder Locator”getByPlaceholder(text)
Section titled “getByPlaceholder(text)”Find an input by its placeholder / hint text. Useful for text fields that do not have a separate visible label.
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.
getByTestId(id)
Section titled “getByTestId(id)”Find an element by a dedicated test identifier.
await device.getByTestId("submit-button").tap()On Android, getByTestId matches React Native’s testID prop (mapped to a content-description prefix). On iOS, it matches the accessibilityIdentifier.
locator({ id })
Section titled “locator({ id })”Find an element by its native resource id. On Android this is the R.id.foo resource id; on iOS, the accessibilityIdentifier.
// Short form also works if the package prefix is unambiguousWarning: Resource IDs are implementation details. They break when views are refactored, renamed, or replaced. Prefer accessible locators whenever possible.
locator({ className })
Section titled “locator({ className })”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 whenlocator({ className })is used for widgets that have well-known roles.
Priority 4 — XPath (Discouraged, Android-only)
Section titled “Priority 4 — XPath (Discouraged, Android-only)”locator({ xpath })
Section titled “locator({ xpath })”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 attributesawait 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.
Chaining and Scoping
Section titled “Chaining and Scoping”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 listconst item = device.getByRole("list", { name: "Shopping cart" }).getByText("Item 3", { exact: true })await expect(item).toBeVisible()
// Tap the delete button inside a specific rowawait device.getByTestId("row-5").getByRole("button", { name: "Delete" }).tap()Scoping is lazy — no queries are made until you call an action or assertion.
Choosing the Right Locator
Section titled “Choosing the Right Locator”Follow this decision process:
- Can you identify the element by its role and name? Use
getByRole(). - Does the element have unique visible text? Use
getByText(). - Is it an icon or image with a description? Use
getByDescription(). - Is it a text input with a placeholder? Use
getByPlaceholder(). - None of the above work? Use
getByTestId()orlocator({ id }). - Custom widget with no standard role? Use
locator({ className }). - Nothing else works on Android? Use
locator({ xpath })with an explanatory comment.
ESLint Plugin
Section titled “ESLint Plugin”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-selectors | warn | Suggests accessible getters instead of getByTestId() or locator({ id }). |