End-to-End Tests

What Maestro and Detox each cost to run, how they differ, and which three journeys are worth automating.

Syntaxmaestro test .maestro/sign-in.yaml | detox test -c ios.sim.debug

An end-to-end test installs the built app on a simulator, an emulator or a real device and drives it through the screens like a person. It is the only test that proves the native build, the navigation and the server work together.

Maestro

Flows are YAML files. Maestro works through the accessibility layer from outside the app, so nothing is added to your JavaScript, and the same flow runs against a development build or an EAS build. Commands wait for their target by default, which removes most of the flakiness this kind of test is known for. Run one file or a whole folder with maestro test .maestro/, and add --format=JUNIT --output=report.xml when CI needs to read the result.

Detox

Tests are JavaScript run by Jest, and Detox 20 synchronises with the app: it knows when React Native is idle, so you rarely add a wait. The cost is setup. You need a build made with Detox's own instrumentation, a .detoxrc.js listing configurations, and two commands per run: detox build -c ios.sim.debug, then detox test -c ios.sim.debug.

Choosing journeys

Each run costs minutes plus a machine with Xcode or the Android SDK. Automate the paths whose failure would be an outage: signing in, the one action the app exists for, and payment. Target elements by testID, because visible copy changes with every translation.

Example

Example · tsx
// e2e/signIn.test.tsx — run by Detox, not by the unit-test Jest config
import { by, device, element, expect as expectElement } from 'detox';

describe('Sign in', () => {
  beforeAll(async () => {
    // delete: true wipes the previous install, so no saved session leaks in.
    await device.launchApp({ newInstance: true, delete: true });
  });

  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('takes a known user to the feed', async () => {
    await element(by.id('email-input')).typeText('[email protected]');
    await element(by.id('password-input')).typeText('correct-horse');
    await element(by.id('sign-in-button')).tap();

    // Detox waits for the app to go idle before asserting; no sleep needed.
    await expectElement(element(by.id('feed-screen'))).toBeVisible();
  });

  it('keeps a wrong password on the same screen', async () => {
    await element(by.id('email-input')).typeText('[email protected]');
    await element(by.id('password-input')).typeText('wrong');
    await element(by.id('sign-in-button')).tap();

    await expectElement(element(by.text('Email or password is incorrect'))).toBeVisible();
    await expectElement(element(by.id('feed-screen'))).not.toBeVisible();
  });
});

When to use it

  • Prove sign-in still works on a release build before submitting it to the stores.
  • Catch a navigation regression that only appears on a real device, not in a component test.
  • Run a three-flow smoke suite nightly against the staging API.
  • Reproduce a crash that only happens on a cold start from a push notification.

More examples

The same journey as a Maestro flow

Everything after the --- is a list of commands, run in order. tapOn accepts bare text as a shorter form (- tapOn: "Sign in"), which reads better but breaks the moment the copy is translated; the id form targets a React Native testID and survives. clearState starts from a fresh install, which is what makes the flow repeatable. Because nothing is compiled into the app, the same file also runs against a release build you are about to submit.

Example · yaml
# .maestro/sign-in.yaml
appId: com.example.trips
tags:
  - smoke
---
- launchApp:
    clearState: true
- tapOn:
    id: "email-input"
- inputText: "[email protected]"
- tapOn:
    id: "password-input"
- inputText: "correct-horse"
- tapOn:
    id: "sign-in-button"
- assertVisible:
    id: "feed-screen"
- takeScreenshot: signed-in
- back

Running flows locally and in CI

Tags are how one folder serves two schedules: the smoke subset on a pull request, everything else nightly. The -e flag passes values a flow reads as ${EMAIL}, which keeps test credentials out of the repository. Detox has the matching pair of commands: detox build -c ios.sim.debug once per native change, then detox test -c ios.sim.debug for each run.

Example · bash
# Boot a device by name rather than relying on whatever is already open.
maestro start-device --platform=ios --device-model=iPhone-17-Pro

# One flow, while writing it.
maestro test .maestro/sign-in.yaml

# Every flow in the folder, with a report CI can parse.
maestro test .maestro/ --format=JUNIT --output=report.xml

# Only the smoke tags, with values injected rather than hard-coded.
maestro test .maestro/ --include-tags=smoke -e [email protected]

testIDs that a flow can rely on

On screen: two stacked fields and a Sign in row, centred vertically. flexDirection defaults to column here, so the children stack without being told to. The testIDs are the contract between the app and the flows, so name them for what the element is rather than where it sits, and put one on each screen's root so a flow can assert where it has landed. Component tests should still query by role and text; testID is for the tools that cannot see roles as reliably.

Example · tsx
// components/SignInScreen.tsx
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';

type SignInScreenProps = { onSubmit: (email: string, password: string) => void };

export default function SignInScreen({ onSubmit }: SignInScreenProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    // One testID on the screen container: a flow asserts on it to know
    // which screen it is looking at.
    <View style={styles.screen} testID="sign-in-screen">
      <TextInput
        testID="email-input"
        placeholder="[email protected]"
        autoCapitalize="none"
        keyboardType="email-address"
        value={email}
        onChangeText={setEmail}
      />
      <TextInput
        testID="password-input"
        placeholder="Password"
        secureTextEntry
        value={password}
        onChangeText={setPassword}
      />
      <Pressable
        testID="sign-in-button"
        accessibilityRole="button"
        onPress={() => onSubmit(email.trim(), password)}
      >
        <Text>Sign in</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
});

Discussion

  • Be the first to comment on this lesson.