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
TherenderRouter 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();
});
Navigation Testing
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();
});
Navigation Methods
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());
Testing Links
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
Navigation Guards
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 inact():
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