| name | testing-library |
| description | Enforces best practices for… |
Testing Library Best Practices
Overview
This skill enforces best practices for unit testing in Expo applications using Jest, @testing-library/react-native, and jest-expo. Tests should be user-centric, accessible, and behavior-focused rather than implementation-focused.
Core Principles
1. Test User Behavior, Not Implementation
Focus on what the component does from a user's perspective, not how it achieves it internally.
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
expect(component.state.isSubmitting).toBe(false);
2. Use Accessible Queries
Queries should reflect how users and assistive technologies interact with the UI.
screen.getByRole("button", { name: /save changes/i });
screen.getByTestId("save-btn");
3. One Assertion Per Behavior
Each test should verify one behavior. Multiple assertions are acceptable when verifying different aspects of the same behavior.
test("displays error message when submission fails", async () => {
render(<Form />);
await userEvent.press(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Failed");
});
test("form works correctly", async () => {
});
4. Prefer userEvent Over fireEvent
userEvent simulates realistic user interactions including the full event sequence.
const user = userEvent.setup();
await user.press(screen.getByRole("button", { name: "Submit" }));
fireEvent.press(screen.getByRole("button", { name: "Submit" }));
Query Priority
Choose queries based on accessibility, following this priority order:
| Priority | Query | When to Use |
|---|
| 1 | getByRole | Interactive elements, headings, buttons |
| 2 | getByLabelText | Form fields with labels |
| 3 | getByText | Non-interactive content, static text |
| 4 | getByTestId | Last resort when semantic queries fail |
For detailed query patterns, see references/query-priority.md.
Async Testing Patterns
Use findBy for Async Assertions
expect(await screen.findByRole("alert")).toBeOnTheScreen();
expect(screen.getByRole("alert")).toBeOnTheScreen();
Use waitFor for Side Effects
await waitFor(() => {
expect(mockCallback).toHaveBeenCalledWith("success");
});
await waitFor(() => {
fireEvent.press(button);
});
For comprehensive async patterns, see references/async-patterns.md.
Mocking Patterns
Required Global Mocks
These must be configured in jest/setup-jest.ts:
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);
jest.mock("expo-font", () => ({
...jest.requireActual("expo-font"),
isLoaded: jest.fn(() => true),
}));
For complete mocking patterns, see references/mocking-patterns.md.
Expo Router Testing
Use renderRouter from expo-router/testing-library instead of render when testing components that use Expo Router.
import { renderRouter, screen } from "expo-router/testing-library";
test("navigates to player detail", async () => {
renderRouter({
index: () => <PlayerList />,
"players/[id]": () => <PlayerDetail />,
});
await userEvent.press(screen.getByRole("button", { name: "View Player" }));
expect(screen).toHavePathname("/players/123");
});
For Expo Router testing details, see references/expo-router-testing.md.
Test Structure
File Organization
- Place test files in
__tests__/ directories, not alongside source files
- Never place tests inside the
app/ directory (Expo Router constraint)
- Use
.test.ts or .test.tsx extensions
AAA Pattern
Structure every test with Arrange-Act-Assert:
test("increments counter when button pressed", async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.press(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByRole("text", { name: "Count: 1" })).toBeOnTheScreen();
});
Descriptive Test Names
Use descriptive names that explain the expected behavior:
test("displays validation error when email format is invalid", () => {});
test("disables submit button while form is submitting", () => {});
test("email validation works", () => {});
test("sets isSubmitting to true", () => {});
Jest Configuration
Manual React Native Resolution (No Preset)
Lisa configures Jest manually instead of using the jest-expo preset to avoid
jsdom incompatibility with react-native/jest/setup.js. The configuration in
jest.expo.ts provides haste, resolver, transform, and setupFiles that match
the preset's behavior without redefining window.
Use Fake Timers with userEvent
jest.useFakeTimers();
test("handles debounced input", async () => {
const user = userEvent.setup();
render(<SearchInput />);
await user.type(screen.getByRole("textbox"), "query");
jest.runAllTimers();
expect(await screen.findByText("Results")).toBeOnTheScreen();
});
Anti-Patterns
Never Test Implementation Details
expect(wrapper.state().isLoading).toBe(true);
expect(wrapper.instance().handleSubmit).toHaveBeenCalled();
expect(screen.getByRole("progressbar")).toBeOnTheScreen();
Never Use getByTestId as Default
screen.getByTestId("submit-button");
screen.getByRole("button", { name: "Submit" });
Never Wrap render or fireEvent in act()
await act(async () => {
render(<Component />);
});
render(<Component />);
Never Put Side Effects in waitFor
await waitFor(() => {
fireEvent.press(button);
expect(result).toBeOnTheScreen();
});
fireEvent.press(button);
await waitFor(() => {
expect(result).toBeOnTheScreen();
});
Never Use Multiple Assertions in waitFor
await waitFor(() => {
expect(title).toBeOnTheScreen();
expect(subtitle).toBeOnTheScreen();
expect(button).toBeEnabled();
});
expect(await screen.findByRole("heading")).toBeOnTheScreen();
expect(screen.getByText("Subtitle")).toBeOnTheScreen();
expect(screen.getByRole("button")).toBeEnabled();
Quick Reference
Common Matchers
| Matcher | Purpose |
|---|
toBeOnTheScreen() | Element is currently rendered |
toBeEnabled() | Interactive element is enabled |
toBeDisabled() | Interactive element is disabled |
toHaveTextContent() | Element contains text |
toBeVisible() | Element is visible to user |
toBeChecked() | Checkbox/radio is checked |
Query Variants
| Prefix | Returns | Throws on 0 | Throws on >1 | Async |
|---|
| getBy | Element | Yes | Yes | No |
| queryBy | Element | null | No | Yes | No |
| findBy | Promise<Element> | Yes | Yes | Yes |
| getAllBy | Element[] | Yes | No | No |
| queryAllBy | Element[] | No | No | No |
| findAllBy | Promise<Element[]> | Yes | No | Yes |
References