Simplify Maestro E2E testing for React Native. Test screens in isolation, browse a dev catalog, and catch visual regressions — all with zero production overhead.
- Isolated screen testing — Deep link directly to any screen with pre-injected state
- Dev catalog — Browse and preview all testable screens in one place
- Visual regression — Screenshot comparison with HTML reports
- Zero prod impact — Babel plugin strips all preflight code from production builds
- CLI tooling — Generate test skeletons, run tests, compare snapshots
- Router agnostic — Works with Expo Router (auto-detected) or React Navigation via
onNavigate
npm install react-native-preflightPeer dependencies: expo-linking, react, react-native. expo-router is optional — if installed, navigation works automatically. Without it, provide an onNavigate prop.
Skip the manual setup — this package ships a Claude Code plugin that handles everything: Babel config, StateInjector, screen wrapping, and Maestro YAML generation.
# Add the marketplace and install the plugin
/plugin marketplace add RamboWasReal/react-native-preflight
/plugin install react-native-preflight@react-native-preflight-pluginsThen run:
/react-native-preflight:preflight-setup
Already using Claude Code? This is the fastest way to get started. The plugin auto-detects your project structure (Expo Router or React Navigation) and configures everything.
npx preflight initCreates .maestro/ directories, adds the preflight deep link scheme to app.json, scaffolds a catalog screen, and configures the Babel plugin.
Use scenario() to register a screen for testing. It wraps the component and makes it discoverable by the catalog and CLI.
import { scenario } from 'react-native-preflight';
export default scenario(
{
id: 'settings',
route: '/settings',
description: 'Settings screen',
inject: async () => {
// Pre-populate stores, query cache, etc.
},
test: ({ see, tap, scroll }) => [
see('Settings'),
tap('dark-mode-toggle'),
scroll('footer', 'down'),
],
},
function SettingsScreen() {
// your component...
},
);-
id— Unique identifier, used as MaestrotestIDand YAML filename -
route— Must match the file-based route (Expo Router) or screen name (React Navigation) -
inject()— Called BEFORE navigation to set up deterministic state (zero flash) -
test()— Optional. Generates Maestro test steps vianpx preflight generate:see('text')— assert visible textsee({ id: 'testID' })— assert testID visibletap('buttonId')— tap element by testIDlongPress('itemId')— long press element by testIDtype('inputId', 'value')— type text into inputnotSee('text')— assert text not visiblewait(2000)— wait N millisecondsscroll('elementId', 'down')— scroll until element is visible (scrollUntilVisible)swipe('left')— swipe in a direction (default 400ms)swipe('up', 200)— swipe with custom durationback()— press back buttonhideKeyboard()— dismiss the keyboardraw('- setLocation:\n latitude: 45.5')— inject raw Maestro YAML
Test functions can be extracted to separate files and imported:
// e2e/tests/settings.ts import type { TestHelpers } from 'react-native-preflight'; export const settingsTest = ({ see, tap }: TestHelpers) => [ see('Settings'), tap('dark-mode-toggle'), ]; // app/settings.tsx import { settingsTest } from '@/e2e/tests/settings'; export default scenario({ id: 'settings', route: '/settings', test: settingsTest }, SettingsScreen);
The generator follows single-level imports to resolve test steps from external files.
-
variants— Optional. Test multiple states of the same screen. Each variant inheritsroute,inject, andtestfrom the base config unless overridden:
export default scenario({
id: 'dashboard',
route: '/dashboard',
variants: {
'with-data': {
inject: () => { /* populate stores with mock data */ },
test: ({ see }) => [see('Welcome back')],
},
'empty-state': {
inject: () => { /* clear all stores */ },
test: ({ see }) => [see('Get started')],
},
},
}, DashboardScreen);Generates screens/dashboard/with-data.yaml and screens/dashboard/empty-state.yaml.
Wrap your root layout with StateInjector. It listens for preflight:// deep links, calls inject, then navigates.
Expo Router (auto-detected):
// app/_layout.tsx
import { StateInjector } from 'react-native-preflight';
export default function RootLayout() {
return (
<StateInjector>
<Stack />
</StateInjector>
);
}React Navigation:
import { StateInjector } from 'react-native-preflight';
export default function App() {
const navigation = useNavigation();
return (
<StateInjector onNavigate={(route) => navigation.navigate(route)}>
<Stack.Navigator>{/* ... */}</Stack.Navigator>
</StateInjector>
);
}Browse and preview all registered scenarios from a dev-only screen.
import { Preflight } from 'react-native-preflight';
export default function PreflightScreen() {
return <Preflight />;
}For React Navigation: <Preflight onNavigate={(id) => navigation.navigate(id)} />
Strip all preflight code from production builds.
// babel.config.js
module.exports = {
presets: ['babel-preset-expo'],
plugins: [
['react-native-preflight/babel', { strip: process.env.NODE_ENV === 'production' }],
],
};If you ran
npx preflight init, this is already configured.
| Command | Description |
|---|---|
npx preflight init |
Scaffold directories, scheme, catalog, Babel plugin |
npx preflight generate |
Scan scenario() calls and generate Maestro YAML |
npx preflight test |
Interactive scenario picker |
npx preflight test <id> |
Run Maestro test for one scenario |
npx preflight test --all |
Run all scenarios (screens + flows) |
npx preflight test --retry 2 |
Retry all tests up to N times on failure |
npx preflight test <id> --snapshot |
Run and capture screenshot |
npx preflight snapshot:compare |
Compare current vs baseline screenshots |
npx preflight snapshot:compare --ci |
Same, but exit 1 on regression |
npx preflight snapshot:update [id] |
Promote current screenshots to baselines |
npx preflight snapshot:reset [id] |
Delete all snapshots (or one scenario's) |
In preflight.config.js or under a preflight key in package.json:
| Key | Default | Description |
|---|---|---|
appId |
"" (auto-detected from app.json) |
Bundle ID — string or { ios, android } object |
scheme |
"preflight" |
Deep link scheme |
screensDir |
".maestro/screens" |
Generated Maestro YAML location |
snapshotsDir |
".maestro/snapshots" |
Screenshot baselines and diffs |
threshold |
0.1 |
Allowed pixel diff % before comparison fails |
srcDir |
"" (auto-detected) |
Source directory for screen files |
Generated YAML uses Maestro's waitForAnimationToEnd before each screenshot — no manual delay needed.
iOS and Android often have different bundle identifiers. Use an object instead of a string:
{
"preflight": {
"appId": {
"ios": "com.example.app.dev",
"android": "com.example.app.staging"
}
}
}Generated YAML uses appId: ${APP_ID} — Maestro resolves the value at runtime. When running tests, specify the target platform:
npx preflight test --platform ios
npx preflight test --all --platform androidA plain string appId still works without --platform.
isPreflightActive() returns true after a preflight deep link has been handled. Use it to skip security gates, onboarding, and permission modals during E2E tests:
import { isPreflightActive } from 'react-native-preflight';
if (isPreflightActive()) {
// Skip guards that would block E2E navigation
}Stripped in production by the Babel plugin — zero runtime cost.
Pass variables to Maestro YAML via env in your scenario:
scenario({
id: 'login',
route: '/login',
env: { TEST_EMAIL: 'test@example.com', TEST_PASSWORD: 'password123' },
// ...
}, LoginScreen);These become Maestro env: variables, accessible in YAML via ${TEST_EMAIL}.
Test complete user journeys by adding flow to a scenario:
export default scenario({
id: 'onboarding',
route: '/onboarding',
inject: () => { /* set up initial state */ },
test: ({ type, tap }) => [
type('name-input', 'Jane'),
tap('next-btn'),
],
flow: [
{ screen: 'setup', actions: ({ tap }) => [tap('skip-btn')], skipIf: 'home' },
{ screen: 'home' },
],
}, OnboardingScreen);npx preflight generate produces both screens/onboarding.yaml (isolated test) and flows/onboarding.yaml (full journey). Both appear in the interactive test picker.
skipIf makes a flow step conditional — if the given testID is already visible, the step is skipped.
Catch unintended UI changes by comparing screenshots between test runs.
npx preflight test --all --snapshotFirst run creates baseline.png for each scenario. Subsequent runs update current.png. Passed tests get their screenshots saved; failed tests are skipped.
npx preflight snapshot:compareCompares current.png vs baseline.png for every scenario. Generates an HTML report and opens it in your browser. Use --ci to exit with code 1 on regression (no auto-open).
npx preflight snapshot:update # Update all baselines
npx preflight snapshot:update settings # Update one baselinePromotes current.png to baseline.png when UI changes are intentional.
.maestro/snapshots/
home/
baseline.png ← stable reference
current.png ← latest test run
diff.png ← generated by compare (if different)
dashboard/
with-data/ ← variant subdirectory
baseline.png
current.png
report.html ← visual comparison report
scenario(config, Component)registers the screen in an in-memory registry at import time and wraps it with<View testID={id}>for Maestro assertions.StateInjectorinterceptspreflight://scenario/<id>deep links, callsinject(), then navigates viaexpo-routeror youronNavigatecallback.- The Babel plugin replaces
scenario(config, Component)with justComponentand removes<Preflight />elements — zero preflight code in production.
MIT