skip to content

Matchers, Actions & Expectations

Detox tests find elements by testID, text or label, act on them, and assert with expect or wait with waitFor, while the device API launches and reloads the app. Interviewers probe testID use.

on this pageshow

explore

questions

6

In Detox, what does element(by.id('...')) match in a React Native app, and why is it preferred over by.text?

level: juniorimportance: must knowfreq 55%

answer

  1. matched in the native hierarchy
  2. testID prop becomes the identifier
  3. copy and locale changes
  4. custom components must forward testID
  5. by.label concatenates child labels

basics

~20 s

Detox's by.id matches a native view's test identifier, which React Native sets from the testID prop. Unlike by.text, it survives copy changes and translations and is unique by design, so Detox recommends it as the default matcher.

solid answer

~40 s

`element(by.id('signup.submit'))` is resolved inside the app against the native view hierarchy: the `testID` prop becomes the accessibility identifier on iOS and the view tag on Android. I prefer it to `by.text` because text changes with copy edits and locales and repeats across screens, while an ID is stable and unique. `by.label` matches `accessibilityLabel`, which React Native can compute from child labels, so it surprises people too. The catch is that `testID` only works on built-in components; a custom `PrimaryButton` must forward it to a `Pressable` or `View`, or `by.id` finds nothing. I still use `toHaveText` to assert copy on an element found by ID.

code

typescript · 8 lines
typescript
import { by, element, expect } from 'detox';

it('signs up a new meal-kit customer', async () => {
  await element(by.id('signup.email')).typeText('[email protected]');
  await element(by.id('signup.password')).typeText('correct-horse-42');
  await element(by.id('signup.submit')).tap();
  await expect(element(by.id('plans.screen'))).toBeVisible();
});

go deeper

for a junior

Recall that by.id matches the testID prop, and give two reasons it beats by.text: copy changes and translations.

for a middle

Explain that matching happens in the native hierarchy, what testID becomes per platform, and why custom components must forward it.

for a senior

Set ID naming conventions for a team, including derived child IDs and stable row keys, and debug a missing ID through the native hierarchy.

for a principal

Decide whether test IDs ship in production builds, and how the team keeps them consistent as screens are redesigned.

## What a Detox matcher does A **Detox** test finds UI with `element(matcher)`, then either acts on it (`tap()`, `typeText()`) or asserts on it through `expect(...)`. The matcher is evaluated **inside the running app**, against the native view hierarchy that React Native rendered — not against React components or JSX. That detail explains almost everything about which matcher to choose. | Matcher | Matches on iOS | Matches on Android | Set in React Native by | |---|---|---|---| | `by.id('x')` | accessibility identifier | the view's tag | the `testID` prop | | `by.text('x')` | the element's text | the element's text | the rendered string inside `Text` or an input | | `by.label('x')` | accessibility label | content description | the `accessibilityLabel` prop | | `by.type('Cls')` | native class name | canonical class name | not set by you; differs per platform | `by.id`, `by.text` and `by.label` also accept regular expressions (`by.id(/^dish\.[0-9]+$/)`) with the `i`, `s` and `m` flags. ## Why `by.id` is the default choice The Detox docs recommend matching by unique test IDs, and interviewers expect you to say why: - **Decoupled from copy.** A product team renaming "Continue" to "Next" breaks every `by.text('Continue')` test but no `by.id` test. - **Locale-agnostic.** A suite run under another language, or with a pseudo-locale, still finds `signup.submit`; text matchers do not. - **Unique by construction.** Text such as "Continue" or "Add" appears on many screens; a well-named ID appears once, avoiding ambiguous matches. - **Stable across platforms.** `testID` maps to the platform's identifier on both iOS and Android, while `by.type` class names differ per platform. - **Fewer label surprises.** Detox's own docs warn that React Native computes accessibility labels in a non-standard way: a view with no explicit label can match the **concatenated labels of its children**, and iOS gives text elements their text as a label automatically. `by.text` still has a place — asserting that a visible string is correct, for example `expect(element(by.id('signup.error'))).toHaveText('Enter a valid postcode')` — but as an assertion on an element found by ID, not as the way to find it. ## `by.label`, `by.type` and `by.traits` in practice - **`by.label`** is tempting because it doubles as an accessibility check, but the Detox docs show the trap: a `View` with `testID='title-root'` and no label of its own, containing two `Text` children labelled `title` and `subtitle`, is matched by the label `title subtitle`. Detox aligns Android with iOS here, so the concatenation happens on both. - **`by.type`** matches a native class name, and those differ per platform — `RCTImageView` on iOS against `android.widget.ImageView` on Android — so a shared test needs a `device.getPlatform()` branch. - **`by.traits`** matches iOS accessibility traits such as `button` or `header`; it is iOS-only. These are tools for the cases IDs cannot reach, such as a third-party component that does not accept a `testID`. ## The composite-component trap `testID` is a prop of React Native's **built-in components** (`View`, `Text`, `TextInput`, `Pressable` and so on). A custom component such as `PrimaryButton` does nothing with a `testID` prop unless it forwards it to a built-in child. The Detox troubleshooting guide lists this as the first thing to check when `by.id` finds nothing: ```tsx import { Pressable, Text } from 'react-native'; type Props = { title: string; onPress: () => void; testID?: string }; export function PrimaryButton({ title, onPress, testID }: Props) { return ( <Pressable testID={testID} onPress={onPress}> <Text testID={testID ? `${testID}.label` : undefined}>{title}</Text> </Pressable> ); } ``` Deriving child IDs (`signup.submit.label`) lets a test reach a specific part of a composite without resorting to text. ## Naming IDs so tests read well 1. Prefix by screen or feature: `signup.email`, `signup.password`, `signup.submit`. 2. For repeated rows, add a stable key rather than a position: `plan.card.family-4`, not `plan.card.2`. 3. Keep IDs out of user-visible strings, so copy and translation changes never touch tests. ## The meal-kit sign-up, matched by ID ```js await element(by.id('welcome.getStarted')).tap(); await element(by.id('signup.email')).typeText('[email protected]'); await element(by.id('signup.password')).typeText('correct-horse-42'); await element(by.id('signup.submit')).tap(); await expect(element(by.id('plans.screen'))).toBeVisible(); ``` Every lookup here survives a copy change, a translation and a redesign that keeps the same controls. When a lookup fails, inspecting the native hierarchy (Xcode's view debugger on iOS) shows whether the ID reached a native view at all — React Native test IDs appear there as accessibility identifiers.

  • Why does by.id sometimes find nothing even though the testID is in the JSX?
    The prop is on a custom component that never passes it to a built-in React Native component, so no native view receives the identifier. Forward `testID` to the `Pressable`, `View` or `TextInput` the component renders, and give children derived IDs such as `signup.submit.label` if tests need them.
  • When is by.text still the right matcher?
    When the text itself is what the test is about and it is unique on screen — for example a one-off confirmation message. More often the better pattern is to find the element by ID and assert its copy with `toHaveText`, so the lookup does not break when the wording changes.

saying these in an interview costs you the question

  • by.id matches the React component's name in the JSX tree.
  • Any custom component accepts testID automatically.
  • by.text is safer because users see the text.
  • Using the same testID on several list rows is fine for tapping one.
  • by.type class names are identical on iOS and Android.
open as a page

In Detox, how do expect's toBeVisible() and toExist() differ, and when is waitFor(...).withTimeout() the right tool instead?

level: middleimportance: must knowfreq 50%

basics

~20 s

toExist() passes when the element is in the native view hierarchy; toBeVisible() also needs at least 75 % of it on screen by default. expect checks once after Detox's idle sync, while waitFor polls until a timeout, which must be set with withTimeout().

open as a page

In Detox, when should a test call device.launchApp({ newInstance: true }), device.reloadReactNative() or device.openURL() in a sign-up suite?

level: middleimportance: should knowfreq 35%

basics

~20 s

device.launchApp({ newInstance: true }) restarts the app process; device.reloadReactNative() only reloads the JS bundle, fast but leaving native state and sometimes misbehaving; device.openURL() delivers a link to the running app, while launchApp({ url }) tests a link at launch.

open as a page

In a Detox test of a React Native sign-up form, why can replaceText leave the screen in a different state than typeText?

level: middleimportance: should knowfreq 35%

basics

~20 s

Detox's typeText enters text through the system keyboard, firing the same change events a user would, while replaceText sets the value directly and may skip input callbacks. Validation, masks or submit-enable logic driven by those callbacks can then disagree with what the field shows.

open as a page

A Detox sign-up test's element(by.text('Continue')).tap() fails on iOS with 'Multiple elements found' — what causes it, and what is the robust fix?

level: seniorimportance: should knowfreq 28%

basics

~20 s

The matcher resolves to several native views, often including a Continue button on a screen still mounted underneath, and Detox fails rather than guess. Give the button a unique testID or scope the match with withAncestor; atIndex is a last resort.

open as a page

A Detox test cannot tap a meal card far down a React Native FlatList — why, and how do scroll, scrollTo and whileElement reach it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

A FlatList renders only rows near the viewport, so a distant card does not exist yet, and a nearer one exists but is off screen. Scroll the list element: waitFor(card).toBeVisible().whileElement(by.id('list')).scroll(200, 'down') scrolls until the card shows, then tap it.

open as a page