React Native Testing Library: Unit Tests, Queries, and Async Utilities

React Native Testing Library: Unit Tests, Queries, and Async Utilities

React Native Testing Library (RNTL) is the unit and integration testing layer that sits between your Jest unit tests and your Detox/Maestro E2E tests. It renders React Native components in a test environment, provides a DOM-like query API, and gives you control over async behavior — all without a device or simulator.

If you've used Testing Library for React on the web, RNTL will be familiar. The philosophy is the same: test your components the way users interact with them, not the way they're implemented. The differences are in the query vocabulary and some async patterns specific to React Native.

Setup

Install the library and its peer dependencies:

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-native-.*|@react-navigation)/)',
  ],
};

The transformIgnorePatterns entry is important — React Native modules ship as ES modules and need to be transformed by Babel in the test environment. If you're getting "unexpected token" errors on imports from react-native-* packages, this is the fix.

Your First Test

// components/Counter.tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <View>
      <Text testID="count-display">{count}</Text>
      <TouchableOpacity testID="increment-button" onPress={() => setCount(c => c + 1)}>
        <Text>Increment</Text>
      </TouchableOpacity>
    </View>
  );
}
// components/Counter.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Counter } from './Counter';

describe('Counter', () => {
  it('starts at zero', () => {
    render(<Counter />);
    expect(screen.getByTestId('count-display')).toHaveTextContent('0');
  });

  it('increments when button is pressed', () => {
    render(<Counter />);
    fireEvent.press(screen.getByTestId('increment-button'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('1');
  });

  it('can increment multiple times', () => {
    render(<Counter />);
    const button = screen.getByTestId('increment-button');
    fireEvent.press(button);
    fireEvent.press(button);
    fireEvent.press(button);
    expect(screen.getByTestId('count-display')).toHaveTextContent('3');
  });
});

The Screen Object

RNTL v7+ exposes queries via the screen object (imported from @testing-library/react-native). This is preferred over destructuring from render():

// Preferred
render(<MyComponent />);
const element = screen.getByTestId('foo');

// Also works, but not preferred
const { getByTestId } = render(<MyComponent />);
const element = getByTestId('foo');

Using screen makes it easier to see what's being queried and avoids having to thread query functions through multiple levels of test setup.

Queries

Queries are the core of RNTL. They find elements in the rendered tree.

Query Variants

Every query exists in six forms:

Variant Returns Throws if missing Throws if multiple
getBy* Element Yes Yes
getAllBy* Array Yes No
queryBy* Element or null No Yes
queryAllBy* Array (empty OK) No No
findBy* Promise<Element> Yes (async) Yes
findAllBy* Promise<Array> Yes (async) No

Use getBy* when you expect the element to be there. Use queryBy* when it might not be (for negative assertions). Use findBy* when the element appears asynchronously.

Available Queries

// By testID — most reliable, matches testID prop
screen.getByTestId('submit-button')

// By visible text — use for user-facing text
screen.getByText('Submit')
screen.getByText(/submit/i)  // regex, case-insensitive

// By display value — input values
screen.getByDisplayValue('user@example.com')

// By placeholder text
screen.getByPlaceholderText('Enter email')

// By accessibility label
screen.getByLabelText('Email address')

// By role — semantic queries
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('textbox')
screen.getByRole('checkbox', { checked: true })

// By hint text (accessibility hint)
screen.getByHintText('Double tap to submit the form')

Query Priority

The Testing Library philosophy recommends queries in this order, from most to least preferred:

  1. getByRole — most accessible, tests what users and screen readers see
  2. getByLabelText — good for form inputs
  3. getByPlaceholderText — acceptable for inputs without labels
  4. getByText — fine for non-interactive elements
  5. getByTestId — use when nothing else works

In practice, React Native components often don't have well-defined ARIA roles, so getByTestId is used more frequently than in web testing. Add testID props liberally — they're stable, fast to query, and explicit.

Custom Matchers

With @testing-library/jest-native configured, you get useful custom matchers:

// Element is visible (not hidden by style)
expect(element).toBeVisible();
expect(element).not.toBeVisible();

// Element is disabled
expect(element).toBeDisabled();
expect(element).toBeEnabled();

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

// Style
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle({ fontWeight: 'bold', fontSize: 16 });

// Prop value
expect(element).toHaveProp('value', 'user@example.com');
expect(element).toHaveProp('editable', false);

FireEvent

fireEvent simulates native events on elements:

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

// Press
fireEvent.press(screen.getByTestId('button'));

// Change text (for TextInput)
fireEvent.changeText(screen.getByTestId('email-input'), 'user@example.com');

// Scroll
fireEvent.scroll(screen.getByTestId('scroll-view'), {
  nativeEvent: {
    contentOffset: { y: 500 },
    contentSize: { height: 1000, width: 400 },
    layoutMeasurement: { height: 600, width: 400 },
  },
});

// Custom events
fireEvent(element, 'onSwipeLeft');

// With event data
fireEvent(element, 'onSelectionChange', {
  nativeEvent: { selection: { start: 0, end: 5 } },
});

UserEvent — Realistic User Interactions

RNTL v12+ includes a userEvent API that simulates realistic user interactions rather than firing synthetic events. This catches bugs that fireEvent misses:

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

it('types text character by character', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  // Types 'hello' one character at a time, triggering
  // focus, keyPress, and change events for each character
  await user.type(screen.getByTestId('email-input'), 'hello@example.com');

  // More realistic press that includes pointerDown, pointerUp
  await user.press(screen.getByTestId('submit-button'));
});

userEvent operations are async because they simulate timing between events. Use await for all userEvent calls.

Async Testing

React Native components often render asynchronously — data fetches, useEffect hooks, state updates from context. RNTL provides several utilities for async scenarios.

waitFor

waitFor retries an assertion until it passes or times out:

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

it('shows user data after fetch', async () => {
  render(<UserProfile userId="123" />);

  // Initially shows loading state
  expect(screen.getByTestId('loading-spinner')).toBeVisible();

  // Wait for data to load (default timeout: 1000ms)
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeVisible();
  });

  // Spinner should be gone
  expect(screen.queryByTestId('loading-spinner')).toBeNull();
});

For custom timeouts and intervals:

await waitFor(
  () => expect(screen.getByText('Success')).toBeVisible(),
  { timeout: 3000, interval: 100 }
);

findBy queries

findBy* queries are syntactic sugar for waitFor + getBy*. Use them when you're just waiting for an element to appear:

// This
const element = await screen.findByText('Loaded data');

// Is equivalent to
await waitFor(() => screen.getByText('Loaded data'));
const element = screen.getByText('Loaded data');

act()

React Native updates are batched and require act() to flush. RNTL wraps most of its APIs in act() automatically, but you need it when triggering async state updates manually:

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

it('updates state after timer', async () => {
  jest.useFakeTimers();
  render(<TimedComponent />);

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

  expect(screen.getByText('Timer complete')).toBeVisible();
  jest.useRealTimers();
});

Mocking

Mocking API Calls

Use jest.mock or msw for network requests:

// With jest.mock
jest.mock('../api/users', () => ({
  fetchUser: jest.fn().mockResolvedValue({
    id: '123',
    name: 'John Doe',
    email: 'john@example.com',
  }),
}));

it('displays user data', async () => {
  render(<UserProfile userId="123" />);
  expect(await screen.findByText('John Doe')).toBeVisible();
});

With msw (recommended for complex API scenarios):

import { setupServer } from 'msw/node';
import { rest } from 'msw';

const server = setupServer(
  rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
    return res(ctx.json({ name: 'John Doe', email: 'john@example.com' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('displays user profile', async () => {
  render(<UserProfile userId="123" />);
  expect(await screen.findByText('John Doe')).toBeVisible();
});

it('handles API error', async () => {
  server.use(
    rest.get('https://api.example.com/users/:id', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );

  render(<UserProfile userId="123" />);
  expect(await screen.findByText('Failed to load user')).toBeVisible();
});

Mocking Navigation

If your component uses React Navigation, wrap it in a mock navigator:

import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';

it('navigates on button press', async () => {
  const navigate = jest.fn();
  jest.mock('@react-navigation/native', () => ({
    ...jest.requireActual('@react-navigation/native'),
    useNavigation: () => ({ navigate }),
  }));

  render(<ProductCard productId="42" />);
  fireEvent.press(screen.getByTestId('view-details-button'));

  expect(navigate).toHaveBeenCalledWith('ProductDetail', { productId: '42' });
});

Mocking Context

Wrap components in context providers for testing:

import { CartProvider } from '../context/CartContext';

function renderWithCart(ui, { cartItems = [] } = {}) {
  return render(
    <CartProvider initialItems={cartItems}>
      {ui}
    </CartProvider>
  );
}

it('shows correct cart count', () => {
  renderWithCart(<CartIcon />, {
    cartItems: [
      { id: '1', name: 'Product A', quantity: 2 },
      { id: '2', name: 'Product B', quantity: 1 },
    ],
  });

  expect(screen.getByTestId('cart-badge')).toHaveTextContent('3');
});

Integration Tests

RNTL shines for integration tests — rendering multiple connected components together with real context and navigation, mocking only the network boundary.

// Test a complete checkout flow at the component level
it('completes checkout flow', async () => {
  const user = userEvent.setup();

  render(
    <StoreProvider>
      <NavigationContainer>
        <CheckoutStack />
      </NavigationContainer>
    </StoreProvider>
  );

  // Cart screen
  expect(screen.getByTestId('cart-screen')).toBeVisible();
  expect(screen.getByText('2 items')).toBeVisible();

  await user.press(screen.getByTestId('proceed-to-checkout'));

  // Payment screen
  expect(await screen.findByTestId('payment-screen')).toBeVisible();
  await user.type(screen.getByTestId('card-number'), '4242424242424242');
  await user.type(screen.getByTestId('expiry'), '12/26');
  await user.type(screen.getByTestId('cvv'), '123');

  await user.press(screen.getByTestId('pay-button'));

  // Confirmation
  expect(await screen.findByTestId('order-confirmation')).toBeVisible();
  expect(screen.getByText(/order #\d+/i)).toBeVisible();
});

This kind of test gives you high confidence that the component layer works correctly — routing, state management, and UI all exercised together — without the infrastructure overhead of a full E2E test.

Snapshot Testing

RNTL supports snapshot testing, but use it with caution:

it('renders correctly', () => {
  const tree = render(<ProductCard name="Widget" price={29.99} />).toJSON();
  expect(tree).toMatchSnapshot();
});

Snapshot tests catch unintended rendering changes, but they break constantly on intentional changes and tend to get blindly updated without review. They're most useful for stable, pure presentational components. Don't use them for components with complex state or async behavior.

Testing Hooks

For custom hooks, use renderHook:

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

it('adds items to cart', () => {
  const { result } = renderHook(() => useCart());

  expect(result.current.items).toHaveLength(0);

  act(() => {
    result.current.addItem({ id: '1', name: 'Widget', price: 29.99 });
  });

  expect(result.current.items).toHaveLength(1);
  expect(result.current.total).toBe(29.99);
});

it('handles async data fetch', async () => {
  const { result } = renderHook(() => useProductData('123'));

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

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

  expect(result.current.product).toEqual({ id: '123', name: 'Widget' });
});

Where RNTL Fits in Your Test Strategy

React Native testing has three layers:

  1. Unit tests (Jest) — pure functions, business logic, reducers, selectors. No rendering.
  2. Component/integration tests (RNTL) — components, screens, flows. Real rendering, mocked network.
  3. E2E tests (Detox/Maestro) — complete app flows on a real device or simulator.

RNTL occupies the middle layer. It runs fast (no device needed), gives you high confidence in component behavior, and catches a large class of bugs before they reach E2E testing.

A reasonable distribution for a mature React Native codebase:

  • 60-70% unit tests (fast, zero overhead)
  • 20-30% RNTL integration tests (fast, reliable)
  • 5-10% E2E tests (slow, expensive, high value for critical paths)

Complementing with Production Monitoring

RNTL, Detox, and Maestro all run in controlled environments. Production is different — real API latency, real device variations, real OS updates. Bugs that slip through your test pyramid show up as user reports.

HelpMeTest monitors your production app continuously, running real flows on real devices and alerting when something breaks. It's the final layer that catches what testing misses — not because the tests were wrong, but because production is genuinely different.

Summary

React Native Testing Library is the right tool for testing React Native components without a device. The key practices:

  1. Use screen.getByTestId() for stable selectors in complex components, getByRole() and getByText() for user-facing assertions
  2. Use findBy* queries for async elements, queryBy* for negative assertions
  3. Use userEvent over fireEvent for realistic interaction simulation
  4. Mock at the network boundary with msw, not at the module level
  5. Write integration tests that exercise complete flows through your component tree
  6. Reserve E2E tests for critical paths — keep the bulk of coverage in RNTL

The investment in RNTL tests pays off quickly: they run in seconds, don't require device infrastructure, and catch most of the bugs that would otherwise reach E2E testing or production.

Read more