Almost every codebase we inherit has tests. Almost none of them catch the bugs that actually ship. The suite is 400 snapshot files and a handful of pure-function assertions, while the crashes come from a native module that changed behaviour under the New Architecture, a permission dialog nobody scripted, or a release build that differs from the debug build everyone tested.
This is the layering we put in place on client projects. It is deliberately small: three layers, each with a job the others cannot do.
Layer 1: React Native Testing Library, for behaviour
Unit and integration tests run in Jest with @testing-library/react-native. Two rules make this layer worth its runtime.
Query the way a user finds things. Prefer getByRole, getByLabelText, and getByText over testID where you reasonably can. This has a pleasant side effect: components that are hard to query are usually components that are hard for a screen reader to use, so your test suite starts doubling as a rough accessibility audit.
Test the screen, not the leaf. Render a screen with its providers — navigation, query client, theme — and assert on what the user sees after an interaction:
import { render, screen, userEvent } from '@testing-library/react-native';
test('shows a validation error before submitting', async () => {
const user = userEvent.setup();
render(<SignInScreen />, { wrapper: AppProviders });
await user.press(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/enter your email/i)).toBeOnTheScreen();
});
Note userEvent rather than fireEvent: it goes through the same press/timing sequence the runtime does, so it catches disabled buttons and debounce logic that fireEvent happily ignores.
Mock at the network boundary with MSW, not at the module boundary. Mocking your own data layer only proves your mocks agree with themselves.
Delete your snapshot tests, or at least stop writing new ones. A snapshot fails on every intentional change and passes on most unintentional ones. The exception we keep: one snapshot per design-system primitive, reviewed like code.
Layer 2: Maestro, for the flows that make money
Jest never touches a native module, a real navigator, or the keyboard. That is what end-to-end tests are for, and in 2026 Maestro is what we reach for on client work — YAML flows, no build step for the test code itself, and a tolerance for async UI that makes flakiness manageable.
appId: com.example.app
---
- launchApp:
clearState: true
- tapOn: "Sign in"
- tapOn:
id: "email-input"
- inputText: "qa@example.com"
- tapOn: "Continue"
- assertVisible: "Your orders"
Scope discipline matters more than tooling here. We write E2E flows for the paths where a failure costs money or trust: onboarding and sign-in, the primary create/purchase action, push-notification deep links, and the offline-to-online transition. Five to fifteen flows, not fifty. Every flow you add is a flow someone maintains at 2am before a release.
A few things that keep them green:
- Seed state through a test-only path, not through the UI. Logging in via API and launching straight into the authenticated stack removes the single flakiest step in most suites.
- Run against a release-configuration build. Debug builds hide Hermes bytecode issues, ProGuard/R8 stripping, and the New Architecture behaviour differences you most want to find.
- Test permission dialogs explicitly —
clearState: trueplus a scripted allow/deny is the only way to cover the cold-start path a reviewer will see.
Layer 3: The native regression net
This is the layer teams skip, and it is the one the New Architecture made necessary. When your app depends on Turbo Modules and Fabric components — your own or a library's — the risky changes are native and JS tests cannot see them.
Three cheap safeguards cover most of it:
- Build both platforms on every PR. A failed native build is a caught regression. This alone catches the majority of dependency-bump breakage.
- Contract-test your own native modules. For each Turbo Module, one Jest test asserting the TypeScript spec surface plus one Maestro flow exercising it on device. When someone changes the codegen spec, something red appears.
- Smoke-launch the release build. A Maestro flow that launches the app, waits for the first authenticated screen, and exits. Startup crashes from a native library are the single most common "it worked on my machine" release failure.
Wiring it into CI
The pipeline we recommend, in rough order of cost:
| Stage | Runs on | Time budget |
|---|---|---|
| Typecheck, lint, Jest/RNTL | Every push | Under 5 minutes |
| iOS + Android release builds | Every PR to main | Under 25 minutes |
| Maestro critical flows on those builds | Every PR to main | Under 15 minutes |
| Full Maestro suite + device matrix | Nightly and pre-release | Whatever it takes |
On Expo projects, EAS Workflows expresses this directly: a build job per platform, then a Maestro job that consumes the build artifacts. On bare projects the same shape drops into GitHub Actions with a macOS runner for iOS. Either way, the important property is that E2E runs against the artifact you would ship, not a freshly compiled debug variant.
Two guardrails for the humans: quarantine flaky flows into a non-blocking job the same day they flake — never disable them silently — and put the coverage number on the wall rather than in the merge gate. A coverage threshold reliably produces tests that execute code without asserting anything.
What good looks like after a quarter
On the teams we have taken through this, the honest outcome is not "no bugs." It is that releases stop being events. The suite catches the classes of failure that used to reach users — broken sign-in after a dependency bump, a deep link that lands on a blank screen, a release build that will not launch on Android 15 — and the team stops treating every store submission as a coin flip.
If your suite is green and your releases still are not, that is usually a layering problem rather than an effort problem. We do this as a short engagement: audit the existing suite, cut what does not earn its keep, and stand up the E2E and CI layers with your team driving. Get in touch if that sounds like your week before a release.