LLM Guidelines for React Native Testing Library

Actionable guidelines for writing tests with React Native Testing Library (RNTL) v13.

Core APIs

render

const result = render(<Component />, options?);  // Sync in v13
OptionDescription
wrapperReact component to wrap the rendered component (e.g., providers)
createNodeMockFunction to create mock refs
ReturnDescription
rerender(component)Re-render with a new component
unmount()Unmount the rendered component
toJSON()Get JSON representation for snapshots
debug(options?)Print the component tree to console
rootRoot element of the rendered component

screen

Prefer screen over destructuring from render(). Provides all query methods after render() is called.

render(<Component />);
screen.getByRole('button'); // Access queries via screen

renderHook

const { result, rerender, unmount } = renderHook(() => useMyHook(), options?);  // Sync in v13
OptionDescription
initialPropsInitial props passed to the hook
wrapperReact component to wrap the hook (e.g., providers)
ReturnDescription
result.currentCurrent return value of the hook
rerender(props?)Re-render hook with new props
unmount()Unmount the hook

Query Selection

  • Prefer getByRole as first choice for querying elements
  • Query priority: getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValuegetByTestId (last resort)
  • Use findBy* for elements that appear asynchronously (after API calls, timeouts, state updates)
  • Use queryBy* ONLY for checking non-existence (with .not.toBeOnTheScreen())
  • Never use getBy* for non-existence checks
  • Avoid container.queryAll() - use screen queries instead
  • Query by visible text, not testID when text is available

Assertions

  • Use RNTL matchers - prefer semantic matchers over prop assertions
  • Combine queries with matchers: expect(screen.getByText('Hello')).toBeOnTheScreen()
  • No redundant null checks - getBy* already throws if not found

Jest Matchers Reference

MatcherDescription
toBeOnTheScreen()Element is present in the element tree
toBeVisible()Element is visible (checks style, aria-hidden, accessibilityElementsHidden, ancestors)
toBeEmptyElement()Element has no children or text content
toContainElement(element)Element contains another element
toBeEnabled()Element is not disabled (checks aria-disabled, accessibilityState, ancestors)
toBeDisabled()Element has aria-disabled or accessibilityState={{ disabled: true }} (checks ancestors)
toBeBusy()Element has aria-busy or accessibilityState={{ busy: true }}
toBeChecked()Element has aria-checked or accessibilityState={{ checked: true }}
toBePartiallyChecked()Element has aria-checked="mixed" or accessibilityState={{ checked: 'mixed' }}
toBeSelected()Element has aria-selected or accessibilityState={{ selected: true }}
toBeExpanded()Element has aria-expanded or accessibilityState={{ expanded: true }}
toBeCollapsed()Element has aria-expanded={false} or accessibilityState={{ expanded: false }}
toHaveTextContent(text)Element has matching text content
toHaveDisplayValue(value)TextInput has matching display value
toHaveAccessibleName(name?)Element has matching aria-label, accessibilityLabel, or text content
toHaveAccessibilityValue(value)Element has matching aria-value* or accessibilityValue
toHaveStyle(style)Element has matching style
toHaveProp(name, value?)Element has prop (use semantic matchers when possible)

User Interactions

Prefer userEvent over fireEvent for realistic user interaction simulation. userEvent triggers the complete event sequence that real users would produce.

userEvent (Preferred, Async)

const user = userEvent.setup();
MethodDescription
await user.press(element)Press an element (triggers pressIn, pressOut, press)
await user.longPress(element, options?)Long press with optional { duration }
await user.type(element, text, options?)Type into TextInput (triggers focus, keyPress, change, changeText per char)
await user.clear(element)Clear TextInput (select all + backspace)
await user.paste(element, text)Paste text into TextInput
await user.scrollTo(element, options)Scroll a ScrollView with { y } or { x } offset

fireEvent (Low-level, Sync in v13)

Use only when userEvent doesn't support the event or when you need direct control.

MethodDescription
fireEvent(element, eventName, ...data)Fire any event by name
fireEvent.press(element)Fire onPress only (no pressIn/pressOut)
fireEvent.changeText(element, text)Fire onChangeText directly
fireEvent.scroll(element, eventData)Fire onScroll with event data

Sync vs Async APIs (v13)

  • render(), fireEvent.*, renderHook() are synchronous - no await needed
  • userEvent.* is asynchronous - requires await
  • Don't wrap in act() - render and fireEvent handle it internally
  • For React 19: use async variants: renderAsync(), fireEventAsync.*() with await.

waitFor Usage

  • Use findBy* instead of waitFor + getBy* when waiting for elements
  • Never perform side-effects (like fireEvent.press()) inside waitFor callbacks
  • One assertion per waitFor callback
  • Never pass empty callbacks - always include a meaningful assertion
  • Place side-effects before waitFor - perform actions, then wait for result

Code Organization

  • Use screen instead of destructuring from render(): screen.getByText() not const { getByText } = render()
  • Prefer userEvent over fireEvent for realistic interactions
  • Don't use cleanup() - handled automatically
  • Name wrappers descriptively: ThemeProvider not Wrapper
  • Install ESLint plugin: eslint-plugin-testing-library

Quick Checklist

  • Using getByRole as first choice?
  • Using findBy* for async elements (not waitFor + getBy*)?
  • Using queryBy* only for non-existence?
  • Using RNTL matchers (toBeOnTheScreen(), toBeDisabled(), etc.)?
  • Using screen not destructuring from render()?
  • Avoiding side-effects in waitFor?
  • Using userEvent when appropriate?

Example: Good Pattern

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

test('user can submit form', async () => {
  const user = userEvent.setup();

  const Component = () => {
    const [name, setName] = React.useState('');
    const [submitted, setSubmitted] = React.useState(false);

    return (
      <View>
        <TextInput role="textbox" aria-label="Name" value={name} onChangeText={setName} />
        <Pressable role="button" aria-label="Submit" onPress={() => setSubmitted(true)}>
          <Text>Submit</Text>
        </Pressable>
        {submitted && <Text role="alert">Form submitted!</Text>}
      </View>
    );
  };

  render(<Component />);

  // getByRole as first choice
  const input = screen.getByRole('textbox', { name: 'Name' });
  const button = screen.getByRole('button', { name: 'Submit' });

  // userEvent for realistic interactions (async)
  await user.type(input, 'John Doe');
  await user.press(button);

  // findBy* for async elements
  const successMessage = await screen.findByRole('alert');

  // RNTL matchers
  expect(successMessage).toBeOnTheScreen();
  expect(successMessage).toHaveTextContent('Form submitted!');
});

Example: Anti-Patterns

// getBy* for non-existence
expect(screen.getByText('Error')).not.toBeOnTheScreen();

// waitFor + getBy* instead of findBy*
await waitFor(() => {
  expect(screen.getByText('Loaded')).toBeOnTheScreen();
});

// Side-effect in waitFor
await waitFor(async () => {
  fireEvent.press(button);
  expect(screen.getByText('Result')).toBeOnTheScreen();
});

// accessibility* props instead of ARIA
<Pressable accessibilityRole="button" accessibilityLabel="Submit" />;

// Destructuring from render
const { getByText } = render(<Component />);

By following these guidelines, your tests will be more maintainable, accessible, and reliable.

Need React or React Native expertise you can count on?