@testing-library/react-native
Version:
Simple and complete React Native testing utilities that encourage good testing practices.
229 lines (175 loc) • 11.1 kB
Markdown
# LLM Guidelines for React Native Testing Library
Actionable guidelines for writing tests with React Native Testing Library (RNTL) v14.
## Core APIs
### render
```tsx
const result = await render(<Component />, options?);
```
| Option | Description |
| --------- | ---------------------------------------------------------------- |
| `wrapper` | React component to wrap the rendered component (e.g., providers) |
| Return | Description |
| --------------------- | ------------------------------------------------ |
| `rerender(component)` | Re-render with a new component (async) |
| `unmount()` | Unmount the rendered component (async) |
| `toJSON()` | Get JSON representation for snapshots |
| `debug(options?)` | Print the component tree to console |
| `container` | Root host element of the rendered tree |
| `root` | First child host element (your component's root) |
### screen
**Prefer `screen`** over destructuring from `render()`. Provides all query methods after `render()` is called.
```tsx
await render(<Component />);
screen.getByRole('button'); // Access queries via screen
```
### renderHook
```tsx
const { result, rerender, unmount } = await renderHook(() => useMyHook(), options?);
```
| Option | Description |
| -------------- | -------------------------------------------------- |
| `initialProps` | Initial props passed to the hook |
| `wrapper` | React component to wrap the hook (e.g., providers) |
| Return | Description |
| ------------------ | ------------------------------------- |
| `result.current` | Current return value of the hook |
| `rerender(props?)` | Re-render hook with new props (async) |
| `unmount()` | Unmount the hook (async) |
## Query Selection
- **Prefer `getByRole`** as first choice for querying elements
- **Query priority**: `getByRole` → `getByLabelText` → `getByPlaceholderText` → `getByText` → `getByDisplayValue` → `getByTestId` (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
| Matcher | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------- |
| `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)
```tsx
const user = userEvent.setup();
```
| Method | Description |
| ------------------------------------ | ----------------------------------------------------------------------------------- |
| `user.press(element)` | Press an element (triggers `pressIn`, `pressOut`, `press`) |
| `user.longPress(element, options?)` | Long press with optional `{ duration }` |
| `user.type(element, text, options?)` | Type into TextInput (triggers `focus`, `keyPress`, `change`, `changeText` per char) |
| `user.clear(element)` | Clear TextInput (select all + backspace) |
| `user.paste(element, text)` | Paste text into TextInput |
| `user.scrollTo(element, options)` | Scroll a ScrollView with `{ y }` or `{ x }` offset |
### fireEvent (Low-level)
Use only when `userEvent` doesn't support the event or when you need direct control.
| Method | Description |
| ---------------------------------------- | --------------------------------------------- |
| `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 |
## Async/Await (v14)
- **Always `await`**: `render()`, `fireEvent.*`, `renderHook()`, `userEvent.*`
- **Make test functions `async`**: `test('name', async () => { ... })`
- **Don't wrap in `act()`** - `render` and `fireEvent` handle it internally
## 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 `await` for all async operations?
- ✅ 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
```tsx
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>
);
};
await render(<Component />);
// ✅ getByRole as first choice
const input = screen.getByRole('textbox', { name: 'Name' });
const button = screen.getByRole('button', { name: 'Submit' });
// ✅ userEvent for realistic interactions
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
```tsx
// ❌ Missing await
test('bad', () => {
render(<Component />);
fireEvent.press(screen.getByText('Submit'));
});
// ❌ 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 () => {
await fireEvent.press(button);
expect(screen.getByText('Result')).toBeOnTheScreen();
});
// ❌ accessibility* props instead of ARIA
<Pressable accessibilityRole="button" accessibilityLabel="Submit" />;
// ❌ Destructuring from render
const { getByText } = await render(<Component />);
```
By following these guidelines, your tests will be more maintainable, accessible, and reliable.