React Native Unit Testing with Jest: Components, Hooks, and Navigation

React Native Unit Testing with Jest: Components, Hooks, and Navigation

Unit tests are the foundation of a React Native test strategy. They run fast, give immediate feedback, and catch logic errors long before they reach a device or simulator. Yet many React Native teams skip them or write tests that test nothing meaningful.

This guide covers the practical patterns you need: testing components, hooks, async operations, and navigation in a React Native project using Jest.

Setup

React Native projects created with the CLI already include Jest. Check package.json:

{
  "jest": {
    "preset": "react-native"
  }
}

Install React Native Testing Library, which provides the rendering utilities:

npm install --save-dev @testing-library/react-native @testing-library/jest-native

Add the custom matchers to your setup file:

// jest.setup.js
import '@testing-library/jest-native/extend-expect';

Update package.json:

{
  "jest": {
    "preset": "react-native",
    "setupFilesAfterFramework": ["./jest.setup.js"]
  }
}

Testing Components

The core pattern: render a component, query elements, assert on their state.

// components/LoginForm.js
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, Text } from 'react-native';

export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  return (
    <View>
      <TextInput
        testID="email-input"
        value={email}
        onChangeText={setEmail}
        placeholder="Email"
      />
      <TextInput
        testID="password-input"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        placeholder="Password"
      />
      <TouchableOpacity
        testID="submit-button"
        onPress={() => onSubmit({ email, password })}
        disabled={!email || !password}
      >
        <Text>Log In</Text>
      </TouchableOpacity>
    </View>
  );
}
// components/LoginForm.test.js
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react-native';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('renders email and password inputs', () => {
    render(<LoginForm onSubmit={jest.fn()} />);
    expect(screen.getByTestId('email-input')).toBeOnTheScreen();
    expect(screen.getByTestId('password-input')).toBeOnTheScreen();
  });

  it('calls onSubmit with email and password when form is submitted', () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);

    fireEvent.changeText(screen.getByTestId('email-input'), 'user@example.com');
    fireEvent.changeText(screen.getByTestId('password-input'), 'secret123');
    fireEvent.press(screen.getByTestId('submit-button'));

    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'user@example.com',
      password: 'secret123',
    });
  });

  it('does not call onSubmit when fields are empty', () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);

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

    expect(mockSubmit).not.toHaveBeenCalled();
  });
});

Querying Elements

React Native Testing Library provides several query methods:

// By testID (most reliable, doesn't depend on text content)
screen.getByTestId('submit-button')

// By text (good for visible labels)
screen.getByText('Log In')

// By role (accessibility-aware)
screen.getByRole('button', { name: 'Log In' })

// By placeholder text
screen.getByPlaceholderText('Email')

// Returns null instead of throwing if not found
screen.queryByTestId('optional-element')

// Async variants — wait for element to appear
await screen.findByTestId('success-message')

Prefer getByTestId for reliability. Text-based selectors break when copy changes; testIDs are stable.

Testing Async Components

Many components make network requests or perform async operations. Use waitFor or findBy* queries:

// components/UserProfile.js
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUser } from '../api/users';

export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <ActivityIndicator testID="loader" />;
  return (
    <View>
      <Text testID="user-name">{user.name}</Text>
      <Text testID="user-email">{user.email}</Text>
    </View>
  );
}
// components/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react-native';
import { UserProfile } from './UserProfile';
import { fetchUser } from '../api/users';

// Mock the API module
jest.mock('../api/users');

describe('UserProfile', () => {
  it('shows loader while fetching', () => {
    fetchUser.mockReturnValue(new Promise(() => {})); // never resolves
    render(<UserProfile userId="123" />);
    expect(screen.getByTestId('loader')).toBeOnTheScreen();
  });

  it('shows user data after loading', async () => {
    fetchUser.mockResolvedValue({ name: 'Alice', email: 'alice@example.com' });
    render(<UserProfile userId="123" />);

    await waitFor(() => {
      expect(screen.getByTestId('user-name')).toHaveTextContent('Alice');
      expect(screen.getByTestId('user-email')).toHaveTextContent('alice@example.com');
    });
  });

  it('passes userId to the API', async () => {
    fetchUser.mockResolvedValue({ name: 'Bob', email: 'bob@example.com' });
    render(<UserProfile userId="456" />);

    await waitFor(() => screen.getByTestId('user-name'));

    expect(fetchUser).toHaveBeenCalledWith('456');
  });
});

Testing Custom Hooks

Use renderHook from React Native Testing Library to test hooks in isolation:

// hooks/useCounter.js
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  return { count, increment, decrement, reset };
}
// hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with default value of 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.decrement());
    expect(result.current.count).toBe(4);
  });

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(3));
    act(() => result.current.increment());
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(3);
  });
});

Testing Hooks with Async Operations

// hooks/useFetchUser.js
import { useState, useEffect } from 'react';
import { fetchUser } from '../api/users';

export function useFetchUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, loading, error };
}
// hooks/useFetchUser.test.js
import { renderHook, waitFor } from '@testing-library/react-native';
import { useFetchUser } from './useFetchUser';
import { fetchUser } from '../api/users';

jest.mock('../api/users');

describe('useFetchUser', () => {
  it('starts in loading state', () => {
    fetchUser.mockReturnValue(new Promise(() => {}));
    const { result } = renderHook(() => useFetchUser('123'));
    expect(result.current.loading).toBe(true);
    expect(result.current.user).toBeNull();
  });

  it('sets user on success', async () => {
    const mockUser = { id: '123', name: 'Alice' };
    fetchUser.mockResolvedValue(mockUser);

    const { result } = renderHook(() => useFetchUser('123'));

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBeNull();
  });

  it('sets error on failure', async () => {
    const error = new Error('Network error');
    fetchUser.mockRejectedValue(error);

    const { result } = renderHook(() => useFetchUser('123'));

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.user).toBeNull();
    expect(result.current.error).toEqual(error);
  });
});

Testing Navigation

React Navigation is notoriously difficult to test because it relies on a context provider. The cleanest approach: wrap your test renders in a navigation container.

// test-utils/navigation.js
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';

export function NavigationWrapper({ children }) {
  return <NavigationContainer>{children}</NavigationContainer>;
}

For components that use useNavigation() internally, you need either the wrapper or to mock the hook:

// Mock navigation approach
jest.mock('@react-navigation/native', () => ({
  ...jest.requireActual('@react-navigation/native'),
  useNavigation: () => ({
    navigate: jest.fn(),
    goBack: jest.fn(),
    push: jest.fn(),
  }),
}));

// Then in tests:
import { useNavigation } from '@react-navigation/native';

it('navigates to details on item press', () => {
  const { navigate } = useNavigation();
  render(<ItemList />);
  fireEvent.press(screen.getByTestId('item-0'));
  expect(navigate).toHaveBeenCalledWith('ItemDetails', { id: '0' });
});

Snapshot Testing

Snapshot tests capture component output and alert you to unintended visual changes:

it('matches snapshot', () => {
  const tree = render(<LoginForm onSubmit={jest.fn()} />).toJSON();
  expect(tree).toMatchSnapshot();
});

Use snapshots sparingly. They're good for catching accidental changes to stable components but become a maintenance burden on components that change frequently. When a snapshot fails, verify the change was intentional before updating with jest --updateSnapshot.

Running Tests

# Run all tests
npm <span class="hljs-built_in">test

<span class="hljs-comment"># Watch mode
npm <span class="hljs-built_in">test -- --watch

<span class="hljs-comment"># Run specific file
npm <span class="hljs-built_in">test -- LoginForm.test.js

<span class="hljs-comment"># With coverage
npm <span class="hljs-built_in">test -- --coverage

Coverage reports show which lines and branches are untested:

npm test -- --coverage --coverageDirectory=coverage
open coverage/lcov-report/index.html

What to Test and What Not To

Good candidates for unit tests:

  • Business logic in hooks and utility functions
  • Conditional rendering based on props or state
  • Error states and loading states
  • Form validation and submission
  • Navigation calls

Less valuable to unit test:

  • Component styling (use visual tests or screenshots)
  • Native module behavior (mock it, test the mock behavior)
  • Third-party library internals

The goal is tests that catch real bugs. A test that only checks "this component renders without crashing" adds little value. A test that verifies "the submit button is disabled when the email field is empty" catches a real regression.

Read more