Introduction to React Native Testing

React Native Testing Library (RNTL) is the industry standard for testing React Native applications. Built on the principles of testing user behavior rather than implementation details, RNTL encourages writing maintainable tests that give you confidence in your application’s functionality.

This guide covers comprehensive testing strategies, from basic component tests to complex integration scenarios, native module mocking, and performance testing best practices.

Why React Native Testing Library?

RNTL follows the guiding principle: “The more your tests resemble the way your software is used, the more confidence they can give you.”

Key Advantages

  • User-Centric Testing: Test components as users interact with them
  • Implementation Independence: Refactor components without breaking tests
  • Async Utilities: Built-in helpers for asynchronous operations
  • Accessibility Focus: Encourages accessible component design
  • Jest Integration: Seamless integration with Jest ecosystem

Comparison with Other Testing Approaches

ApproachFocusMaintenanceUser Confidence
Enzyme (deprecated)Implementation detailsHighLow
React Native Testing LibraryUser behaviorLowHigh
Detox (E2E)Full app flowsMediumVery High

Environment Setup

Installation

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

Jest Configuration

Configure jest.config.js:

module.exports = {
  preset: 'react-native' (as discussed in [Detox: Grey-Box Testing for React Native Applications](/blog/detox-react-native-grey-box)),
  setupFilesAfterEnv: [
    '@testing-library/jest-native/extend-expect',
    '<rootDir>/jest.setup.js'
  ],
  transformIgnorePatterns: [
    'node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*)/)'
  ],
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
    '!src/**/__tests__/**'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

Create jest.setup.js:

import 'react-native-gesture-handler/jestSetup';

jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

global.fetch = jest.fn();

beforeEach(() => {
  jest (as discussed in [API Testing Architecture: From Monoliths to Microservices](/blog/api-testing-architecture-microservices)).clearAllMocks();
});

Component Testing Fundamentals

Basic Component Test

// src/components/Button.tsx
import React from 'react';
import { TouchableOpacity, Text, StyleSheet } from 'react-native';

interface ButtonProps {
  onPress: () => void;
  title: string;
  disabled?: boolean;
}

export const Button: React.FC<ButtonProps> = ({
  onPress,
  title,
  disabled = false
}) => (
  <TouchableOpacity
    testID="custom-button"
    onPress={onPress}
    disabled={disabled}
    style={[styles.button, disabled && styles.disabled]}
  >
    <Text style={styles.text}>{title}</Text>
  </TouchableOpacity>
);

const styles = StyleSheet.create({
  button: {
    backgroundColor: '#007AFF',
    padding: 12,
    borderRadius: 8,
  },
  disabled: {
    backgroundColor: '#CCC',
  },
  text: {
    color: 'white',
    fontSize: 16,
  },
});

// src/components/__tests__/Button.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';

describe('Button Component', () => {
  it('renders correctly with title', () => {
    const { getByText } = render(
      <Button onPress={() => {}} title="Click Me" />
    );

    expect(getByText('Click Me')).toBeTruthy();
  });

  it('calls onPress when pressed', () => {
    const onPressMock = jest (as discussed in [GraphQL Testing: Complete Guide with Examples](/blog/graphql-testing-guide)).fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Press" />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).toHaveBeenCalledTimes(1);
  });

  it('does not call onPress when disabled', () => {
    const onPressMock = jest.fn();
    const { getByTestId } = render(
      <Button onPress={onPressMock} title="Press" disabled />
    );

    fireEvent.press(getByTestId('custom-button'));
    expect(onPressMock).not.toHaveBeenCalled();
  });
});

Testing Forms and Input

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

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

export const LoginForm: React.FC<LoginFormProps> = ({ onSubmit }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = () => {
    if (!email || !password) {
      setError('Email and password are required');
      return;
    }

    if (!email.includes('@')) {
      setError('Invalid email format');
      return;
    }

    setError('');
    onSubmit(email, password);
  };

  return (
    <View testID="login-form">
      <TextInput
        testID="email-input"
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        autoCapitalize="none"
        keyboardType="email-address"
      />
      <TextInput
        testID="password-input"
        placeholder="Password"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
      />
      {error ? <Text testID="error-message">{error}</Text> : null}
      <Button onPress={handleSubmit} title="Login" />
    </View>
  );
};

// src/components/__tests__/LoginForm.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { LoginForm } from '../LoginForm';

describe('LoginForm', () => {
  it('submits form with valid credentials', () => {
    const onSubmitMock = jest.fn();
    const { getByTestId } = render(
      <LoginForm onSubmit={onSubmitMock} />
    );

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

    expect(onSubmitMock).toHaveBeenCalledWith(
      'user@example.com',
      'password123'
    );
  });

  it('shows error for empty fields', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

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

    expect(
      getByText('Email and password are required')
    ).toBeTruthy();
  });

  it('shows error for invalid email format', () => {
    const { getByTestId, getByText } = render(
      <LoginForm onSubmit={() => {}} />
    );

    fireEvent.changeText(getByTestId('email-input'), 'invalid');
    fireEvent.changeText(getByTestId('password-input'), 'pass');
    fireEvent.press(getByTestId('custom-button'));

    expect(getByText('Invalid email format')).toBeTruthy();
  });
});

Asynchronous Testing

Testing API Calls

// src/services/api.ts
export const fetchUserProfile = async (userId: string) => {
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );
  if (!response.ok) {
    throw new Error('Failed to fetch user');
  }
  return response.json();
};

// src/components/UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { fetchUserProfile } from '../services/api';

interface User {
  id: string;
  name: string;
  email: string;
}

export const UserProfile: React.FC<{ userId: string }> = ({
  userId
}) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const loadUser = async () => {
      try {
        const data = await fetchUserProfile(userId);
        setUser(data);
      } catch (err) {
        setError('Failed to load user');
      } finally {
        setLoading(false);
      }
    };

    loadUser();
  }, [userId]);

  if (loading) {
    return <ActivityIndicator testID="loading" />;
  }

  if (error) {
    return <Text testID="error">{error}</Text>;
  }

  return (
    <View testID="user-profile">
      <Text testID="user-name">{user?.name}</Text>
      <Text testID="user-email">{user?.email}</Text>
    </View>
  );
};

// src/components/__tests__/UserProfile.test.tsx
import React from 'react';
import {
  render,
  waitFor,
  screen
} from '@testing-library/react-native';
import { UserProfile } from '../UserProfile';
import * as api from '../../services/api';

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

describe('UserProfile', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('displays loading state initially', () => {
    (api.fetchUserProfile as jest.Mock).mockImplementation(
      () => new Promise(() => {}) // Never resolves
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    expect(getByTestId('loading')).toBeTruthy();
  });

  it('displays user data after successful fetch', async () => {
    const mockUser = {
      id: '123',
      name: 'John Doe',
      email: 'john@example.com'
    };

    (api.fetchUserProfile as jest.Mock).mockResolvedValue(
      mockUser
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    await waitFor(() => {
      expect(getByTestId('user-name')).toHaveTextContent(
        'John Doe'
      );
    });

    expect(getByTestId('user-email')).toHaveTextContent(
      'john@example.com'
    );
  });

  it('displays error message on fetch failure', async () => {
    (api.fetchUserProfile as jest.Mock).mockRejectedValue(
      new Error('Network error')
    );

    const { getByTestId } = render(
      <UserProfile userId="123" />
    );

    await waitFor(() => {
      expect(getByTestId('error')).toHaveTextContent(
        'Failed to load user'
      );
    });
  });
});

Testing Custom Hooks

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

export const 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 };
};

// src/hooks/__tests__/useCounter.test.ts
import { renderHook, act } from '@testing-library/react-native';
import { useCounter } from '../useCounter';

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

  it('initializes with custom 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(10));

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

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

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

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

Mocking Native Modules

Mocking React Native APIs

// jest.setup.js additions
jest.mock('react-native/Libraries/Alert/Alert', () => ({
  alert: jest.fn(),
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
  setItem: jest.fn(() => Promise.resolve()),
  getItem: jest.fn(() => Promise.resolve(null)),
  removeItem: jest.fn(() => Promise.resolve()),
  clear: jest.fn(() => Promise.resolve()),
}));

// Example test using mocked AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage';
import { saveUserToken } from '../storage';

it('saves token to AsyncStorage', async () => {
  await saveUserToken('abc123');

  expect(AsyncStorage.setItem).toHaveBeenCalledWith(
    'userToken',
    'abc123'
  );
});

Mocking Navigation

// __mocks__/@react-navigation/native.ts
export const useNavigation = () => ({
  navigate: jest.fn(),
  goBack: jest.fn(),
  setOptions: jest.fn(),
});

export const useRoute = () => ({
  params: {},
});

// Component test using navigation
import { useNavigation } from '@react-navigation/native';
import { render, fireEvent } from '@testing-library/react-native';
import { ProductDetails } from '../ProductDetails';

jest.mock('@react-navigation/native');

it('navigates to cart on add to cart', () => {
  const navigate = jest.fn();
  (useNavigation as jest.Mock).mockReturnValue({ navigate });

  const { getByText } = render(
    <ProductDetails productId="123" />
  );

  fireEvent.press(getByText('Add to Cart'));

  expect(navigate).toHaveBeenCalledWith('Cart');
});

Snapshot Testing

Component Snapshots

import React from 'react';
import { render } from '@testing-library/react-native';
import { ProductCard } from '../ProductCard';

it('matches snapshot', () => {
  const product = {
    id: '1',
    name: 'Product Name',
    price: 29.99,
    image: 'https://example.com/image.jpg'
  };

  const { toJSON } = render(<ProductCard product={product} />);
  expect(toJSON()).toMatchSnapshot();
});

Inline Snapshots

it('renders price correctly', () => {
  const { getByTestId } = render(
    <ProductPrice price={29.99} currency="USD" />
  );

  expect(getByTestId('price').props.children)
    .toMatchInlineSnapshot(`"$29.99"`);
});

Advanced Testing Patterns

Testing Context Providers

// src/contexts/AuthContext.tsx
import React, { createContext, useState, useContext } from 'react';

interface AuthContextType {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContextType | undefined>(
  undefined
);

export const AuthProvider: React.FC = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    // API call logic
    const userData = await apiLogin(email, password);
    setUser(userData);
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

// Test helper
const renderWithAuth = (component: React.ReactElement) => {
  return render(<AuthProvider>{component}</AuthProvider>);
};

// Component test
it('displays user name after login', async () => {
  const { getByTestId } = renderWithAuth(<Dashboard />);

  await waitFor(() => {
    expect(getByTestId('welcome-message')).toHaveTextContent(
      'Welcome, John'
    );
  });
});

Testing Lists and FlatLists

import React from 'react';
import { FlatList, Text } from 'react-native';

interface Item {
  id: string;
  title: string;
}

const ItemList: React.FC<{ items: Item[] }> = ({ items }) => (
  <FlatList
    testID="item-list"
    data={items}
    keyExtractor={item => item.id}
    renderItem={({ item }) => (
      <Text testID={`item-${item.id}`}>{item.title}</Text>
    )}
  />
);

it('renders all items in list', () => {
  const items = [
    { id: '1', title: 'First' },
    { id: '2', title: 'Second' },
    { id: '3', title: 'Third' },
  ];

  const { getByTestId } = render(<ItemList items={items} />);

  expect(getByTestId('item-1')).toHaveTextContent('First');
  expect(getByTestId('item-2')).toHaveTextContent('Second');
  expect(getByTestId('item-3')).toHaveTextContent('Third');
});

Best Practices

Query Priority

Follow this priority when selecting elements:

  1. getByRole / getByLabelText: Accessible to all users
  2. getByPlaceholderText: For form elements
  3. getByText: For non-interactive elements
  4. getByTestId: Last resort for implementation details

Test Organization

describe('Component Name', () => {
  describe('rendering', () => {
    it('renders initial state correctly', () => {});
    it('renders loading state', () => {});
  });

  describe('user interactions', () => {
    it('handles button click', () => {});
    it('submits form on enter', () => {});
  });

  describe('edge cases', () => {
    it('handles network errors gracefully', () => {});
    it('validates input boundaries', () => {});
  });
});

Coverage Goals

MetricTargetPriority
Statements80%+High
Branches75%+High
Functions80%+Medium
Lines80%+Medium

Continuous Integration

GitHub Actions Example

name: React Native Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test -- --coverage --watchAll=false

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Conclusion

React Native Testing Library provides a robust foundation for testing mobile applications with a user-centric approach. By focusing on how users interact with your app rather than implementation details, you build a test suite that’s resilient to refactoring and provides genuine confidence in your application’s behavior.

Key Takeaways:

  • Prioritize accessibility-based queries (role, label) over testID
  • Use waitFor for async operations and state updates
  • Mock native modules and external dependencies appropriately
  • Maintain high test coverage while avoiding over-testing
  • Integrate testing into CI/CD pipelines for continuous quality assurance

Start by testing critical user flows and gradually expand coverage to achieve comprehensive test protection.