Testing Navigation in React Native: React Navigation, Deep Links, and Stack Flows
Testing navigation in React Native requires mocking React Navigation's hooks and prop injection, verifying that screens navigate correctly in response to user actions, and testing deep links and complex stack flows. This post covers the full range of navigation testing patterns.
Navigation is one of the trickiest parts of a React Native app to test. Screens depend on navigation props, hooks read from navigation context, deep links trigger screen transitions, and testing a full navigation stack requires either mounting the entire app or carefully mocking the navigation layer.
This guide walks through every approach — from unit testing a single screen to testing complete navigation flows.
The Problem with Navigation in Tests
React Navigation requires a NavigationContainer (or at minimum a navigation context) to work. Without it, calling useNavigation() throws, navigation.navigate() doesn't exist, and components that access route params crash.
You have two options:
- Mock the navigation — replace navigation hooks with jest mocks for unit and component tests
- Wrap with real navigation — mount a NavigationContainer for integration-level tests
Both are valid. Use mocking for testing a single screen's behavior in isolation, and real navigation for testing flows that span multiple screens.
Setup
Install the navigation testing utilities:
npm install --save-dev @react-navigation/native @react-navigation/native-stack
# Testing utilities are included in @react-navigation/nativeApproach 1: Mocking Navigation Props
The simplest approach for testing a screen that receives navigation and route as props:
// screens/ProductScreen.tsx
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'Product'>;
export function ProductScreen({ navigation, route }: Props) {
const { productId } = route.params;
return (
<View>
<Text testID="product-id">{productId}</Text>
<Button title="Go Back" onPress={() => navigation.goBack()} />
<Button
title="View Reviews"
onPress={() => navigation.navigate('Reviews', { productId })}
/>
</View>
);
}In the test, inject mocked navigation and route:
import { render, screen } from '@testing-library/react-native';
import userEvent from '@testing-library/user-event';
import { ProductScreen } from '../screens/ProductScreen';
function buildNavigation(overrides = {}) {
return {
navigate: jest.fn(),
goBack: jest.fn(),
push: jest.fn(),
pop: jest.fn(),
replace: jest.fn(),
reset: jest.fn(),
canGoBack: jest.fn().mockReturnValue(true),
dispatch: jest.fn(),
setOptions: jest.fn(),
addListener: jest.fn().mockReturnValue(jest.fn()),
removeListener: jest.fn(),
isFocused: jest.fn().mockReturnValue(true),
...overrides,
};
}
function buildRoute(params = {}) {
return { key: 'test-key', name: 'Product', params };
}
describe('ProductScreen', () => {
it('shows product id from route params', () => {
const navigation = buildNavigation();
const route = buildRoute({ productId: 'prod-123' });
render(<ProductScreen navigation={navigation as any} route={route as any} />);
expect(screen.getByTestId('product-id')).toHaveTextContent('prod-123');
});
it('navigates back when Go Back is pressed', async () => {
const navigation = buildNavigation();
const user = userEvent.setup();
render(
<ProductScreen
navigation={navigation as any}
route={buildRoute({ productId: 'prod-123' }) as any}
/>
);
await user.press(screen.getByText('Go Back'));
expect(navigation.goBack).toHaveBeenCalledOnce();
});
it('navigates to reviews with correct product id', async () => {
const navigation = buildNavigation();
const user = userEvent.setup();
render(
<ProductScreen
navigation={navigation as any}
route={buildRoute({ productId: 'prod-456' }) as any}
/>
);
await user.press(screen.getByText('View Reviews'));
expect(navigation.navigate).toHaveBeenCalledWith('Reviews', {
productId: 'prod-456',
});
});
});Approach 2: Mocking useNavigation Hook
Many components use the useNavigation hook instead of props. Mock it at the module level:
// components/CartButton.tsx
import { useNavigation } from '@react-navigation/native';
export function CartButton({ itemCount }: { itemCount: number }) {
const navigation = useNavigation();
return (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={`Cart, ${itemCount} items`}
onPress={() => navigation.navigate('Cart' as never)}
>
<Text>{itemCount}</Text>
</TouchableOpacity>
);
}const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
navigate: mockNavigate,
goBack: jest.fn(),
canGoBack: jest.fn().mockReturnValue(true),
}),
}));
import { render, screen } from '@testing-library/react-native';
import userEvent from '@testing-library/user-event';
import { CartButton } from '../components/CartButton';
beforeEach(() => jest.clearAllMocks());
test('navigates to cart on press', async () => {
const user = userEvent.setup();
render(<CartButton itemCount={3} />);
await user.press(screen.getByRole('button', { name: /Cart/i }));
expect(mockNavigate).toHaveBeenCalledWith('Cart');
});Mocking useRoute
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useRoute: () => ({
key: 'test',
name: 'UserProfile',
params: { userId: 'user-42' },
}),
}));Approach 3: Real NavigationContainer for Integration Tests
For testing flows that involve multiple screens or need real navigation behavior, wrap with the actual NavigationContainer:
// test-utils/navigation.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { render } from '@testing-library/react-native';
const Stack = createNativeStackNavigator();
interface RenderWithNavigationOptions {
initialRouteName?: string;
initialParams?: Record<string, unknown>;
screens: Record<string, React.ComponentType<any>>;
}
export function renderWithNavigation(
options: RenderWithNavigationOptions
) {
const { screens, initialRouteName, initialParams } = options;
const screenEntries = Object.entries(screens);
return render(
<NavigationContainer>
<Stack.Navigator initialRouteName={initialRouteName ?? screenEntries[0][0]}>
{screenEntries.map(([name, Component]) => (
<Stack.Screen
key={name}
name={name}
component={Component}
initialParams={name === initialRouteName ? initialParams : undefined}
/>
))}
</Stack.Navigator>
</NavigationContainer>
);
}Using it:
import { screen, waitFor } from '@testing-library/react-native';
import userEvent from '@testing-library/user-event';
import { renderWithNavigation } from '../test-utils/navigation';
import { LoginScreen } from '../screens/LoginScreen';
import { HomeScreen } from '../screens/HomeScreen';
test('navigates to home after successful login', async () => {
jest.spyOn(authService, 'login').mockResolvedValue({ userId: '1', name: 'Alice' });
const user = userEvent.setup();
renderWithNavigation({
initialRouteName: 'Login',
screens: { Login: LoginScreen, Home: HomeScreen },
});
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
await user.press(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(screen.getByTestId('home-screen')).toBeOnTheScreen();
});
});Testing Tab Navigation
Tab navigator requires a slightly different setup:
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const Tab = createBottomTabNavigator();
function renderWithTabs() {
return render(
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name="Feed" component={FeedScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
</NavigationContainer>
);
}
test('switches to search tab', async () => {
const user = userEvent.setup();
renderWithTabs();
// Tab bar buttons have role "tab" and the label as accessible name
await user.press(screen.getByRole('tab', { name: 'Search' }));
await waitFor(() => {
expect(screen.getByTestId('search-screen')).toBeOnTheScreen();
});
});Testing Deep Links
Deep links invoke navigation from outside the app. React Navigation handles them via the linking config in NavigationContainer.
Unit testing the linking config
// navigation/linking.ts
export const linking = {
prefixes: ['myapp://', 'https://myapp.com'],
config: {
screens: {
Home: '',
Product: 'product/:productId',
UserProfile: 'users/:userId',
Settings: {
screens: {
Account: 'settings/account',
Notifications: 'settings/notifications',
},
},
},
},
};import { getStateFromPath } from '@react-navigation/native';
import { linking } from '../navigation/linking';
describe('Deep link routing', () => {
function parseUrl(url: string) {
const path = url.replace(/^myapp:\/\//, '/');
return getStateFromPath(path, linking.config);
}
it('routes product URL to Product screen', () => {
const state = parseUrl('myapp://product/prod-123');
expect(state?.routes[0].name).toBe('Product');
expect(state?.routes[0].params).toEqual({ productId: 'prod-123' });
});
it('routes user profile URL', () => {
const state = parseUrl('myapp://users/user-456');
expect(state?.routes[0].name).toBe('UserProfile');
expect(state?.routes[0].params).toEqual({ userId: 'user-456' });
});
it('routes nested settings URLs', () => {
const state = parseUrl('myapp://settings/notifications');
// Nested navigator — check the route tree
expect(state?.routes[0].name).toBe('Settings');
const settingsState = state?.routes[0].state;
expect(settingsState?.routes[0].name).toBe('Notifications');
});
});Integration testing deep links
test('deep link opens product screen', async () => {
const { getByTestId } = renderWithNavigation({
screens: { Home: HomeScreen, Product: ProductScreen },
initialRouteName: 'Home',
});
// Simulate deep link
act(() => {
Linking.emit('url', { url: 'myapp://product/prod-789' });
});
await waitFor(() => {
expect(getByTestId('product-screen')).toBeOnTheScreen();
});
});Testing Navigation State and Back Button
test('back button returns to previous screen', async () => {
const user = userEvent.setup();
renderWithNavigation({
screens: {
ProductList: ProductListScreen,
ProductDetail: ProductDetailScreen,
},
});
// Navigate forward
await user.press(screen.getByText('Widget Pro'));
await waitFor(() => {
expect(screen.getByTestId('product-detail')).toBeOnTheScreen();
});
// Go back
await user.press(screen.getByLabelText('Go back'));
await waitFor(() => {
expect(screen.getByTestId('product-list')).toBeOnTheScreen();
});
});Testing Focus and Blur Events
Screens often refresh data or stop timers when they gain/lose focus:
// screens/FeedScreen.tsx
export function FeedScreen() {
const [posts, setPosts] = React.useState([]);
const navigation = useNavigation();
React.useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
fetchPosts().then(setPosts);
});
return unsubscribe;
}, [navigation]);
return <FlatList data={posts} renderItem={/* ... */} />;
}const mockAddListener = jest.fn();
let focusListener: (() => void) | null = null;
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
addListener: (event: string, listener: () => void) => {
if (event === 'focus') focusListener = listener;
return jest.fn();
},
}),
}));
test('refreshes posts when screen focuses', async () => {
const mockFetchPosts = jest.spyOn(api, 'fetchPosts').mockResolvedValue([
{ id: '1', title: 'Post One' },
]);
render(<FeedScreen />);
// Simulate focus event
act(() => {
focusListener?.();
});
await waitFor(() => {
expect(screen.getByText('Post One')).toBeOnTheScreen();
});
expect(mockFetchPosts).toHaveBeenCalledOnce();
});Testing Modal Navigation
test('modal appears and closes correctly', async () => {
const user = userEvent.setup();
render(
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Main" component={MainScreen} />
<Stack.Screen
name="FilterModal"
component={FilterModal}
options={{ presentation: 'modal' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
await user.press(screen.getByText('Filter'));
await waitFor(() => {
expect(screen.getByTestId('filter-modal')).toBeOnTheScreen();
});
await user.press(screen.getByText('Apply'));
await waitFor(() => {
expect(screen.queryByTestId('filter-modal')).not.toBeOnTheScreen();
});
});createNavigationMock Helper
For large test suites, a centralized helper reduces duplication:
// test-utils/createNavigationMock.ts
export function createNavigationMock(overrides: Partial<any> = {}) {
return {
navigate: jest.fn(),
goBack: jest.fn(),
push: jest.fn(),
pop: jest.fn(),
popToTop: jest.fn(),
replace: jest.fn(),
reset: jest.fn(),
dispatch: jest.fn(),
setOptions: jest.fn(),
setParams: jest.fn(),
canGoBack: jest.fn(() => true),
isFocused: jest.fn(() => true),
getId: jest.fn(() => 'test-screen-id'),
getParent: jest.fn(() => null),
getState: jest.fn(() => ({ index: 0, routes: [] })),
addListener: jest.fn(() => jest.fn()),
removeListener: jest.fn(),
...overrides,
};
}
export function createRouteMock<T extends Record<string, unknown>>(
name: string,
params?: T
) {
return {
key: `${name}-test-key`,
name,
params: params ?? {},
path: undefined,
};
}Summary
Navigation testing in React Native operates on a spectrum from pure unit (mocking hooks) to full integration (real NavigationContainer). The right choice depends on what you're testing: a single screen's behavior needs only a mocked navigation object; a multi-screen flow needs the real navigator; deep link routing is best tested by parsing URLs directly against the linking config. Build a small set of helpers to eliminate boilerplate and you'll find navigation tests straightforward to write and maintain.