Week 17: End-to-End Testing with Detox

Unit and component tests from Week 16 verify pieces in isolation. This week verifies the whole thing — a real app, built and installed on a real simulator or emulator, driven exactly the way a user would drive it, with Detox.

Phase 7 of 8 Week 17 of 22 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Set up Detox for both iOS and Android
  • Write an E2E test using matchers to find elements and actions to interact with them
  • Handle asynchronous UI correctly without introducing flaky waits
  • Understand what running Detox in CI requires beyond a local setup

1. Detox Setup & Config

terminal
npm install detox --save-dev
npx detox init
.detoxrc.js — one configured device
module.exports = {
  testRunner: { args: { config: 'e2e/jest.config.js' } },
  apps: {
    ios: {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app',
      build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
  },
  devices: {
    simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },
  },
  configurations: {
    'ios.sim.debug': { device: 'simulator', app: 'ios' },
  },
};

Unlike Jest's mocked native modules, Detox drives a real, fully built app — which means it needs a real native build to exist first (the same expo run:ios/run:android build Week 12's native module also required), not just the JS bundle.

2. Writing an E2E Test

e2e/signIn.test.js
describe('Sign in flow', () => {
  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('signs in and lands on the feed', async () => {
    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('correct-password');
    await element(by.id('sign-in-button')).tap();

    await expect(element(by.text('Feed'))).toBeVisible();
  });

  it('shows an error on wrong credentials', async () => {
    await element(by.id('email-input')).typeText('user@example.com');
    await element(by.id('password-input')).typeText('wrong-password');
    await element(by.id('sign-in-button')).tap();

    await expect(element(by.text('Invalid credentials'))).toBeVisible();
  });
});

by.id matches a component's testID prop, set deliberately for testing — more stable than matching visible text, which changes with copy edits and localization. Reserve by.text for assertions on the actual user-visible outcome.

3. Waiting for Async UI

waiting for a network-dependent screen
await element(by.id('load-posts-button')).tap();

await waitFor(element(by.id('posts-list')))
  .toBeVisible()
  .withTimeout(5000);

Detox has its own automatic synchronization with idle timers, animations and network requests in most cases — but a genuinely slow API call, a long animation, or a WebView still needs an explicit waitFor(...).withTimeout(), the Detox equivalent of Week 16's waitFor from RNTL. The most common source of a flaky Detox suite is skipping this and asserting immediately after an action that triggers async work.

4. Running Detox in CI

Detox in CI has requirements beyond a local run: a machine that can actually launch a simulator or emulator (macOS runners for iOS; a Linux runner with KVM, or a macOS runner, for Android), the app pre-built as a CI step before tests run, and meaningfully longer job times than a unit test suite — a full E2E run genuinely takes minutes, not seconds.

a GitHub Actions step (macOS runner, iOS)
- run: npx detox build --configuration ios.sim.debug
- run: npx detox test --configuration ios.sim.debug --cleanup

Given the cost, most teams run the full unit/component suite (Week 16) on every push, and reserve the E2E suite for pull requests into a main branch, or a nightly scheduled run — a pattern worth remembering going into Week 19's CI/CD lesson.

5. Hands-on Exercise

Hands-on

Write a Detox E2E flow

Drive the auth-gated app from Week 5 through Detox, end to end.

Requirements:

  1. Set up Detox for at least one platform (iOS simulator or Android emulator), including a working build configuration.
  2. Add testID props to the sign-in form and key screens from Week 5's exercise.
  3. A Detox test that signs in and asserts the main tabs become visible.
  4. A second test asserting an error message appears on invalid credentials.
  5. At least one waitFor(...).withTimeout() covering a screen that loads data asynchronously, with a short comment explaining why it was needed there and not elsewhere.
Hint

If tests pass individually but fail when run together, check beforeEach — a missing device.reloadReactNative() (or equivalent app-state reset) between tests means state from one test (like being signed in) leaks into the next, which expects to start from a clean, signed-out state.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does Detox need a real native build of the app, where Week 16's Jest tests didn't?

Detox drives the actual compiled app running on a real simulator/emulator, interacting with real native UI — there's no JS-only environment to run it in the way Jest runs in Node. Jest tests instead run components in a simulated environment with native modules mocked out entirely, which is faster but doesn't exercise the real native build.

Q2

Why prefer by.id (testID) over by.text for locating elements to interact with in a Detox test?

A testID is a stable identifier set deliberately for testing, so it survives copy changes, localization, or minor UI rewording untouched. Matching by visible text ties the test to exact wording, which breaks the test on changes that have nothing to do with actual functionality — text matching is better reserved for asserting on the outcome the user would actually see.

Q3

What causes a Detox test to become flaky when asserting right after an action that triggers a network call?

Detox's automatic synchronization handles many cases, but a genuinely slow request can still resolve after the assertion runs, so the element being asserted on isn't there yet — intermittently, depending on network conditions. Wrapping the assertion in waitFor(...).withTimeout() polls until the element appears (or a real timeout is hit) instead of asserting at a single, unreliable moment.

Q4

Why do most teams run the full E2E suite less often (e.g. only on merges to main, or nightly) than the unit/component suite?

A real E2E run needs an actual simulator/emulator and a full native build, which takes meaningfully longer than a mocked unit-test run — running it on every single push would slow down every contributor's feedback loop for a cost that isn't worth paying that often. The fast unit/component suite catches most regressions cheaply on every push; E2E is reserved for the checkpoints where a native-level check is worth the wait.