Routing tests with expo-router/testing-library, and the small Maestro suite that runs on a real binary. Open this when the feature is the flow — auth guards, deep links, tabs — or when setting up device tests in CI.
Sources: https://docs.expo.dev/router/reference/testing/ · https://docs.expo.dev/build-reference/e2e-tests/ · https://maestro.dev/
1. When routing deserves its own test
Most screens do not need one: render the screen directly and assert what it shows. Write a routing test when the navigation decision is the behaviour — an auth guard, a redirect after sign-in, a deep link into a detail route, a tab that must preserve state.
Never assert that router.push was called. That passes while the destination is broken.
2. renderRouter
import { renderRouter, screen } from 'expo-router/testing-library';
it('sends signed-out users to the sign-in route', async () => {
renderRouter(
{
index: () => null,
'sign-in': SignInScreen,
'(app)/orders': OrdersScreen,
_layout: RootLayout, // the layout holding the Stack.Protected guards
},
{ initialUrl: '/(app)/orders' },
);
expect(screen).toHavePathname('/sign-in');
});
renderRouter builds a mock file system from the object you pass: keys are route paths, values are components. It also accepts an array of paths (each rendering null), a fixture directory, or a directory plus overrides — useful when only one or two routes need real components.
Matchers: toHavePathname, toHaveSearchParams, toHaveSegments. All of them read the real router state, so the assertion fails when a guard, a group or a layout is wrong.
it('opens the order from a deep link and shows the not-found state for an unknown id', async () => {
renderRouter({ 'orders/[id]': OrderDetailScreen }, { initialUrl: '/orders/9999' });
expect(await screen.findByText(/we could not find that order/i)).toBeOnTheScreen();
});
Cover with routing tests: the guard in both directions, a deep link to a valid and an invalid entity, and the redirect after sign-in landing where the user intended to go.
3. Hooks that need a router
A hook calling useLocalSearchParams or router needs the router in the tree. Either render the screen that uses it, or give the hook its parameters as arguments — the second is usually the better design, and it makes the hook testable with a plain unit test.
4. Maestro flows
Maestro drives the real binary through the accessibility tree; flows are YAML and readable by anyone on the team.
# .maestro/checkout.yaml
appId: com.example.app
---
- launchApp:
clearState: true
- tapOn: 'Email address'
- inputText: 'test@example.com'
- tapOn: 'Password'
- inputText: ${MAESTRO_TEST_PASSWORD}
- tapOn: 'Sign in'
- assertVisible: 'Your orders'
- tapOn: 'Order 1001'
- assertVisible: 'Order details'
Rules that keep a device suite usable:
clearState: trueat launch, so a flow never inherits the previous one's session.- Seed the data the flow needs through the API before it runs, and tear it down after. A flow that depends on data someone created by hand fails on the next environment reset.
- Select by visible text or accessibility label, never by index or coordinates.
- Three to eight flows total: sign-in, the money path, one permission-gated path, one offline path. Every extra flow costs minutes of device time on every pull request.
Run locally with maestro test .maestro/checkout.yaml against a simulator or a connected device.
5. Building the binary for tests on EAS
// eas.json
{
"build": {
"e2e-test": {
"withoutCredentials": true,
"ios": { "simulator": true },
"android": { "buildType": "apk" }
}
}
}
A simulator build for iOS and an APK for Android are what Maestro can install. withoutCredentials avoids needing signing certificates for a build nobody ships.
An EAS workflow builds that profile and runs the flows; it can be triggered on a pull request or started manually:
eas workflow:run .eas/workflows/e2e-test-android.yml
The same commands run from any CI system: build with eas build --profile e2e-test, download the artifact, install it on an emulator, run maestro test.
6. Pipeline shape
PR: tsc --noEmit → eslint → jest (unit + component, sharded)
→ eas build --profile e2e-test (android) → maestro test .maestro/
main: the same, plus the iOS simulator flow
Keep the Jest suite under a couple of minutes so it runs on every push, and let the device flows run once per pull request. If the device suite starts going red for reasons unrelated to the change, fix it that week — a permanently red end-to-end suite teaches the team to ignore all test failures.
7. Detox, if you already have it
Detox is still a valid choice and is stronger at synchronisation with the app's internals (it waits for the JS thread to be idle rather than polling the UI). Keep it if it is green and the team knows it. Do not run Detox and Maestro side by side: two device suites double the cost, double the flakes, and nobody trusts either.