joabgonzalez

react-native-testing-library

"User-centric React Native component testing. Trigger: When testing React Native components with RNTL."

joabgonzalez 9 Updated 8mo ago
GitHub

Install

npx skillscat add joabgonzalez/ai-agents-skills/react-native-testing-library

Install via the SkillsCat registry.

SKILL.md

React Native Testing Library Skill

Component testing for React Native apps, focusing on behavior over implementation details.

When to Use

  • Testing React Native components and screens
  • Simulating user interactions (press, type, scroll)
  • Writing maintainable UI tests that resist refactoring
  • Testing accessibility features and labels
  • Verifying component behavior from user perspective

Don't use for:

  • End-to-end tests across app (use Detox or Maestro)
  • Testing native modules directly (use native test frameworks)
  • Performance benchmarking (use React Native performance tools)

Critical Patterns

โœ… REQUIRED: Query by accessibility, not implementation

Use queries that reflect how users and assistive tech interact with the app.

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

// โŒ WRONG: Query by implementation details
const button = container.findByType('TouchableOpacity');
const text = container.findByProps({ testID: 'submit-button-text' });

// โœ… CORRECT: Query by accessibility
const button = screen.getByRole('button', { name: /submit/i });
const text = screen.getByText(/submit/i);
const input = screen.getByLabelText(/email/i);

โœ… REQUIRED: Use fireEvent for interactions

Simulate user interactions with fireEvent.

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

// โœ… CORRECT: Simulate user interactions
test('increments counter on button press', () => {
  render(<Counter />);

  const button = screen.getByRole('button', { name: /increment/i });
  const count = screen.getByText(/count: 0/i);

  fireEvent.press(button);

  expect(screen.getByText(/count: 1/i)).toBeOnTheScreen();
});

โœ… REQUIRED: Test async updates with findBy

Use findBy queries for async UI updates.

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

// โœ… CORRECT: Wait for async updates
test('loads and displays user data', async () => {
  render(<UserProfile userId="123" />);

  // findBy queries automatically wait (default: 1000ms timeout)
  const userName = await screen.findByText(/john doe/i);
  expect(userName).toBeOnTheScreen();

  // Alternative: Use waitFor for complex conditions
  await waitFor(() => {
    expect(screen.getByText(/john doe/i)).toBeOnTheScreen();
  });
});

โœ… REQUIRED: Mock native modules

Mock React Native native modules for stable tests.

// __mocks__/@react-native-async-storage/async-storage.js
export default {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
};

// test file
jest.mock('@react-native-async-storage/async-storage');

import AsyncStorage from '@react-native-async-storage/async-storage';

test('saves data to storage', async () => {
  render(<SaveButton data={{ name: 'John' }} />);

  const button = screen.getByRole('button', { name: /save/i });
  fireEvent.press(button);

  await waitFor(() => {
    expect(AsyncStorage.setItem).toHaveBeenCalledWith(
      'userData',
      JSON.stringify({ name: 'John' })
    );
  });
});

Decision Tree

Selecting elements?
  โ†’ Use accessibility queries first (getByRole, getByLabelText)
  โ†’ Fallback to getByText for content
  โ†’ Last resort: getByTestId for elements without accessible labels

Simulating interactions?
  โ†’ Use fireEvent.press for touch
  โ†’ Use fireEvent.changeText for TextInput
  โ†’ Use fireEvent.scroll for ScrollView

Waiting for async updates?
  โ†’ Use findBy queries for simple waits
  โ†’ Use waitFor for complex conditions
  โ†’ Use waitForElementToBeRemoved for disappearing elements

Testing platform-specific UI?
  โ†’ Mock Platform.OS in test setup
  โ†’ Test both iOS and Android variants
  โ†’ Use Platform.select() in components for conditional rendering

Mocking native modules?
  โ†’ Create manual mock in __mocks__/ directory
  โ†’ Use jest.mock() to activate mock
  โ†’ Reset mocks between tests with jest.clearAllMocks()

Testing navigation?
  โ†’ Mock @react-navigation/native
  โ†’ Test navigation.navigate() calls
  โ†’ Use createNavigationContainerRef for integration tests

Testing with context (Redux, theme)?
  โ†’ Create custom render function with providers
  โ†’ Wrap component in necessary providers
  โ†’ Share wrapper across test suite

Edge Cases

  • Native module mocks: Some modules require specific mock implementations. Check library docs for recommended mocks (e.g., react-native-reanimated requires babel plugin).

  • Async UI updates: React Native's async rendering can cause race conditions. Always use findBy or waitFor, never setTimeout.

  • Platform-specific UI: Mock Platform.OS to test both iOS and Android variants. Reset mock after each test.

  • Navigation testing: Mocking navigation can be complex. Consider integration tests with createNavigationContainerRef for complex flows.

  • Accessibility labels: If label not set, queries fail. Always set accessibilityLabel or accessible={true} on interactive elements.

Checklist

  • Queries use accessibility-first approach (getByRole, getByLabelText)
  • Interactions use fireEvent (press, changeText, scroll)
  • Async updates use findBy or waitFor
  • Native modules mocked appropriately
  • Platform-specific UI tested for both iOS and Android
  • Tests pass without console warnings
  • jest-native matchers used (toBeOnTheScreen, toHaveTextContent)
  • Custom render function created if using providers (Redux, theme)
  • Mocks reset between tests (jest.clearAllMocks in beforeEach)
  • Accessibility labels verified on interactive elements

Example

// Button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { AsyncButton } from './AsyncButton';

// Custom render with providers
const renderWithProviders = (ui: React.ReactElement) => {
  return render(
    <ThemeProvider theme={defaultTheme}>
      {ui}
    </ThemeProvider>
  );
};

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

  test('displays loading state during async operation', async () => {
    const onPress = jest.fn(
      () => new Promise((resolve) => setTimeout(resolve, 100))
    );

    renderWithProviders(<AsyncButton onPress={onPress}>Submit</AsyncButton>);

    const button = screen.getByRole('button', { name: /submit/i });

    // Initial state
    expect(button).toBeEnabled();
    expect(screen.queryByText(/loading/i)).not.toBeOnTheScreen();

    // Press button
    fireEvent.press(button);

    // Loading state
    expect(button).toBeDisabled();
    expect(await screen.findByText(/loading/i)).toBeOnTheScreen();

    // Completed state
    await waitFor(() => {
      expect(button).toBeEnabled();
      expect(screen.queryByText(/loading/i)).not.toBeOnTheScreen();
    });

    expect(onPress).toHaveBeenCalledTimes(1);
  });

  test('handles error state', async () => {
    const onPress = jest.fn(() => Promise.reject(new Error('Failed')));

    renderWithProviders(<AsyncButton onPress={onPress}>Submit</AsyncButton>);

    const button = screen.getByRole('button', { name: /submit/i });
    fireEvent.press(button);

    const errorMessage = await screen.findByText(/failed/i);
    expect(errorMessage).toBeOnTheScreen();
  });
});

Resources