DEV Community

Cover image for Detox + Cucumber BDD for React Native E2E testing
Warren de Leon
Warren de Leon

Posted on • Originally published at warrendeleon.com

Detox + Cucumber BDD for React Native E2E testing

Why BDD for E2E tests

Most Detox tutorials show you how to write imperative test code:

await element(by.id('email-input')).typeText('user@example.com');
await element(by.id('password-input')).typeText('password123');
await element(by.id('login-button')).tap();
await expect(element(by.id('home-screen'))).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

This works. But it reads like code, not like a test specification. When a product manager asks "what does the login test cover?", you hand them a TypeScript file and hope for the best.

Cucumber BDD lets you write tests in plain English using Gherkin syntax:

Feature: User Authentication

  Scenario: Successful login
    Given the app is launched
    And I am on the "Login" screen
    When I type "user@example.com" into the input with testID "email-input"
    And I type "password123" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see the "Home" screen
Enter fullscreen mode Exit fullscreen mode

Same test. Same Detox commands underneath. But now anyone on the team can read it, review it, and suggest missing scenarios.

💡 The key advantage: feature files become living documentation. When a scenario passes, you know the app supports that behaviour. When it fails, you know exactly which user flow broke, in plain language.

Assumptions

The setup below was written against:

  • React Native 0.74+ (bare workflow, not Expo)
  • TypeScript with the standard RN Babel config
  • macOS host (iOS simulator + Android emulator setup)
  • Xcode 15+ with Command Line Tools, an iOS simulator created (e.g. iPhone 17 Pro)
  • Android Studio with at least one AVD created (e.g. Pixel 7 API 35)
  • Node 18 or later

If you're on Expo, Detox needs a custom dev client. The Cucumber layer is the same regardless.

Installation

Install Detox, Cucumber, and the TypeScript loader as dev dependencies:

yarn add -D detox @cucumber/cucumber ts-node
cd ios && pod install && cd ..
Enter fullscreen mode Exit fullscreen mode

The iOS pod install is required because Detox ships an Xcode test target.

You also need two host-level tools that aren't npm packages:

brew tap wix/brew
brew install applesimutils
Enter fullscreen mode Exit fullscreen mode

applesimutils is what Detox uses to drive the iOS simulator. For Android you need a working emulator with USB debugging enabled. Detox's CLI is invoked via npx detox, so no global install is needed.

The configuration files

Three config files wire everything together.

.detoxrc.js

The Detox configuration defines your app builds and device targets:

module.exports = {
  testRunner: {
    args: {
      config: '.cucumber.js',
    },
    forwardEnv: true,
  },
  apps: {
    'ios.debug': {
      type: 'ios.app',
      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/YourApp.app',
      build: 'xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build',
    },
    'android.debug': {
      type: 'android.apk',
      binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
      build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug',
    },
  },
  devices: {
    simulator: {
      type: 'ios.simulator',
      device: { type: 'iPhone 17 Pro' },
    },
    emulator: {
      type: 'android.emulator',
      device: { avdName: 'Pixel_7_API_35' },
    },
  },
  configurations: {
    'ios.sim.debug': {
      device: 'simulator',
      app: 'ios.debug',
    },
    'android.emu.debug': {
      device: 'emulator',
      app: 'android.debug',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

.cucumber.js

The Cucumber configuration tells it where to find feature files, step definitions, and how to format output:

module.exports = {
  default: {
    paths: ['src/features/**/__tests__/*.feature'],
    require: [
      'src/test-utils/cucumber/support/**/*.{ts,tsx}',
      'src/test-utils/cucumber/step-definitions/**/*.cucumber.{ts,tsx}',
    ],
    requireModule: ['ts-node/register'],
    format: ['src/test-utils/cucumber/formatters/CheckmarkFormatter.js'],
    formatOptions: { colorsEnabled: true },
    strict: true,
    parallel: 2,
    retry: 1,
  },
};
Enter fullscreen mode Exit fullscreen mode
Option What it does
paths Where Gherkin feature files live
require Where step definitions and support files live
requireModule Enables TypeScript support
format Custom formatter for readable output
strict Fails on undefined or pending steps
parallel Number of parallel workers
retry Retries for flaky tests in parallel mode

tsconfig.cucumber.json

A minimal TypeScript config for the Cucumber runtime:

{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2020",
    "jsx": "react",
    "strict": true,
    "esModuleInterop": true,
    "paths": { "@app/*": ["./src/*"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

The support layer

Three files set up the Detox lifecycle inside Cucumber.

detox-setup.ts

import * as detox from 'detox';

export async function setupDetox(workerId: string) {
  await detox.init(undefined, { workerId });
}

export async function cleanupDetox() {
  await detox.cleanup();
}
Enter fullscreen mode Exit fullscreen mode

hooks.ts

This is the glue between Cucumber's lifecycle and Detox's device management:

import { BeforeAll, Before, After, AfterAll } from '@cucumber/cucumber';
import { setupDetox, cleanupDetox } from './detox-setup';

BeforeAll({ timeout: 180000 }, async function () {
  const workerId = process.env.CUCUMBER_WORKER_ID || '0';
  await setupDetox(`cucumber-worker-${workerId}`);
  await device.launchApp({
    newInstance: true,
    launchArgs: { detoxEnableSynchronization: 0 },
  });
  await device.enableSynchronization();
});

Before({ timeout: 30000 }, async function () {
  await detox.onTestStart(this);
  await device.reloadReactNative();
});

After(async function (scenario) {
  if (scenario.result?.status === 'FAILED') {
    const name = scenario.pickle.name.replace(/\s+/g, '-');
    await device.takeScreenshot(name);
  }
  await detox.onTestDone(this);
});

AfterAll(async function () {
  await cleanupDetox();
});
Enter fullscreen mode Exit fullscreen mode
Hook Timeout What it does
BeforeAll 180s Boots the simulator, launches the app
Before 30s Reloads React Native for a fresh state per scenario
After default Takes a screenshot on failure, notifies Detox
AfterAll default Tears down Detox

The synchronisation trick matters: launch with synchronisation disabled (detoxEnableSynchronization: 0), then enable it after the app is running. This avoids Detox timing out during the initial bundle load.

world.ts

A custom Cucumber World that carries Detox context between steps:

import { World } from '@cucumber/cucumber';

export class DetoxWorld extends World {
  device = device;
  testID: string | null = null;

  setTestID(id: string) { this.testID = id; }
  getTestID(): string {
    if (!this.testID) throw new Error('No testID set');
    return this.testID;
  }
}
Enter fullscreen mode Exit fullscreen mode

Writing feature files

Feature files are plain text with Gherkin syntax. Each scenario describes a user flow:

Feature: User Authentication

  Scenario: Successful login
    Given the app is launched
    And I navigate to the Login screen
    When I type "testuser@example.com" into the input with testID "email-input"
    And I type "SecurePass123" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see the "Home" screen

  Scenario: Login with invalid credentials
    Given the app is launched
    And I navigate to the Login screen
    When I type "testuser@example.com" into the input with testID "email-input"
    And I type "WrongPassword" into the input with testID "password-input"
    And I tap the "Login" button
    Then I should see text "Invalid email or password"

  Scenario: Deep link opens password reset
    Given the app is launched via password reset deep link
    Then I should see the "Reset Password" screen
Enter fullscreen mode Exit fullscreen mode

Tags let you filter which scenarios to run:

@accessibility @voiceover @ios
Feature: VoiceOver Gestures

  @eaa
  Scenario: Navigate login form with swipe gestures
    ...
Enter fullscreen mode Exit fullscreen mode

Then in your test command:

yarn detox test --tags "@accessibility and @ios"
Enter fullscreen mode Exit fullscreen mode

Writing step definitions

Each Gherkin step maps to a function. These are the reusable building blocks that make BDD powerful.

💡 Detox globals. device, element, by, and waitFor are exposed as globals by Detox at runtime. TypeScript needs to know about them, so add "types": ["detox", "node"] to your tsconfig.cucumber.json (or import them explicitly via import { device, element, by, waitFor } from 'detox'). Without one of these, every step definition will show red squigglies on device.launchApp.

Common steps

import { Given, When, Then } from '@cucumber/cucumber';

Given('the app is launched', async function () {
  await device.terminateApp();
  await device.clearKeychain();
  await device.launchApp({ newInstance: true });
  await new Promise(r => setTimeout(r, 500));
});

Given('I am on the {string} screen', async function (screen: string) {
  const testID = `${screen.toLowerCase().replace(/\s+/g, '-')}-screen`;
  await waitFor(element(by.id(testID)))
    .toBeVisible()
    .withTimeout(20000);
});

When('I tap the {string} button', async function (name: string) {
  const testID = `${name.toLowerCase().replace(/\s+/g, '-')}-button`;
  await element(by.id(testID)).tap();
});

When('I type {string} into the input with testID {string}',
  async function (text: string, testID: string) {
    await waitFor(element(by.id(testID)))
      .toBeVisible()
      .withTimeout(5000);
    await element(by.id(testID)).replaceText(text);
  }
);

Then('I should see the {string} screen', async function (screen: string) {
  const testID = `${screen.toLowerCase().replace(/\s+/g, '-')}-screen`;
  await new Promise(r => setTimeout(r, 500));
  await waitFor(element(by.id(testID)))
    .toBeVisible()
    .withTimeout(20000);
});

Then('I should see text {string}', async function (text: string) {
  await waitFor(element(by.text(text)))
    .toBeVisible()
    .withTimeout(5000);
});
Enter fullscreen mode Exit fullscreen mode

Key patterns:

  • ✅ Consistent testID convention: screen names become kebab-case with a suffix. "Login" becomes login-screen, "Home" becomes home-screen
  • ✅ Explicit waits: Every assertion uses waitFor with a timeout, not raw expect. Animations and network calls need settling time
  • ✅ replaceText over typeText: typeText appends to existing text. replaceText clears first. Safer for form inputs

Element finding strategies

Sometimes by.id() isn't enough. A resilient step definition tries multiple strategies:

When('I tap the text {string}', async function (text: string) {
  try {
    await element(by.text(text)).tap();
  } catch {
    try {
      await element(by.label(text)).tap();
    } catch {
      const testID = text.toLowerCase().replace(/\s+/g, '-');
      await element(by.id(testID)).tap();
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Try by.text() first (visible text), fall back to by.label() (accessibility label), then by.id() (testID). This handles buttons that render text differently across platforms.

The custom formatter

Cucumber's default output is verbose. A custom formatter gives you clean, scannable results:

✓ Feature: User Authentication
  ✓ Scenario: Successful login (2340ms)
    ✓ Given the app is launched (890ms)
    ✓ And I navigate to the Login screen (450ms)
    ✓ When I type "testuser@example.com" into the input with testID "email-input" (120ms)
    ✓ And I type "SecurePass123" into the input with testID "password-input" (95ms)
    ✓ And I tap the "Login" button (85ms)
    ✓ Then I should see the "Home" screen (700ms)

  ✗ Scenario: Login with expired token (1890ms)
    ✓ Given the app is launched (850ms)
    ✓ And I navigate to the Login screen (420ms)
    ✗ Then I should see the "Session Expired" screen (620ms)
      Error: Element not found: session-expired-screen

2 scenarios (1 passed, 1 failed)
12 steps (11 passed, 1 failed)
Enter fullscreen mode Exit fullscreen mode

The formatter is a class that listens to Cucumber events:

const { Formatter } = require('@cucumber/cucumber');

class CheckmarkFormatter extends Formatter {
  constructor(options) {
    super(options);

    options.eventBroadcaster.on('envelope', (envelope) => {
      if (envelope.testStepFinished) {
        this.onTestStepFinished(envelope.testStepFinished);
      }
      if (envelope.testCaseFinished) {
        this.onTestCaseFinished(envelope.testCaseFinished);
      }
    });
  }

  onTestStepFinished(event) {
    const { testStepResult } = event;
    const icon = testStepResult.status === 'PASSED' ? '✓' :
                 testStepResult.status === 'FAILED' ? '✗' :
                 testStepResult.status === 'SKIPPED' ? 'â—‹' : '?';
    const color = testStepResult.status === 'PASSED' ? '\x1b[32m' :
                  testStepResult.status === 'FAILED' ? '\x1b[31m' : '\x1b[33m';
    this.log(`${color}  ${icon}\x1b[0m ${this.getStepText(event)}\n`);
  }
}

module.exports = CheckmarkFormatter;
Enter fullscreen mode Exit fullscreen mode

The full implementation in my project tracks pickles, maps test steps to their Gherkin text, calculates timing, and outputs a summary with pass/fail counts.

Parallel execution

Detox supports running scenarios across multiple simulators. The .cucumber.js config sets the worker count, and each worker gets its own simulator instance.

# Run with 3 parallel simulators
DETOX_WORKERS=3 yarn detox:ios:test:parallel
Enter fullscreen mode Exit fullscreen mode

The BeforeAll hook reads CUCUMBER_WORKER_ID to initialise each worker with its own Detox instance. Scenarios are distributed across workers automatically.

Setting Local CI
iOS workers 2-3 3
Android workers 1-2 2
Retry on failure 1 1
Fail fast No No

💡 Tip: Disable fail-fast in parallel mode. One flaky scenario shouldn't stop the other workers. With retry enabled, the flaky test gets a second chance while the rest continue running.

Accessibility testing with BDD

Detox can't drive VoiceOver or TalkBack directly. Manual screen reader testing is still essential. But what Detox can do is verify that the right accessibility labels, roles, and traits are set on every element. Written in Gherkin, these tests catch accessibility regressions before a human tester ever opens VoiceOver.

My project has two feature files that test accessibility properties: one for iOS patterns and one for Android.

@accessibility @voiceover @ios @eaa
Feature: VoiceOver Gestures

  Scenario: Navigate login form with swipe right
    Given the app is launched
    And I am on the "Login" screen
    And VoiceOver focus is on the "Email" element
    When I swipe right to move to the next element
    Then VoiceOver focus should move to the next element
    And I should hear the accessibility label for "Password"

  Scenario: Activate login button with double tap
    Given the app is launched
    And I am on the "Login" screen
    And I have entered valid credentials
    And VoiceOver focus is on the "Login" button
    When I double tap to activate
    Then I should see the "Home" screen

  Scenario: Error announced via live region
    Given the app is launched
    And I am on the "Login" screen
    And I have entered invalid credentials
    When I double tap to activate
    Then the error message should be announced via a live region
Enter fullscreen mode Exit fullscreen mode

The step definitions for accessibility testing maintain state:

interface AccessibilityState {
  focusedElementIndex: number;
  visitedElements: string[];
  lastAnnouncement: string | null;
  granularity: 'characters' | 'words' | 'lines' | 'headings' | 'default';
}
Enter fullscreen mode Exit fullscreen mode

This tracks expected focus order, announcement text, and reading granularity. 50 scenarios across both feature files verify accessibility labels, focus behaviour, live region announcements, and custom actions. They don't replace manual testing with a real screen reader, but they stop regressions from shipping.

The scripts

Package.json scripts make the workflow clean:

{
  "scripts": {
    "detox:ios:build": "detox build -c ios.sim.debug",
    "detox:ios:test": "detox test -c ios.sim.debug",
    "detox:ios:test:parallel": "DETOX_WORKERS=2 detox test -c ios.sim.debug",
    "e2e:ios": "yarn detox:ios:build && yarn detox:ios:test"
  }
}
Enter fullscreen mode Exit fullscreen mode

First run looks like this:

yarn e2e:ios
Enter fullscreen mode Exit fullscreen mode
$ detox build -c ios.sim.debug
Building app for ios.sim.debug...
xcodebuild ... ** BUILD SUCCEEDED **

$ detox test -c ios.sim.debug
✓ Feature: User Authentication
  ✓ Scenario: Successful login (2340ms)
  ✓ Scenario: Login with invalid credentials (1820ms)

2 scenarios (2 passed)
12 steps (12 passed)
Enter fullscreen mode Exit fullscreen mode

If xcodebuild fails on the first run, double-check that the iOS simulator named in .detoxrc.js actually exists (xcrun simctl list devices). The most common first-run failure is a hardcoded iPhone 17 Pro that hasn't been created in Xcode yet.

Common pitfalls

Synchronisation is the hardest part. Detox tries to automatically wait for the app to be idle, but animations, timers, and network calls can confuse it. The launch-with-sync-disabled pattern (detoxEnableSynchronization: 0 then enableSynchronization() after) avoids the most common timeout.

typeText appends, replaceText replaces. If a field has placeholder text or previous input, typeText adds to it. Use replaceText for form inputs where you want a clean value.

Screenshots on failure are essential. The After hook captures a screenshot when a scenario fails. Without this, debugging CI failures is guesswork. Name the screenshot after the scenario so you can match failures to images.

Feature files should describe behaviour, not implementation. Write "When I log in" not "When I type into email-input and tap login-button". The implementation details belong in step definitions, not in the Gherkin.

The full file structure

src/
  test-utils/
    cucumber/
      formatters/
        CheckmarkFormatter.js    # Custom ✓/✗ formatter
      step-definitions/
        common.cucumber.tsx      # Shared steps (tap, type, navigate)
        auth.cucumber.tsx         # Authentication steps
        accessibility.cucumber.tsx # VoiceOver + TalkBack steps
      support/
        detox-setup.ts           # Detox initialisation
        hooks.ts                  # BeforeAll/Before/After/AfterAll
        world.ts                  # Cucumber World context
e2e/
  accessibility/
    VoiceOverGestures.feature    # iOS screen reader tests
    TalkBackGestures.feature     # Android screen reader tests
Enter fullscreen mode Exit fullscreen mode

What you get

The setup takes a morning. Writing the first feature file takes an afternoon. After that, adding new scenarios is fast because the step definitions are reusable.

The payoff:

  1. Tests that anyone can read. Product managers, QA, designers. The Gherkin files are the spec and the test rolled into one.
  2. Parallel execution out of the box. Cucumber's built-in parallelism works with Detox. Three simulators, three workers, three times faster.
  3. Accessibility regression catching. 50 scenarios verify that labels, roles, and traits are correct. Not a replacement for manual screen reader testing, but a safety net that stops regressions from reaching QA.

When an E2E test fails, you should know what broke without reading the test code.

This post covers E2E testing. For unit and integration tests, I use MSW v2 to mock the network layer instead of jest.fn(). The two approaches complement each other: MSW for fast, focused tests against real HTTP calls; Detox + Cucumber for full user flows on a real device.

The code examples in this post are from rn-warrendeleon, my personal React Native project. The full Detox + Cucumber setup, step definitions, custom formatter, and accessibility feature files are all in the repo.

Top comments (0)