Expo Router Testing Guide: Typed Routes, Layouts, Tabs, and Modals with Jest

Expo Router Testing Guide: Typed Routes, Layouts, Tabs, and Modals with Jest

Expo Router brings file-based routing to React Native with full TypeScript support. Each file in your app/ directory maps to a route, layouts wrap segments, and tabs and modals are first-class patterns. Testing all of this requires understanding what Expo Router provides and what you need to verify yourself.

This guide covers testing Expo Router with Jest and React Native Testing Library (RNTL): typed route navigation, layout rendering, tab switching, and modal presentation.

Setup

npx expo install expo-router
npm install -D jest @testing-library/react-native @testing-library/jest-native jest-expo

jest.config.js:

module.exports = {
  preset: 'jest-expo',
  setupFilesAfterFramework: ['@testing-library/jest-native/extend-expect'],
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)',
  ],
}

Mock Expo Router in your test setup:

// jest-setup.ts
jest.mock('expo-router', () => require('expo-router/testing-library'))

Testing Screen Components

Expo Router screen components are React Native components. Test them by passing props directly:

// app/(tabs)/home.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { useRouter } from 'expo-router'

interface HomeScreenProps {
  userName?: string
}

export default function HomeScreen({ userName = 'Guest' }: HomeScreenProps) {
  const router = useRouter()

  return (
    <View style={styles.container}>
      <Text style={styles.heading}>Welcome, {userName}</Text>
      <Pressable
        onPress={() => router.push('/profile')}
        testID="profile-button"
      >
        <Text>Go to profile</Text>
      </Pressable>
    </View>
  )
}
// app/(tabs)/home.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native'
import { describe, it, expect, jest } from '@jest/globals'

jest.mock('expo-router', () => ({
  useRouter: jest.fn(() => ({
    push: jest.fn(),
    back: jest.fn(),
    replace: jest.fn(),
  })),
}))

import { useRouter } from 'expo-router'
import HomeScreen from './home'

describe('HomeScreen', () => {
  it('renders welcome message with user name', () => {
    render(<HomeScreen userName="Alice Chen" />)

    expect(screen.getByText('Welcome, Alice Chen')).toBeOnTheScreen()
  })

  it('navigates to profile when button is pressed', () => {
    const push = jest.fn()
    jest.mocked(useRouter).mockReturnValue({ push, back: jest.fn(), replace: jest.fn() } as any)

    render(<HomeScreen />)

    fireEvent.press(screen.getByTestId('profile-button'))

    expect(push).toHaveBeenCalledWith('/profile')
  })

  it('uses Guest as default user name', () => {
    render(<HomeScreen />)

    expect(screen.getByText('Welcome, Guest')).toBeOnTheScreen()
  })
})

Testing Typed Routes

Expo Router generates a typed href for all your routes. Test that navigation calls use correct typed paths:

// Using typed routes (Expo SDK 50+)
import { Link } from 'expo-router'

export function ProductCard({ productId }: { productId: string }) {
  return (
    <Link href={{ pathname: '/products/[id]', params: { id: productId } }}>
      View product
    </Link>
  )
}
// ProductCard.test.tsx
import { render, screen } from '@testing-library/react-native'
import { ProductCard } from './ProductCard'

// expo-router/testing-library renders Link correctly
describe('ProductCard', () => {
  it('renders with correct href for product', () => {
    render(<ProductCard productId="prod-123" />)

    const link = screen.getByText('View product')
    expect(link).toBeOnTheScreen()
    // For deeper href assertions, use the rendered router state
  })
})

For integration tests that verify actual navigation, use renderRouter from expo-router/testing-library:

import { renderRouter, screen } from 'expo-router/testing-library'

describe('Typed route navigation', () => {
  it('navigates to product detail with correct id param', async () => {
    renderRouter({
      'index': () => (
        <Link href={{ pathname: '/products/[id]', params: { id: 'prod-123' } }}>
          View product
        </Link>
      ),
      'products/[id]': ({ route }) => (
        <Text>Product: {route.params.id}</Text>
      ),
    }, { initialUrl: '/' })

    fireEvent.press(screen.getByText('View product'))

    await waitFor(() => {
      expect(screen.getByText('Product: prod-123')).toBeOnTheScreen()
    })
  })
})

Testing Layouts

Layouts in Expo Router (_layout.tsx) wrap child screens. Test them with mock children:

// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'

export default function TabsLayout() {
  return (
    <Tabs
      screenOptions={{
        headerShown: false,
        tabBarActiveTintColor: '#5aff28',
      }}
    >
      <Tabs.Screen
        name="home"
        options={{
          title: 'Home',
          tabBarIcon: ({ color }) => (
            <Ionicons name="home" color={color} size={24} />
          ),
        }}
      />
      <Tabs.Screen
        name="explore"
        options={{
          title: 'Explore',
          tabBarIcon: ({ color }) => (
            <Ionicons name="search" color={color} size={24} />
          ),
        }}
      />
    </Tabs>
  )
}

Test tab navigation using renderRouter:

import { renderRouter, screen, fireEvent, waitFor } from 'expo-router/testing-library'

describe('TabsLayout', () => {
  it('switches between home and explore tabs', async () => {
    renderRouter({
      '(tabs)/_layout': require('./app/(tabs)/_layout').default,
      '(tabs)/home': () => <Text>Home Screen</Text>,
      '(tabs)/explore': () => <Text>Explore Screen</Text>,
    }, { initialUrl: '/(tabs)/home' })

    // Home tab is visible
    expect(screen.getByText('Home Screen')).toBeOnTheScreen()

    // Tap Explore tab
    fireEvent.press(screen.getByRole('button', { name: 'Explore' }))

    await waitFor(() => {
      expect(screen.getByText('Explore Screen')).toBeOnTheScreen()
    })
  })
})

Testing Modal Routes

Expo Router presents modals using the (modal) group or presentation: 'modal' option:

// app/edit-profile.tsx (presented as modal)
import { View, Text, Pressable } from 'react-native'
import { useRouter } from 'expo-router'

export default function EditProfileModal() {
  const router = useRouter()
  return (
    <View testID="edit-profile-modal">
      <Text>Edit Profile</Text>
      <Pressable onPress={() => router.back()} testID="close-button">
        <Text>Close</Text>
      </Pressable>
    </View>
  )
}
describe('EditProfileModal', () => {
  it('renders modal content', () => {
    render(<EditProfileModal />)

    expect(screen.getByTestId('edit-profile-modal')).toBeOnTheScreen()
    expect(screen.getByText('Edit Profile')).toBeOnTheScreen()
  })

  it('calls router.back when close button is pressed', () => {
    const back = jest.fn()
    jest.mocked(useRouter).mockReturnValue({ back, push: jest.fn(), replace: jest.fn() } as any)

    render(<EditProfileModal />)

    fireEvent.press(screen.getByTestId('close-button'))

    expect(back).toHaveBeenCalledOnce()
  })
})

Testing Auth Guards

Expo Router's useSegments and useRootNavigationState enable auth-based redirects:

// hooks/useAuthGuard.ts
import { useEffect } from 'react'
import { useRouter, useSegments } from 'expo-router'
import { useSession } from './useSession'

export function useAuthGuard() {
  const { session, isLoading } = useSession()
  const segments = useSegments()
  const router = useRouter()

  useEffect(() => {
    if (isLoading) return

    const inAuthGroup = segments[0] === '(auth)'

    if (!session && !inAuthGroup) {
      router.replace('/(auth)/login')
    } else if (session && inAuthGroup) {
      router.replace('/')
    }
  }, [session, segments, isLoading])
}
// hooks/useAuthGuard.test.ts
import { renderHook } from '@testing-library/react-native'
import { describe, it, expect, jest, beforeEach } from '@jest/globals'

jest.mock('expo-router', () => ({
  useRouter: jest.fn(),
  useSegments: jest.fn(),
}))

jest.mock('./useSession', () => ({
  useSession: jest.fn(),
}))

import { useRouter, useSegments } from 'expo-router'
import { useSession } from './useSession'
import { useAuthGuard } from './useAuthGuard'

describe('useAuthGuard', () => {
  const replace = jest.fn()

  beforeEach(() => {
    jest.clearAllMocks()
    jest.mocked(useRouter).mockReturnValue({ replace } as any)
  })

  it('redirects to login when session is null', () => {
    jest.mocked(useSession).mockReturnValue({ session: null, isLoading: false })
    jest.mocked(useSegments).mockReturnValue(['(tabs)'])

    renderHook(() => useAuthGuard())

    expect(replace).toHaveBeenCalledWith('/(auth)/login')
  })

  it('redirects to home when session exists in auth group', () => {
    jest.mocked(useSession).mockReturnValue({ session: { userId: 'u1' }, isLoading: false })
    jest.mocked(useSegments).mockReturnValue(['(auth)'])

    renderHook(() => useAuthGuard())

    expect(replace).toHaveBeenCalledWith('/')
  })

  it('does nothing while loading', () => {
    jest.mocked(useSession).mockReturnValue({ session: null, isLoading: true })
    jest.mocked(useSegments).mockReturnValue(['(tabs)'])

    renderHook(() => useAuthGuard())

    expect(replace).not.toHaveBeenCalled()
  })
})

What Automated Tests Miss

Unit and integration tests with RNTL don't cover:

  • Native navigation animations — transitions and gesture interactions on real devices
  • Deep link handling — opening the app via a URL scheme
  • Push notification navigation — tapping a notification that routes to a specific screen
  • Platform-specific behaviour — iOS and Android handle modals and tab bars differently

HelpMeTest schedules automated tests against your deployed app. Pair it with Maestro for full end-to-end flows that cover what unit tests can't reach.

Summary

Expo Router testing strategy:

  • Screen components → test in isolation; mock useRouter and verify navigation calls
  • Typed routes → use renderRouter from expo-router/testing-library for navigation flow tests
  • Layouts → render with mock children; test tab switching via renderRouter
  • Modals → test the component directly; verify router.back() on close
  • Auth guards → extract hook logic; mock useSegments, useSession, and useRouter

The key tool is expo-router/testing-library's renderRouter — it gives you a real router context without needing a full device.

Read more