skip to content

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.