React Native Testing Library
@testing-library/react-native (RNTL) tests components the way users interact with them - by visible text, accessibility roles, and labels - not by implementation details like internal state or private methods.
Search across all documentation pages
@testing-library/react-native (RNTL) tests components the way users interact with them - by visible text, accessibility roles, and labels - not by implementation details like internal state or private methods.
Quick-reference recipe card - copy-paste ready.
import { useState } from "react";
import { render, screen, userEvent, waitFor } from "@testing-library/react-native";
import { Pressable, Text, TextInput, View } from "react-native";
function LoginForm({ onSubmit }: { onSubmit: (email: string) => void }) {
const [email, setEmail] = useState("");
return (
<View>
<TextInput
accessibilityLabel="Email"
value={email}
onChangeText={setEmail}
/>
<Pressable accessibilityRole="button" onPress={() => onSubmit(email)}>
<Text>Sign in</Text>
</Pressable>
</View>
);
}
test("submits email on press", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "alex@example.com");
await user.press(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith("alex@example.com");
});
});When to reach for this:
renderHook from @testing-library/react-native.renderRouter from expo-router/testing-library.// src/features/tasks/task-list.tsx
import { useCallback, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
Text,
View,
type ListRenderItem,
} from "react-native";
export interface Task {
id: string;
title: string;
done: boolean;
}
interface TaskListProps {
tasks: Task[];
loading: boolean;
onToggle: (id: string) => void;
}
export function TaskList({ tasks, loading, onToggle }: TaskListProps) {
const [error, setError] = useState<string | null>(null);
const renderItem: ListRenderItem<Task> = useCallback(
({ item }) => (
<Pressable
accessibilityRole="checkbox"
accessibilityState={{ checked: item.done }}
onPress={() => onToggle(item.id)}
>
<Text>{item.title}</Text>
</Pressable>
),
[onToggle]
);
if (loading) {
return (
<View accessibilityLabel="Loading tasks">
<ActivityIndicator />
</View>
);
}
if (error) {
return <Text role="alert">{error}</Text>;
}
if (tasks.length === 0) {
return <Text>No tasks yet</Text>;
}
return (
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderItem}
/>
);
}// __tests__/task-list-test.tsx
import { render, screen, userEvent, waitFor } from "@testing-library/react-native";
import { TaskList, type Task } from "../src/features/tasks/task-list";
const TASKS: Task[] = [
{ id: "1", title: "Ship release", done: false },
{ id: "2", title: "Write tests", done: true },
];
describe("<TaskList />", () => {
test("shows loading state", () => {
render(<TaskList tasks={[]} loading onToggle={jest.fn()} />);
expect(screen.getByLabelText("Loading tasks")).toBeOnTheScreen();
});
test("shows empty state", () => {
render(<TaskList tasks={[]} loading={false} onToggle={jest.fn()} />);
expect(screen.getByText("No tasks yet")).toBeOnTheScreen();
});
test("toggles a task via checkbox role", async () => {
const onToggle = jest.fn();
const user = userEvent.setup();
render(<TaskList tasks={TASKS} loading={false} onToggle={onToggle} />);
const shipRelease = screen.getByRole("checkbox", { name: "Ship release" });
expect(shipRelease).toHaveAccessibilityState({ checked: false });
await user.press(shipRelease);
await waitFor(() => {
expect(onToggle).toHaveBeenCalledWith("1");
});
});
test("lists all task titles", () => {
render(<TaskList tasks={TASKS} loading={false} onToggle={jest.fn()} />);
expect(screen.getByText("Ship release")).toBeOnTheScreen();
expect(screen.getByText("Write tests")).toBeOnTheScreen();
});
});What this demonstrates:
getByLabelText for loading, getByRole("checkbox") for toggles, getByText for copy.userEvent.setup() - async press simulates real interaction; always await.waitFor - asserts callbacks after React flushes updates instead of arbitrary delays.toBeOnTheScreen / toHaveAccessibilityState - RNTL matchers registered via import (see Jest Setup for Expo).View, Text, Pressable).screen is a namespace bound to the most recent render() - re-query after state updates instead of caching element references from before rerenders.userEvent schedules interactions like a real user (press, type, scroll) and returns promises - synchronous fireEvent still exists but userEvent is preferred for new tests.waitFor, findBy*) poll until assertions pass or timeout - they replace manual setTimeout + act gymnastics.| Priority | Query | Example |
|---|---|---|
| 1 | getByRole | getByRole("button", { name: "Sign in" }) |
| 2 | getByLabelText | getByLabelText("Email") |
| 3 | getByPlaceholderText | getByPlaceholderText("Search tasks") |
| 4 | getByText | getByText("No tasks yet") |
| 5 | getByTestId | getByTestId("task-list") - last resort |
getBy* throws if zero or many matches. queryBy* returns null when absent (good for negative assertions). findBy* wraps waitFor + getBy* for elements that appear after async work.
// Element appears after fetch - prefer findBy*
expect(await screen.findByText("Welcome back")).toBeOnTheScreen();
// Callback or mock invoked after state flush
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled();
});
// Multiple assertions after one interaction
await user.press(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => {
expect(screen.getByText("Updated")).toBeOnTheScreen();
});Default waitFor timeout is 1000 ms - pass { timeout: 3000 } for slow mocked networks, not for production timing.
renderRouterimport { renderRouter, screen } from "expo-router/testing-library";
import { Text, View } from "react-native";
test("lands on settings route", async () => {
const Settings = () => (
<View>
<Text>Settings</Text>
</View>
);
renderRouter(
{ index: () => <Text>Home</Text>, settings: Settings },
{ initialUrl: "/settings" }
);
expect(screen).toHavePathname("/settings");
expect(screen.getByText("Settings")).toBeOnTheScreen();
});app/ - only routes and layouts belong there.renderRouter accepts inline route maps, string path arrays, or fixture directories.toHavePathname, toHaveSegments, useLocalSearchParams - see Expo Router testing docs.import { renderHook, act } from "@testing-library/react-native";
import { useCounter } from "../src/hooks/use-counter";
test("increments", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});renderHook types result.current from the hook return type - no manual casts.ListRenderItem<T> on components keeps renderItem callbacks typed in both app and tests.eslint-plugin-testing-library on test files to catch getByTestId overuse and missing await on userEvent.Cached element references after rerender - Stale nodes from before a state update cause "element not found" flakes. Fix: Re-query with screen.getBy* inside waitFor, or scope queries to the latest render.
Missing await on userEvent - Press and type are async; forgetting await races assertions. Fix: await user.press(...) and await user.type(...) in every test.
getByTestId as the default - testID is invisible to users and drifts from copy changes. Fix: Add accessibilityLabel / accessibilityRole in components; reserve testID for list items or native-driver edge cases.
fireEvent for complex flows - fireEvent.press bypasses the event pipeline userEvent exercises. Fix: Prefer userEvent.setup() for interactions; keep fireEvent for rare low-level cases.
Testing implementation details - Asserting component.state.count or enzyme-style shallow renders breaks on refactors. Fix: Assert visible outcomes: text, roles, callbacks, accessibility state.
FlatList items not found - Virtualized lists may not mount off-screen rows. Fix: Pass small data arrays in tests, set initialNumToRender high in a test wrapper, or test row components in isolation.
Not importing RNTL in Jest setup - Matchers like toBeOnTheScreen throw "matcher not found". Fix: import "@testing-library/react-native" in jest.setup.ts (see Jest Setup for Expo).
| Alternative | Use When | Don't Use When |
|---|---|---|
| RNTL + Jest | Component and hook tests in CI | You need real native gestures or OS dialogs |
renderRouter (Expo Router) | Route matching, deep links, layout guards | Testing a leaf component with no navigation |
| Maestro E2E | Full flows on simulator/device | A pure formatter with no UI |
| Detox | Gray-box sync with native idle | Quick unit feedback on every file save |
| Snapshot-only tests | Tiny stable markup | Behavior-heavy screens - snapshots miss regressions |
Same philosophy - query by what users see. APIs align (render, screen, userEvent), but RN uses accessibilityRole, accessibilityLabel, and Pressable instead of DOM roles and click.
No for new projects. RNTL registers matchers (toBeOnTheScreen, toHaveAccessibilityState) when you import from @testing-library/react-native. @testing-library/jest-native is legacy.
Prefer getByRole("button", { name: "Sign in" }) - it verifies the control is exposed as a button to assistive tech. getByText("Sign in") alone does not confirm pressability.
When the element appears after async work - fetched data, useEffect, navigation transition. findBy* combines waitFor + getBy* and returns a promise you await.
expect(screen.queryByText("Error")).toBeNull();
// or
expect(screen.queryByText("Error")).not.toBeOnTheScreen();Use queryBy* - getBy* throws when the element is missing.
Yes - renderHook from @testing-library/react-native wraps the hook in a test harness. Use act() when the hook updates state synchronously.
const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), "alex@example.com");Or fireEvent.changeText for direct control when not simulating keystrokes. Prefer userEvent for user-centric tests.
data small in tests.keyExtractor returns stable keys so cells reconcile predictably.Open the modal via userEvent.press on the trigger, then await waitFor or findBy* for modal content. Query inside the modal by role/text - not by portal name (RN has no DOM portal).
screen is preferred - queries always target the latest render without passing getByText around. Destructuring from render() is fine for single-query tests.
renderRouter(routes, { initialUrl: "/profile/42" });
expect(screen).toHavePathname("/profile/42");Use inline route maps or fixture directories; see Expo's renderRouter docs for override patterns.
Mock at the boundary your component uses - jest.mock on the API module, or MSW in Node for integration-style tests. Presenters should receive data via props so fetch mocks stay in container tests.
userEvent for new tests - it models sequential user input. fireEvent is lower-level and can hide missing await bugs when mixed with async state updates.
import { render, screen, debug } from "@testing-library/react-native";
render(<MyComponent />);
screen.debug(); // prints host tree to consoleUse sparingly - prefer explicit queryBy* failures that tell you what is on screen.
Yes - that is the standard Expo stack. Configure Jest first (Jest Setup for Expo), then write component tests with RNTL. Mock native modules as needed (Mocking Native Modules).
jest-expo preset and jest.setup.tsrenderHook patternsStack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026