Jest + React Native Testing Library: The Complete Guide

Jest + React Native Testing Library: The Complete Guide

A comprehensive walkthrough of React Native Testing Library with Jest, covering renderHook for custom hooks, userEvent for realistic interactions, the full query API, async utilities, and accessibility-first testing patterns.

React Native Testing Library (RNTL) has matured into the standard way to test React Native components and hooks. Paired with Jest, it gives you a fast, reliable feedback loop without needing a simulator. This guide goes deep on the APIs you'll use every day.

Setup

Install the required packages:

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

Configure Jest in jest.config.js:

module.exports = {
  preset: 'react-native',
  setupFilesAfterFramework: ['@testing-library/jest-native/extend-expect'],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation)/)',
  ],
};

Add the setup file to jest.setup.ts:

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

Rendering Components

The render function mounts your component into a virtual tree and returns a set of queries:

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import { Text, View } from 'react-native';

function Greeting({ name }: { name: string }) {
  return (
    <View>
      <Text testID="greeting">Hello, {name}!</Text>
    </View>
  );
}

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);
  expect(screen.getByTestId('greeting')).toHaveTextContent('Hello, Alice!');
});

Using screen is preferred over destructuring from render — it always queries the most recently rendered tree, which matters when you re-render inside a test.

The Query API

RNTL exposes six query families. Understanding when to use each is the difference between fragile and resilient tests.

getBy, queryBy, findBy

Prefix Returns On missing On multiple
getBy element throws throws
queryBy element or null returns null throws
findBy Promise<element> rejects rejects
getAllBy array throws
queryAllBy array empty array
findAllBy Promise<array> rejects
// Use getBy when the element must be present
const button = screen.getByRole('button', { name: 'Submit' });

// Use queryBy to assert absence
expect(screen.queryByText('Error')).toBeNull();

// Use findBy for async rendering
const modal = await screen.findByText('Confirmation');

Query Selectors — Priority Order

RNTL recommends querying in this order (most accessible to least):

  1. ByRole — ties to accessibility semantics
  2. ByLabelText — for inputs with labels
  3. ByPlaceholderText — for inputs with placeholders
  4. ByText — visible text content
  5. ByDisplayValue — current value of form elements
  6. ByTestId — escape hatch when nothing else works
// Preferred: role-based
screen.getByRole('button', { name: /submit/i });

// Text content
screen.getByText('Continue');

// Accessibility label
screen.getByLabelText('Email address');

// Last resort
screen.getByTestId('special-button');

userEvent — Realistic Interactions

The userEvent API simulates realistic user interactions, dispatching the full sequence of events a real user would trigger. It's async and should be awaited.

import { render, screen } from '@testing-library/react-native';
import userEvent from '@testing-library/user-event';

function LoginForm() {
  const [email, setEmail] = React.useState('');
  const [submitted, setSubmitted] = React.useState(false);

  return (
    <View>
      <TextInput
        accessibilityLabel="Email"
        value={email}
        onChangeText={setEmail}
      />
      <Button title="Login" onPress={() => setSubmitted(true)} />
      {submitted && <Text>Welcome, {email}</Text>}
    </View>
  );
}

test('user can log in', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.type(screen.getByLabelText('Email'), 'alice@example.com');
  await user.press(screen.getByRole('button', { name: 'Login' }));

  expect(screen.getByText('Welcome, alice@example.com')).toBeOnTheScreen();
});

userEvent.type fires individual keystrokes, triggering onKeyPress, onChange, and onChangeText in sequence — unlike fireEvent.changeText which shortcuts directly to the handler.

Scrolling and Swiping

const user = userEvent.setup();

// Scroll a list
await user.scrollTo(screen.getByTestId('scroll-view'), { y: 500 });

// Long press
await user.longPress(screen.getByText('Hold Me'));

Async Utilities

Most real components fetch data, use timers, or animate. RNTL provides several tools.

waitFor

Retries an assertion until it passes or times out:

import { render, screen, waitFor } from '@testing-library/react-native';

test('shows data after fetch', async () => {
  render(<UserProfile userId="1" />);

  // Initially shows loading
  expect(screen.getByText('Loading...')).toBeOnTheScreen();

  // Wait for data to appear
  await waitFor(() => {
    expect(screen.getByText('Alice Johnson')).toBeOnTheScreen();
  });
});

waitForElementToBeRemoved

More explicit than waitFor for disappearing elements:

await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
expect(screen.getByText('Content loaded')).toBeOnTheScreen();

act

Wrap state updates that happen outside of event handlers:

import { act } from '@testing-library/react-native';

test('timer updates counter', async () => {
  render(<CountdownTimer seconds={3} />);

  await act(async () => {
    jest.advanceTimersByTime(3000);
  });

  expect(screen.getByText('Time is up!')).toBeOnTheScreen();
});

renderHook — Testing Custom Hooks

renderHook lets you test hooks in isolation without building a host component:

import { renderHook, act } from '@testing-library/react-native';

function useCounter(initial = 0) {
  const [count, setCount] = React.useState(initial);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initial);
  return { count, increment, decrement, reset };
}

test('increments counter', () => {
  const { result } = renderHook(() => useCounter(5));

  expect(result.current.count).toBe(5);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(6);
});

test('resets to initial value', () => {
  const { result } = renderHook(() => useCounter(10));

  act(() => {
    result.current.increment();
    result.current.increment();
    result.current.reset();
  });

  expect(result.current.count).toBe(10);
});

Hooks with Context

Pass a wrapper to provide context:

import { ThemeContext } from '../ThemeContext';

test('useTheme reads from context', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <ThemeContext.Provider value={{ mode: 'dark' }}>
      {children}
    </ThemeContext.Provider>
  );

  const { result } = renderHook(() => useTheme(), { wrapper });
  expect(result.current.mode).toBe('dark');
});

Async Hooks

function useUserData(userId: string) {
  const [user, setUser] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

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

  return { user, loading };
}

test('fetches user data', async () => {
  jest.spyOn(global, 'fetchUser').mockResolvedValue({ name: 'Bob' });

  const { result } = renderHook(() => useUserData('42'));

  expect(result.current.loading).toBe(true);

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.user).toEqual({ name: 'Bob' });
});

Accessibility Queries in Depth

Accessibility queries are not just good practice — they catch real bugs that affect screen reader users.

function ProductCard({ product }: { product: Product }) {
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityLabel={`Add ${product.name} to cart`}
      accessibilityState={{ disabled: !product.inStock }}
      onPress={() => addToCart(product)}
    >
      <Text>{product.name}</Text>
      <Text>{product.price}</Text>
    </Pressable>
  );
}

test('disabled when out of stock', () => {
  render(
    <ProductCard
      product={{ name: 'Widget', price: '$9.99', inStock: false }}
    />
  );

  const button = screen.getByRole('button', { name: /Add Widget to cart/i });
  expect(button).toBeDisabled();
});

test('can add in-stock item', async () => {
  const user = userEvent.setup();
  const mockAddToCart = jest.fn();

  render(
    <ProductCard
      product={{ name: 'Widget', price: '$9.99', inStock: true }}
    />
  );

  await user.press(screen.getByRole('button', { name: /Add Widget/i }));
  expect(mockAddToCart).toHaveBeenCalledWith(
    expect.objectContaining({ name: 'Widget' })
  );
});

Checking Accessibility State

// Check role
expect(screen.getByRole('checkbox')).toBeChecked();

// Check accessibility state
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByRole('switch')).toHaveAccessibilityState({ checked: true });

// Check label
expect(screen.getByLabelText('Password')).toBeOnTheScreen();

Custom Matchers from jest-native

@testing-library/jest-native extends Jest with RN-specific matchers:

// Element visibility and presence
expect(element).toBeOnTheScreen();     // mounted and visible
expect(element).toBeEnabled();
expect(element).toBeDisabled();

// Text content
expect(element).toHaveTextContent('Hello');
expect(element).toHaveTextContent(/hello/i);

// Style
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle({ display: 'flex' });

// Prop
expect(element).toHaveProp('value', 'test@example.com');

// Accessibility
expect(element).toBeChecked();
expect(element).toHaveAccessibilityValue({ now: 50, min: 0, max: 100 });

Testing with Providers

Production components almost always need context providers. Create a custom render utility:

// test-utils.tsx
import React from 'react';
import { render, RenderOptions } from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigationContainer } from '@react-navigation/native';
import { ThemeProvider } from '../ThemeProvider';

function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <NavigationContainer>
        <ThemeProvider>{children}</ThemeProvider>
      </NavigationContainer>
    </QueryClientProvider>
  );
}

const customRender = (ui: React.ReactElement, options?: RenderOptions) =>
  render(ui, { wrapper: AllProviders, ...options });

export * from '@testing-library/react-native';
export { customRender as render };

Now every test gets full context automatically:

import { render, screen } from '../test-utils';

test('profile page shows user name', async () => {
  render(<ProfileScreen userId="1" />);
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeOnTheScreen();
  });
});

Performance Tips

Use screen over destructured queries. Re-renders invalidate destructured references.

Avoid act wrapping when possible. userEvent and findBy* handle act internally.

Reset mocks between tests. Add to jest.config.js:

clearMocks: true,
resetMocks: true,

Prefer findBy over waitFor(getBy). findBy is idiomatic and more readable.

Group related renders. If multiple tests render the same component with the same setup, extract to beforeEach.

Summary

React Native Testing Library with Jest gives you a fast, realistic testing experience that maps directly to how users interact with your app. The key principles: query by accessibility roles first, use userEvent for interactions, renderHook for hook logic, and waitFor / findBy for async behavior. Build a custom render utility early — it pays off in every test you write.

Read more

Start now free