Testing Navigation in React Native: React Navigation, Deep Links, and Stack Flows

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:

  1. Mock the navigation — replace navigation hooks with jest mocks for unit and component tests
  2. 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/native

Approach 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();
  });
});

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');
  });
});
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.

Read more

Start now free