E2E Testing Expo Router Apps with Maestro: Navigation, Deep Links, and Auth Guards

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">| bash

Start 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 ios

Your 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.yaml

Basic 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"

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 mockNetwork or 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 testID on tab bar items; assert content changes per tab
  • Modal flows → verify modal content appears, form interaction works, modal dismisses
  • Auth guardsclearState: true to start logged out; assert redirect to login; verify return-to destination after login
  • Deep linksopenLink with your URL scheme; assert correct screen renders
  • Back gestureswipe from left edge; assert navigation back
  • CImaestro cloud or 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.

Read more