Skip to main content
Expo Router provides testing utilities specifically designed for testing file-based routing and navigation in your app.

Setup

Install Dependencies

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

Configure Jest

jest.config.js
module.exports = {
  preset: 'jest-expo',
  setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
  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)',
  ],
};

Setup File

Expo Router’s testing utilities include automatic mocks:
jest-setup.js
import '@testing-library/jest-native/extend-expect';
import 'expo-router/testing-library/mocks';

renderRouter Utility

The renderRouter function renders your routes for testing.

Basic Usage

app/__tests__/routing.test.tsx
import { renderRouter, screen } from 'expo-router/testing-library';
import { Text } from 'react-native';

describe('Routing', () => {
  it('renders home screen', () => {
    renderRouter({
      index: () => <Text testID="home">Home Screen</Text>,
      about: () => <Text testID="about">About Screen</Text>,
    });

    expect(screen.getByTestId('home')).toBeVisible();
  });
});

With Initial URL

it('renders specific route', () => {
  renderRouter(
    {
      index: () => <Text testID="home">Home</Text>,
      profile: () => <Text testID="profile">Profile</Text>,
    },
    {
      initialUrl: '/profile',
    }
  );

  expect(screen.getByTestId('profile')).toBeVisible();
});

With Layouts

import { Stack } from 'expo-router';

it('renders with layout', () => {
  renderRouter({
    _layout: () => <Stack />,
    index: () => <Text testID="home">Home</Text>,
    'profile/[id]': () => <Text testID="profile">Profile</Text>,
  });

  expect(screen.getByTestId('home')).toBeVisible();
});

Using router Object

import { renderRouter, screen } from 'expo-router/testing-library';
import { router } from 'expo-router';
import { act } from '@testing-library/react-native';

it('navigates between screens', () => {
  renderRouter({
    index: () => <Text testID="home">Home</Text>,
    profile: () => <Text testID="profile">Profile</Text>,
  });

  expect(screen.getByTestId('home')).toBeVisible();

  // Navigate to profile
  act(() => router.push('/profile'));
  expect(screen.getByTestID('profile')).toBeVisible();

  // Go back
  act(() => router.back());
  expect(screen.getByTestId('home')).toBeVisible();
});
import { router } from 'expo-router';
import { act } from '@testing-library/react-native';

// Push (adds to history)
act(() => router.push('/profile/123'));

// Navigate (replaces in tab navigators)
act(() => router.navigate('/settings'));

// Replace (replaces current screen)
act(() => router.replace('/login'));

// Back
act(() => router.back());

// Can go back
const canGoBack = router.canGoBack();
expect(canGoBack).toBe(true);

// Dismiss (close modal)
act(() => router.dismiss());
import { Link } from 'expo-router';
import { fireEvent } from '@testing-library/react-native';

it('navigates when link is pressed', () => {
  renderRouter({
    index: () => (
      <>
        <Text testID="home">Home</Text>
        <Link href="/profile" testID="profile-link">
          Go to Profile
        </Link>
      </>
    ),
    profile: () => <Text testID="profile">Profile</Text>,
  });

  const link = screen.getByTestId('profile-link');
  fireEvent.press(link);

  expect(screen.getByTestId('profile')).toBeVisible();
});

Testing Dynamic Routes

Route Parameters

import { useLocalSearchParams } from 'expo-router';

it('passes route parameters', () => {
  renderRouter(
    {
      'profile/[id]': () => {
        const { id } = useLocalSearchParams();
        return <Text testID="user-id">{id}</Text>;
      },
    },
    {
      initialUrl: '/profile/123',
    }
  );

  expect(screen.getByTestId('user-id')).toHaveTextContent('123');
});

Query Parameters

import { useLocalSearchParams } from 'expo-router';

it('reads query parameters', () => {
  renderRouter(
    {
      search: () => {
        const { q, filter } = useLocalSearchParams();
        return (
          <>
            <Text testID="query">{q}</Text>
            <Text testID="filter">{filter}</Text>
          </>
        );
      },
    },
    {
      initialUrl: '/search?q=hello&filter=active',
    }
  );

  expect(screen.getByTestId('query')).toHaveTextContent('hello');
  expect(screen.getByTestId('filter')).toHaveTextContent('active');
});

Catch-all Routes

it('handles catch-all routes', () => {
  renderRouter(
    {
      '[...slug]': () => {
        const { slug } = useLocalSearchParams();
        return <Text testID="slug">{slug}</Text>;
      },
    },
    {
      initialUrl: '/docs/guide/testing',
    }
  );

  // slug is an array
  expect(screen.getByTestId('slug')).toHaveTextContent('docs,guide,testing');
});

Testing Hooks

usePathname

import { usePathname } from 'expo-router';

it('returns current pathname', () => {
  renderRouter(
    {
      'profile/[id]': () => {
        const pathname = usePathname();
        return <Text testID="pathname">{pathname}</Text>;
      },
    },
    {
      initialUrl: '/profile/123',
    }
  );

  expect(screen.getByTestId('pathname')).toHaveTextContent('/profile/123');
});

useSegments

import { useSegments } from 'expo-router';

it('returns route segments', () => {
  renderRouter(
    {
      'posts/[id]/comments': () => {
        const segments = useSegments();
        return <Text testID="segments">{segments.join('/')}</Text>;
      },
    },
    {
      initialUrl: '/posts/123/comments',
    }
  );

  expect(screen.getByTestId('segments')).toHaveTextContent('posts/123/comments');
});

useRouter

import { useRouter } from 'expo-router';

it('provides router methods', () => {
  const TestComponent = () => {
    const router = useRouter();
    return (
      <Button
        testID="navigate-btn"
        onPress={() => router.push('/profile')}
        title="Go"
      />
    );
  };

  renderRouter({
    index: () => <TestComponent />,
    profile: () => <Text testID="profile">Profile</Text>,
  });

  fireEvent.press(screen.getByTestId('navigate-btn'));
  expect(screen.getByTestId('profile')).toBeVisible();
});

Testing Layouts

Stack Navigator

import { Stack } from 'expo-router';

it('renders stack layout', () => {
  renderRouter({
    _layout: () => (
      <Stack
        screenOptions={{
          headerStyle: { backgroundColor: '#fff' },
        }}
      />
    ),
    index: () => <Text testID="home">Home</Text>,
    profile: () => <Text testID="profile">Profile</Text>,
  });

  expect(screen.getByTestId('home')).toBeVisible();
  
  act(() => router.push('/profile'));
  expect(screen.getByTestId('profile')).toBeVisible();
  
  act(() => router.back());
  expect(screen.getByTestId('home')).toBeVisible();
});

Tabs Navigator

import { Tabs } from 'expo-router';

it('renders tabs layout', () => {
  renderRouter({
    '(tabs)/_layout': () => <Tabs />,
    '(tabs)/index': () => <Text testID="home">Home</Text>,
    '(tabs)/profile': () => <Text testID="profile">Profile</Text>,
  });

  // Default tab
  expect(screen.getByTestId('home')).toBeVisible();

  // Switch tabs
  act(() => router.push('/profile'));
  expect(screen.getByTestId('profile')).toBeVisible();
});

Nested Layouts

it('renders nested layouts', () => {
  renderRouter({
    _layout: () => <Stack />,
    '(tabs)/_layout': () => <Tabs />,
    '(tabs)/index': () => <Text testID="home">Home</Text>,
    '(tabs)/profile': () => <Text testID="profile">Profile</Text>,
    modal: () => <Text testID="modal">Modal</Text>,
  });

  expect(screen.getByTestId('home')).toBeVisible();

  act(() => router.push('/modal'));
  expect(screen.getByTestId('modal')).toBeVisible();
});

Testing Redirects

import { Redirect } from 'expo-router';

it('handles redirects', () => {
  renderRouter(
    {
      index: () => <Redirect href="/home" />,
      home: () => <Text testID="home">Home</Text>,
    },
    {
      initialUrl: '/',
    }
  );

  // Should redirect to /home
  expect(screen.getByTestId('home')).toBeVisible();
});

it('conditional redirect', () => {
  const isAuthenticated = false;

  renderRouter({
    index: () => {
      if (!isAuthenticated) {
        return <Redirect href="/login" />;
      }
      return <Text testID="home">Home</Text>;
    },
    login: () => <Text testID="login">Login</Text>,
  });

  expect(screen.getByTestId('login')).toBeVisible();
});

Testing 404/Not Found

it('shows not found screen', () => {
  renderRouter(
    {
      index: () => <Text testID="home">Home</Text>,
      '+not-found': () => <Text testID="not-found">404</Text>,
    },
    {
      initialUrl: '/nonexistent',
    }
  );

  expect(screen.getByTestId('not-found')).toBeVisible();
});

Screen Utilities

Pathname Matchers

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

// Check pathname
expect(screen).toHavePathname('/profile/123');
expect(screen).not.toHavePathname('/home');

// Check segments
expect(screen).toHaveSegments(['profile', '123']);

// Check search params
expect(screen).toHaveSearchParams({ id: '123', tab: 'posts' });

Screen Methods

// Get current state
const pathname = screen.getPathname();
const segments = screen.getSegments();
const params = screen.getSearchParams();
const state = screen.getRouterState();

// Example assertions
expect(pathname).toBe('/profile/123');
expect(segments).toEqual(['profile', '123']);
expect(params).toEqual({ id: '123' });

Advanced Testing

import { useRouter } from 'expo-router';
import { useEffect } from 'react';

it('redirects unauthenticated users', () => {
  const ProtectedScreen = () => {
    const router = useRouter();
    const isAuthenticated = false;

    useEffect(() => {
      if (!isAuthenticated) {
        router.replace('/login');
      }
    }, [isAuthenticated]);

    return <Text testID="protected">Protected</Text>;
  };

  renderRouter(
    {
      index: () => <ProtectedScreen />,
      login: () => <Text testID="login">Login</Text>,
    },
    {
      initialUrl: '/',
    }
  );

  // Should redirect to login
  expect(screen.getByTestId('login')).toBeVisible();
});

Testing with Context

import { AuthProvider } from '@/contexts/AuthContext';

it('renders with context', () => {
  renderRouter(
    {
      index: () => <Text testID="home">Home</Text>,
    },
    {
      wrapper: ({ children }) => (
        <AuthProvider>
          {children}
        </AuthProvider>
      ),
    }
  );

  expect(screen.getByTestId('home')).toBeVisible();
});

Troubleshooting

Timing Issues

Wrap navigation in act():
import { act } from '@testing-library/react-native';

// Good
act(() => router.push('/profile'));

// Bad
router.push('/profile'); // May cause warnings

Async Navigation

Wait for navigation to complete:
import { waitFor } from '@testing-library/react-native';

it('navigates async', async () => {
  renderRouter({
    index: () => <AsyncComponent />,
    result: () => <Text testID="result">Result</Text>,
  });

  fireEvent.press(screen.getByTestId('async-button'));

  await waitFor(() => {
    expect(screen.getByTestId('result')).toBeVisible();
  });
});

Mock File System

For more complex scenarios, use the file system structure:
renderRouter({
  'app/_layout.tsx': () => <Stack />,
  'app/index.tsx': () => <Text>Home</Text>,
  'app/(tabs)/_layout.tsx': () => <Tabs />,
  'app/(tabs)/profile.tsx': () => <Text>Profile</Text>,
});

Best Practices

1. Test User Flows

it('completes checkout flow', () => {
  renderRouter({
    cart: () => <Text testID="cart">Cart</Text>,
    shipping: () => <Text testID="shipping">Shipping</Text>,
    payment: () => <Text testID="payment">Payment</Text>,
    success: () => <Text testID="success">Success</Text>,
  }, { initialUrl: '/cart' });

  // Navigate through flow
  act(() => router.push('/shipping'));
  expect(screen).toHavePathname('/shipping');

  act(() => router.push('/payment'));
  expect(screen).toHavePathname('/payment');

  act(() => router.push('/success'));
  expect(screen).toHavePathname('/success');
});

2. Test Navigation State

it('maintains navigation history', () => {
  renderRouter({
    index: () => <Text testID="home">Home</Text>,
    profile: () => <Text testID="profile">Profile</Text>,
  });

  expect(router.canGoBack()).toBe(false);

  act(() => router.push('/profile'));
  expect(router.canGoBack()).toBe(true);

  act(() => router.back());
  expect(router.canGoBack()).toBe(false);
});

3. Test Edge Cases

it('handles invalid routes', () => {
  renderRouter({
    index: () => <Text testID="home">Home</Text>,
    '+not-found': () => <Text testID="404">Not Found</Text>,
  });

  act(() => router.push('/invalid'));
  expect(screen.getByTestId('404')).toBeVisible();
});

Next Steps

Unit Testing

Test components and logic

E2E Testing

Test complete user flows

Expo Router

Learn more about Expo Router

Debugging

Debug navigation issues