E2E Testing Expo Router Apps with Maestro: Navigation, Deep Links, and Auth Guards
Maestro is a mobile E2E testing framework that drives your app through real gestures on a simulator or device. For Expo Router apps, Maestro tests the full navigation stack — deep links, tab switching, modal presentation, auth redirects — without needing to instrument your app code.
This guide covers E2E testing Expo Router apps with Maestro: setup, navigation flows, deep link testing, and auth guard verification.
Why Maestro for Expo Router
Expo Router's file-based routing and typed navigation are well covered by unit tests. What unit tests can't cover:
- Native navigation gestures (swipe back, swipe to dismiss modal)
- Deep link handling from an external URL scheme
- Push notification navigation
- Real tab bar interaction
- Auth redirect flows that cross native boundaries
Maestro handles all of these by driving the actual app.
Setup
Install Maestro:
curl -Ls "https://get.maestro.mobile.dev" <span class="hljs-pipe">| bashStart your Expo development build or production build on a simulator:
npx expo run:ios
# or for a preview build on a device:
eas build --profile preview --platform iosYour Maestro tests live in .maestro/ or any directory you choose:
.maestro/
auth/
login.yaml
auth-guard.yaml
navigation/
tab-switching.yaml
modal-flow.yaml
deep-links/
product-detail.yamlBasic Navigation Flow
# .maestro/navigation/home-to-profile.yaml
appId: com.yourcompany.app
---
- launchApp
- assertVisible: "Welcome"
- tapOn:
text: "Profile"
- assertVisible: "My Profile"
- assertVisible: "Edit Profile"Maestro's YAML syntax maps to user actions. tapOn is flexible — you can target by text, accessibility ID, or coordinates.
Testing Tab Navigation
# .maestro/navigation/tab-switching.yaml
appId: com.yourcompany.app
---
- launchApp
# Verify initial tab
- assertVisible: "Home"
- assertVisible: "Your dashboard is ready"
# Switch to Explore tab
- tapOn:
id: "explore-tab"
- assertVisible: "Explore"
- assertNotVisible: "Your dashboard is ready"
# Switch to Profile tab
- tapOn:
id: "profile-tab"
- assertVisible: "Profile"
# Switch back to Home
- tapOn:
id: "home-tab"
- assertVisible: "Your dashboard is ready"Add testID props to your tab bar buttons to make them reliably targetable:
// In your _layout.tsx
<Tabs.Screen
name="home"
options={{
tabBarTestID: 'home-tab',
tabBarLabel: 'Home',
}}
/>Testing Modal Presentation and Dismissal
# .maestro/navigation/modal-flow.yaml
appId: com.yourcompany.app
---
- launchApp
- tapOn:
text: "Edit Profile"
# Modal should be presented
- assertVisible: "Edit your profile"
- assertVisible: "Save changes"
# Fill in the form
- tapOn:
id: "name-input"
- clearText
- inputText: "Alice Chen"
# Submit
- tapOn:
text: "Save changes"
# Modal should dismiss, back to profile
- assertNotVisible: "Edit your profile"
- assertVisible: "My Profile"
- assertVisible: "Alice Chen"Testing Auth Guards
Expo Router's auth guards redirect unauthenticated users. Test the full redirect flow:
# .maestro/auth/auth-guard.yaml
appId: com.yourcompany.app
---
# Launch the app fresh (logged out)
- launchApp:
clearState: true
# Attempt to navigate directly to a protected route via deep link
- openLink: "myapp://dashboard"
# Should be on login screen instead
- assertVisible: "Sign in to continue"
- assertVisible: "Email"
- assertVisible: "Password"# .maestro/auth/login-and-redirect.yaml
appId: com.yourcompany.app
---
- launchApp:
clearState: true
- openLink: "myapp://dashboard/settings"
# On login screen
- tapOn:
id: "email-input"
- inputText: "alice@example.com"
- tapOn:
id: "password-input"
- inputText: "password123"
- tapOn:
text: "Sign in"
# After login, should land on the original destination
- assertVisible: "Settings"
- assertNotVisible: "Sign in to continue"Testing Deep Links
Expo Router handles deep links via URL schemes and Universal Links. Test that the app routes correctly:
# .maestro/deep-links/product-detail.yaml
appId: com.yourcompany.app
---
- launchApp
# Open a deep link to a specific product
- openLink: "myapp://products/prod-123"
# Should navigate to product detail
- assertVisible: "Product Details"
# The correct product should load
- assertVisible: "Wireless Headphones"
- assertVisible: "$79.99"For Universal Links (https://), test them from a simulator using xcrun:
xcrun simctl openurl booted "https://app.yourcompany.com/products/prod-123"Maestro's openLink uses the same mechanism internally.
Testing the Back Gesture
Swipe-back navigation is a native gesture that only Maestro can test:
# .maestro/navigation/back-gesture.yaml
appId: com.yourcompany.app
---
- launchApp
- tapOn:
text: "View order"
- assertVisible: "Order details"
# Swipe back from the left edge
- swipe:
startX: 5%
startY: 50%
endX: 80%
endY: 50%
# Back on the order list
- assertVisible: "Your orders"
- assertNotVisible: "Order details"Running Maestro Tests
# Run a single test
maestro <span class="hljs-built_in">test .maestro/navigation/tab-switching.yaml
<span class="hljs-comment"># Run all tests in a directory
maestro <span class="hljs-built_in">test .maestro/
<span class="hljs-comment"># Run with a specific device
maestro <span class="hljs-built_in">test --device <span class="hljs-string">"iPhone 15 Pro" .maestro/
<span class="hljs-comment"># Run in CI (headless)
maestro cloud --apiKey <span class="hljs-variable">$MAESTRO_CLOUD_API_KEY .maestro/CI Integration
# .github/workflows/e2e-ios.yaml
name: E2E Tests (iOS)
on:
push:
branches: [main]
jobs:
e2e:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Build Expo app
run: npx expo run:ios --configuration Release
- name: Run Maestro tests
run: maestro test .maestro/
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-test-results
path: ~/.maestro/tests/Organizing Tests with Tags
Maestro supports tags for filtering which tests to run:
# .maestro/auth/login.yaml
tags:
- auth
- smoke
---
- launchApp
- assertVisible: "Sign in"# Run only smoke tests
maestro <span class="hljs-built_in">test --include-tags smoke .maestro/
<span class="hljs-comment"># Run everything except slow tests
maestro <span class="hljs-built_in">test --exclude-tags slow .maestro/What Maestro Doesn't Cover
Maestro is excellent for navigation and interaction flows, but it doesn't cover:
- Performance metrics — frame rates, JS thread blocking, render time
- Memory leaks — long-running sessions that gradually degrade
- Network error handling — use Maestro's
mockNetworkor test in unit tests - Accessibility — screen reader interactions need dedicated tools (Accessibility Inspector, NVDA)
HelpMeTest schedules Maestro flows to run against your production app on a regular cadence. When a navigation regression ships, continuous monitoring catches it before user reports come in.
Summary
Maestro for Expo Router E2E testing:
- Tab navigation → use
testIDon tab bar items; assert content changes per tab - Modal flows → verify modal content appears, form interaction works, modal dismisses
- Auth guards →
clearState: trueto start logged out; assert redirect to login; verify return-to destination after login - Deep links →
openLinkwith your URL scheme; assert correct screen renders - Back gesture →
swipefrom left edge; assert navigation back - CI →
maestro cloudor headless simulator runs in GitHub Actions
Maestro fills the gap between unit tests and real-device behaviour — especially for navigation patterns that only make sense when fingers touch glass.