| name | testing-library |
| description | Enforces best practices for unit testing with Jest, @testing-library/react-native, and jest-expo in Expo projects. This skill should be used when writing, reviewing, or debugging unit tests to ensure tests are accessible, maintainable, and follow Testing Library guiding principles. Use this skill for test file creation, query selection, async handling, mocking patterns, and Expo Router testing. Use when this capability is needed. |
| metadata | {"author":"CodySwannGT"} |
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
Source: CodySwannGT/lisa — distributed by TomeVault.