If you have ever written a Maestro flow to test a checkout screen, you know the pain. Before you can assert anything about step three of checkout, your YAML has to log in, tap into the cart, add an item, and walk the whole stack to get there. Change the login screen and a dozen unrelated tests break. React Native Preflight is built to make that frustration disappear. It sits on top of Maestro and lets you deep-link directly into any screen with its state already prepared, declare the test right beside the component in TypeScript, and have the Maestro YAML generated for you.
The package react-native-preflight is young and ambitious. It bundles four ideas that usually require four separate tools: isolated deep-linking into screens, declarative test authoring, an in-app catalog of every testable screen, and screenshot-based visual regression. Best of all, a Babel plugin strips every trace of it from your production build, so none of this ships to real users. This article walks through what it does and how to wire it into an Expo project.
A quick word of honesty before we dive in: this is a brand-new, pre-1.0 library with a small but growing following. It is genuinely clever and worth watching, but treat it as a promising newcomer rather than a battle-tested standard.
Why Bother Layering on Maestro
Maestro is already one of the friendliest mobile E2E frameworks around. It is YAML-based, fast, and refreshingly low on flakiness. So why add anything on top of it?
The friction with raw Maestro is not the runner, it is the authoring. Three problems show up again and again:
- Manual navigation. Reaching a deep screen means scripting every step that leads to it. Slow to write, brittle to maintain.
- State setup is awkward. Getting a screen into a specific state, like an empty list versus a populated one, or a logged-in user versus a guest, means scripting the actions that produce that state.
- YAML drifts from code. Selectors and steps live in
.yamlfiles far away from the components they test, so they quietly fall out of sync.
Preflight's answer is to flip the model. You declare the test next to the component, describe the state you want with an inject() function, and let the CLI compile your declarations into Maestro YAML. The screen is deep-linked into existence with its state already in place, no flash, no flicker, no twelve-step warm-up.
Getting It Into Your Project
Installation is a single package, plus a scaffolding step that sets up the directory structure and Babel config.
npm install react-native-preflight
npx preflight init
yarn add react-native-preflight
npx preflight init
Preflight leans Expo-first. Its peer dependencies are react >=18, react-native >=0.72, and expo-linking/expo-router at version 54 or above. React Navigation users are supported too, via a navigation callback we will see shortly.
There is also a Claude Code plugin for fully automated setup if you would rather skip the manual wiring:
/plugin marketplace add RamboWasReal/react-native-preflight
/plugin install react-native-preflight@react-native-preflight-plugins
/react-native-preflight:preflight-setup
The last piece of setup is the Babel plugin, which is what guarantees zero production overhead. The strip flag removes all Preflight code from release builds.
// babel.config.js
module.exports = {
presets: ['babel-preset-expo'],
plugins: [
['react-native-preflight/babel', { strip: process.env.NODE_ENV === 'production' }],
],
};
Declaring Your First Scenario
The heart of Preflight is the scenario() function. You wrap a screen component with it and register that screen as testable. The configuration object carries an id, a route, an optional inject() to set up state, and a test() callback that returns the steps.
import { scenario } from 'react-native-preflight';
export default scenario(
{
id: 'settings',
route: '/settings',
description: 'Settings screen',
inject: async () => {
// Pre-populate stores, async storage, or mock data here.
// This runs BEFORE navigation, so the screen renders ready.
},
test: ({ see, tap, scroll }) => [
see('Settings'),
tap('dark-mode-toggle'),
scroll('footer', 'down'),
],
},
function SettingsScreen() {
// The actual component lives here.
return null;
},
);
A few details make this tick. The id is a unique identifier that doubles as a testID and as the filename of the generated YAML. The route must match your file-based route in Expo Router or your screen name in React Navigation. And inject() is the real magic: because it runs before navigation, the screen mounts straight into the desired state with no visible flash. That single property is the biggest ergonomic win over hand-written Maestro.
For the navigation side to work, wrap your root layout in <StateInjector>. With Expo Router that is all you need; with React Navigation you pass an onNavigate callback so Preflight knows how to move between screens.
// Expo Router root layout
import { StateInjector } from 'react-native-preflight';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<StateInjector>
<Stack />
</StateInjector>
);
}
The Vocabulary of a Test
The test() callback receives a set of helper functions and returns an array of steps. The names read almost like plain English, which keeps tests legible even months later.
test: ({ see, notSee, tap, type, scroll, swipe, back, wait }) => [
see('Welcome'), // assert text is visible
notSee('Loading'), // assert something is gone
type('email-input', 'me@example.com'),
tap('submit-button'),
wait(500), // brief delay
scroll('results-list', 'down'),
swipe('left', 300),
back(),
]
Beyond these you also get longPress, hideKeyboard, and assertions that accept a testID object such as see({ id: 'avatar' }). When you need something the helpers do not cover, there is an escape hatch: raw('...') injects raw Maestro YAML directly into the generated flow. Because test logic is just data, you can extract these step arrays into shared files and reuse them across multiple scenarios.
Generating and Running Tests
With scenarios declared, the CLI does the rest. generate scans your codebase for scenario() calls and emits the corresponding Maestro YAML. The test command runs them, with an interactive picker when you do not name one.
npx preflight generate # scan scenarios, emit Maestro YAML
npx preflight test # interactive scenario picker
npx preflight test settings # run one scenario by id
npx preflight test --all # run every scenario and flow
npx preflight test --all --retry 2 # retry failures twice
Because the YAML is generated, it never drifts from your components. Rename a testID in code, regenerate, and the flow updates with it.
Testing Many States at Once
Most screens have more than one interesting state. A dashboard might show a welcome message when populated and a getting-started prompt when empty. Preflight's variants let you describe each state inside a single scenario, and each one compiles to its own YAML file.
scenario({
id: 'dashboard',
route: '/dashboard',
variants: {
'with-data': {
inject: () => {/* populate the store */},
test: ({ see }) => [see('Welcome back')],
},
'empty-state': {
inject: () => {/* clear the store */},
test: ({ see }) => [see('Get started')],
},
},
}, DashboardScreen);
This is where the inject() philosophy pays off most. Each variant sets up exactly the world it needs, then tests against it, with no shared setup to leak between cases.
Whole Journeys When You Need Them
Isolated screens cover most of your testing, but sometimes you genuinely want to walk a multi-screen flow such as onboarding. The flow property describes a sequence of screens and the actions to perform on each, including conditional skips.
scenario({
id: 'onboarding',
route: '/onboarding',
flow: [
{ screen: 'setup', actions: ({ tap }) => [tap('skip-btn')], skipIf: 'home' },
{ screen: 'home' },
],
}, OnboardingScreen);
A flow scenario generates two artifacts: an isolated test for the screen itself and a separate flow test that runs the full journey. You get both granular and end-to-end coverage from one declaration.
When test runs need to behave differently from production, two more tools help. The env property injects environment variables you can reference with ${VAR} syntax inside steps, handy for test credentials. And isPreflightActive() lets you guard code paths that should only change during testing, like skipping a permission prompt or a security gate. Both are stripped from production by the Babel plugin.
import { isPreflightActive } from 'react-native-preflight';
if (isPreflightActive()) {
// Skip onboarding gates during E2E runs only.
}
A Catalog and a Camera
Two bonus features round out the package. The first is the dev catalog. Drop a <Preflight /> component into a screen and you get a browsable, in-app list of every testable scenario in your app, a bit like Storybook for React Native screens, but tied to your executable tests and their state injection.
import { Preflight } from 'react-native-preflight';
export default function PreflightScreen() {
return <Preflight />;
}
The second is visual regression. Preflight can capture screenshot baselines and diff them on later runs, powered under the hood by pixelmatch and pngjs. The workflow is three commands: capture baselines, compare against them, and accept any intentional change.
npx preflight test --all --snapshot # capture baselines
npx preflight snapshot:compare # generate an HTML diff report
npx preflight snapshot:compare --ci # exit 1 on regression, for CI
npx preflight snapshot:update settings # accept a deliberate change
A threshold config (default 0.1) controls how much pixel difference is tolerated before a test is considered a regression. Most React Native E2E setups need a separate tool entirely for this, so having it built in is a real convenience.
Where Preflight Fits
It is worth being clear about what Preflight is not. It is not a competing test runner. Maestro still does the actual driving of your app, and tools like Detox or Appium remain the alternatives if you want a different runner altogether. Preflight is an authoring and ergonomics layer that sits on top of Maestro. If you already use Maestro, or you are curious about it but dread writing and maintaining the YAML, this is purely additive.
Configuration lives in preflight.config.js or a "preflight" key in your package.json, where you can set the app bundle ID (including separate iOS and Android values), the deep-link scheme, output directories, and the screenshot threshold. Per-platform bundle IDs are resolved at runtime through a --platform flag, so the same generated YAML works for both stores.
Worth a Spot on Your Radar
React Native Preflight makes a compelling argument: that mobile E2E tests should live next to the components they cover, that reaching a screen should be a deep link rather than a journey, and that the YAML should be generated rather than hand-tended. Add a free screen catalog and built-in visual regression, and the value proposition for a Maestro-curious team is easy to see.
The honest caveat remains. This is an early, pre-1.0 project with modest adoption and a single maintainer behind it. The trade-off it asks for is a small amount of lock-in through the scenario() wrapper and the Babel plugin, in exchange for a much nicer authoring experience. For a side project or a team comfortable living near the bleeding edge, that is an easy trade. For a critical production suite, give it a careful evaluation first. Either way, it is one of the more interesting ideas to surface in the React Native testing space lately, and it is well worth a look.