React Native Testing Library: Component Tests, Native Module Mocks, and Async Utils

React Native Testing Library: Component Tests, Native Module Mocks, and Async Utils

@testing-library/react-native brings the same user-centric testing philosophy to mobile that Testing Library brought to the web. This guide covers component rendering, custom hook testing with renderHook, mocking stubborn native modules, taming async state with waitFor and findBy, and the act() gotchas that trip up even experienced React Native developers.

Key Takeaways

Mock native modules at the module level, not inside tests. Native modules like AsyncStorage or react-native-camera have no JavaScript implementation in a Jest environment — jest.mock() in jest.setup.js is the correct home for these stubs. Prefer findBy over waitFor + getBy. findBy queries are built-in async queries that already wrap waitFor internally; using both is redundant and adds cognitive overhead. renderHook belongs in the same test suite as the components that use the hook. Testing a hook in isolation catches logic bugs; testing it through the component catches integration bugs. act() in React Native wraps async native events, not just state updates. Forgetting act() around fireEvent calls that trigger async side effects is the leading cause of "not wrapped in act()" warnings. Test navigation behavior, not navigator internals. Assert that the correct screen is rendered after interaction, not that navigate() was called — that tests your code, not React Navigation's.

Testing React Native applications introduces a unique set of challenges that don't exist in web testing. Native modules, platform-specific behavior, asynchronous bridge communication, and navigation stacks all need to be accounted for before a single assertion can run. @testing-library/react-native abstracts much of this complexity, but understanding what's happening underneath makes the difference between a test suite you trust and one you fight.

Setting Up @testing-library/react-native

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|@testing-library)/)',
  ],
};

The transformIgnorePatterns entry is critical. Many React Native libraries ship ES module syntax and must be transformed by Babel before Jest can run them. The pattern above whitelists the react-native namespace while leaving everything else untransformed.

Writing Component Tests

The library's core API mirrors the web Testing Library: render, queries, and fireEvent.

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

interface Props {
  onSubmit: (email: string, password: string) => void;
}

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

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

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

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

    expect(onSubmit).toHaveBeenCalledWith('user@example.com', 'secret123');
  });

  it('renders email and password inputs', () => {
    const { getByPlaceholderText } = render(<LoginForm onSubmit={jest.fn()} />);
    expect(getByPlaceholderText('Email')).toBeTruthy();
    expect(getByPlaceholderText('Password')).toBeTruthy();
  });
});

Prefer getByTestId when the element has no meaningful accessible label, but prefer getByRole or getByLabelText when the component is accessible — the latter queries encourage you to build accessible components.

Mocking Native Modules

Native modules are the most common source of test failures in React Native. They exist as native code with a thin JavaScript bridge — there is no JavaScript fallback for Jest to execute.

Mocking AsyncStorage

// jest.setup.js
jest.mock('@react-native-async-storage/async-storage', () =>
  require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

The library ships its own mock. Use it instead of writing your own.

Mocking react-native-camera

// __mocks__/react-native-camera.js
export const RNCamera = {
  Constants: {
    Aspect: {},
    BarCodeType: {},
    Type: { back: 'back', front: 'front' },
    CaptureMode: {},
    CaptureTarget: {},
    Orientation: {},
    FlashMode: { off: 'off', on: 'on', auto: 'auto' },
  },
};

export default RNCamera;

Place this file in __mocks__/ at the project root. Jest automatically picks up manual mocks from this directory for modules that match the file name.

Mocking react-native-permissions

jest.mock('react-native-permissions', () => ({
  check: jest.fn().mockResolvedValue('granted'),
  request: jest.fn().mockResolvedValue('granted'),
  PERMISSIONS: {
    IOS: { CAMERA: 'ios.permission.CAMERA' },
    ANDROID: { CAMERA: 'android.permission.CAMERA' },
  },
  RESULTS: {
    GRANTED: 'granted',
    DENIED: 'denied',
    BLOCKED: 'blocked',
    UNAVAILABLE: 'unavailable',
  },
}));

Mocking Platform-Specific Behavior

import { Platform } from 'react-native';

describe('PlatformSpecificComponent', () => {
  it('renders iOS variant', () => {
    jest.replaceProperty(Platform, 'OS', 'ios');
    const { getByTestId } = render(<PlatformSpecificComponent />);
    expect(getByTestId('ios-variant')).toBeTruthy();
  });

  it('renders Android variant', () => {
    jest.replaceProperty(Platform, 'OS', 'android');
    const { getByTestId } = render(<PlatformSpecificComponent />);
    expect(getByTestId('android-variant')).toBeTruthy();
  });
});

renderHook for Custom Hooks

renderHook lets you test hooks in isolation without building a throwaway component around them.

// hooks/useUserProfile.ts
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface UserProfile {
  name: string;
  email: string;
}

export function useUserProfile() {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    AsyncStorage.getItem('user_profile')
      .then((data) => {
        if (data) setProfile(JSON.parse(data));
      })
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  return { profile, loading, error };
}
// hooks/useUserProfile.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useUserProfile } from './useUserProfile';

describe('useUserProfile', () => {
  it('loads profile from AsyncStorage', async () => {
    const mockProfile = { name: 'Alice', email: 'alice@example.com' };
    (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(
      JSON.stringify(mockProfile)
    );

    const { result } = renderHook(() => useUserProfile());

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

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

    expect(result.current.profile).toEqual(mockProfile);
    expect(result.current.error).toBeNull();
  });

  it('handles storage errors gracefully', async () => {
    const storageError = new Error('Storage unavailable');
    (AsyncStorage.getItem as jest.Mock).mockRejectedValueOnce(storageError);

    const { result } = renderHook(() => useUserProfile());

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

    expect(result.current.error).toBe(storageError);
    expect(result.current.profile).toBeNull();
  });
});

Async Utilities: waitFor, findBy, and act()

waitFor

waitFor retries its callback until it stops throwing, with a default timeout of 1000ms:

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

it('shows success message after API call', async () => {
  mockApi.post('/login').mockResolvedValueOnce({ token: 'abc' });
  const { getByTestId, getByText } = render(<LoginScreen />);

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

  await waitFor(() => {
    expect(getByText('Welcome back!')).toBeTruthy();
  });
});

Increase the timeout for operations that genuinely take longer:

await waitFor(() => expect(getByText('Done')).toBeTruthy(), { timeout: 3000 });

findBy Queries

findBy queries combine getBy with waitFor. Use them when querying for elements that appear asynchronously:

it('displays fetched username', async () => {
  mockApi.get('/me').mockResolvedValueOnce({ username: 'alice' });
  const { findByText } = render(<ProfileScreen />);

  // findByText returns a promise — no explicit waitFor needed
  const username = await findByText('alice');
  expect(username).toBeTruthy();
});

act() in React Native

React Native's act() requirement extends beyond state updates. Native events dispatched through fireEvent are synchronous, but any async work they trigger (API calls, timers, state updates from Promises) needs to be wrapped or awaited:

// Wrong — leaves pending state updates after the test
it('submits form', () => {
  const { getByTestId } = render(<Form />);
  fireEvent.press(getByTestId('submit'));
  // async state updates from onPress are not awaited
});

// Correct — await the async consequences
it('submits form', async () => {
  const { getByTestId, findByText } = render(<Form />);
  fireEvent.press(getByTestId('submit'));
  await findByText('Submitted'); // waits for state to settle
});

If you see the warning Warning: An update to Foo inside a test was not wrapped in act(...), the fix is almost always to await the result of an async operation rather than wrapping more code in act() manually.

Testing Navigation with React Navigation

The recommended approach is to render a real navigator in tests rather than mocking it:

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

const Stack = createNativeStackNavigator();

export function renderWithNavigation(
  component: React.ComponentType,
  options?: { initialRouteName?: string }
) {
  const Wrapper = () => (
    <NavigationContainer>
      <Stack.Navigator initialRouteName={options?.initialRouteName ?? 'Screen'}>
        <Stack.Screen name="Screen" component={component} />
        <Stack.Screen name="Details" component={DetailsScreen} />
      </Stack.Navigator>
    </NavigationContainer>
  );

  return render(<Wrapper />);
}
it('navigates to details screen on item press', async () => {
  const { getByTestId, findByText } = renderWithNavigation(HomeScreen);

  fireEvent.press(getByTestId('item-0'));

  // Assert the destination screen rendered, not that navigate() was called
  expect(await findByText('Details')).toBeTruthy();
});

This approach tests the actual navigation outcome and catches issues like incorrect route names or missing screen registrations.

Common Pitfalls

Forgetting to flush promises between renders. When a component fetches data on mount, always use findBy or waitFor — never assert immediately after render().

Using getByTestId for everything. Overuse of testID leads to tests that don't reflect user behavior. Reach for getByRole, getByText, and getByLabelText first.

Not cleaning up mocks. Use afterEach(() => jest.clearAllMocks()) to prevent mock state from leaking between tests.

Mocking too deep. If you're mocking the internals of a component to test another component, the test is too tightly coupled. Test components through their public API — props and rendered output.

A well-structured React Native test suite gives you confidence that real users can complete their flows, not just that functions return the right values. The patterns above scale from small component tests to full screen integration tests without requiring a physical device.

Read more