Testing
Locators
A locator names an element on the screen by what a person or a screen reader sees: its role, its text, its label. This page covers the five ways to find an element, how text matches, how to narrow a locator, and how to reach system UI.
Finding an element
Locators start from the screen fixture, which is the foreground app. They read the device's accessibility
tree, so they work the same on a native, React Native or Expo app.
| Method | Matches | On iOS | On Android |
|---|---|---|---|
getByRole(role, options?) | The element's role, and with name its accessible name | The element type and traits | The view class and accessibility flags |
getByText(text, options?) | The element's own text | A static text's label, a text input's value | text |
getByLabel(text, options?) | The accessibility label | accessibilityLabel | contentDescription |
getByPlaceholder(text, options?) | The placeholder of a text input | placeholderValue | The hint text |
getByTestId(testId) | The test id | accessibilityIdentifier | resource-id, without the package prefix |
await screen.getByRole('button', { name: 'Sign in' }).tap()
await screen.getByLabel('Email').fill('ada@ex.co')
await screen.getByPlaceholder('Password').fill('pw')
await expect(screen.getByText('Welcome back')).toBeVisible()
await expect(screen.getByTestId('count')).toHaveText('Count: 1')React Native's testID becomes the test id on both platforms. Jetpack Compose needs testTagsAsResourceId for
a test tag to reach resource-id.
A locator is lazy and holds no element. It is sent to the device with each action or assertion and resolved there, so it always finds the element as the screen is now.
How text matches
Every text argument is a string or a regular expression.
- A string matches as a case-insensitive substring.
getByText('welcome')finds "Welcome back". exact: truemakes it a case-sensitive match of the whole string.- Whitespace is normalized on both sides first: runs of spaces and line breaks count as one space.
- A regular expression is tested against the normalized text. Only the flags
i,mandsare allowed, and lookbehind, backreferences and named groups are not, because the expression runs on the device. Anything else is refused withVALIDATION. getByTestIdis the exception: a string test id matches exactly.
A substring match can find more than you meant. In the example app a stepper's button is named "Quantity: 1, Increment", which also contains "Increment", so the counter's button is matched exactly:
await screen.getByRole('button', { name: 'Increment', exact: true }).tap()Roles
getByRole takes one of these roles: alert, alertdialog, application, button, checkbox, combobox,
dialog, document, generic, group, heading, img, keyboard, link, list, listitem, menu,
menuitem, progressbar, radio, radiogroup, searchbox, slider, spinbutton, status, switch,
tab, tablist, text, textbox and toolbar. They are ARIA's names, with keyboard and text added.
Each platform maps its native elements to them:
| Role | iOS | Android |
|---|---|---|
button | A button or key, or any element with the button trait | Button, ImageButton, FloatingActionButton |
heading | The header trait | isHeading() |
textbox | A text field, secure text field or text view | EditText, or any editable view |
text | Static text | A TextView that is not editable |
switch | A switch or toggle | Switch, SwitchCompat, SwitchMaterial, ToggleButton |
list, listitem | A table, collection view or outline; a cell | RecyclerView, ListView, GridView; an item of one |
tab, tablist | A tab, or a button inside a tab bar; the tab bar | A tab view; TabLayout, TabWidget |
alertdialog | An alert | An AlertDialog window |
img | An image or icon | ImageView |
link | A link, or the link trait | A ClickableSpan, or a role description of Link |
getByRole options narrow by state as well as by name:
| Option | Type | Matches elements that |
|---|---|---|
name | string or RegExp | have this accessible name |
exact | boolean | match a string name exactly |
checked | boolean or 'mixed' | are checked, unchecked or mixed |
disabled, expanded, selected, pressed | boolean | are in that state |
level | number | are a heading of that level |
includeHidden | boolean | may have no area on screen; without it such elements are skipped |
Narrowing a locator
Locators chain. Each step searches inside the elements the step before it found.
const row = screen.getByRole('listitem').filter({ hasText: 'Oat milk' })
await row.getByRole('button', { name: 'Remove' }).tap()| Method | Keeps |
|---|---|
locator.getByRole(…) and the other four | Matching descendants of each element |
filter({ hasText }), filter({ hasNotText }) | Elements whose text, with their descendants' text, does or does not match |
filter({ has }), filter({ hasNot }) | Elements that do or do not contain a match of another locator |
filter({ visible: true }) | Visible elements only |
nth(index), first(), last() | One element by position, from 0; a negative index counts from the end |
and(other) | Elements that match both locators |
or(other) | Elements that match either locator |
describe(text) | The same elements, with a label that traces and reports show |
One element, or the action fails
Actions and assertions are strict: the locator must match exactly one element. When it matches more, the
call fails at once with LOCATOR_STRICT_VIOLATION, which lists the matches. It is not retried. Narrow the
locator, or pick one element with first(), last() or nth().
When it matches nothing, an action waits for the element until its timeout and then fails with
LOCATOR_NOT_FOUND. Counting is not strict: count(), all() and toHaveCount() accept any number of matches.
Matching ignores visibility, so an element that is in the tree but off-screen or hidden still matches. That is
what lets toBeHidden() and waitFor({ state: 'hidden' }) work. Actions wait for the element to become visible
before they act.
System UI and other apps
screen looks only at the foreground app. Other scopes reach further:
// System UI: permission prompts, system alerts, the status bar.
await screen.system.getByRole('button', { name: 'Allow' }).tap()
// The app and the system UI together.
const anywhere = screen.locator({ root: 'all' })
await expect(anywhere.getByText('Delete item?')).toBeVisible()| Scope | Looks at |
|---|---|
screen | The foreground app |
screen.system | System UI and system alerts |
screen.locator({ root: 'all' }) | The app's tree and the system's, in that order |
screen.locator({ bundleId }) | One running app by its bundle id, instead of the foreground one |
A system button's name depends on the prompt and the device's language. For permission prompts, granting the
permission ahead with the permissions option avoids the prompt altogether. The
system scope needs the ui.systemScope capability, which iOS Simulator and Android Emulator declare and Mac
Catalyst does not. An alert your own app shows is part of the app: find it with screen.
Reading an element
These methods read the element once and return a value. Prefer an assertion when you want to check something, because an assertion retries.
| Method | Returns |
|---|---|
count() | How many elements match |
all() | One locator per match |
isVisible(), isHidden() | Whether the element is visible now, without waiting |
isEnabled(), isDisabled(), isChecked(), isFocused() | The element's state, after waiting for it to exist |
textContent() | The element's own text, or its accessible name when it has no text |
inputValue() | A text input's current value |
accessibleName() | The accessible name |
boundingBox() | The frame in points: { x, y, width, height } |
node() | The whole accessibility node, or undefined when nothing matches |
ariaSnapshot() | The element's subtree as text |
screenshot() | A picture of the element as bytes: PNG, or JPEG with { format: 'jpeg' } |
screen.ariaSnapshot() returns the whole screen as text, one line per element. It is the quickest way to see
what locators can find:
- application "Shop":
- toolbar "Cart":
- button "Back"
- heading "Cart" [level=1]
- textbox "Email" [testId="email"]: "a@b.co"
- switch "Remember me" [checked]
- text: "Total: 12.00"