Skip to content

Commit 17a7fd4

Browse files
committed
chore: add RNTL skill
1 parent 2cd7e75 commit 17a7fd4

File tree

3 files changed

+870
-0
lines changed

3 files changed

+870
-0
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
---
2+
name: react-native-teting-library-v13
3+
description: >
4+
Write tests using React Native Testing Library (RNTL) v13 (`@testing-library/react-native`).
5+
Use when writing, reviewing, or fixing React Native component tests.
6+
Covers: render, screen, queries (getBy/getAllBy/queryBy/findBy), Jest matchers,
7+
userEvent, fireEvent, waitFor, and async patterns.
8+
Supports both React 18 (sync render) and React 19 compat (renderAsync/fireEventAsync).
9+
Triggers on: test files for React Native components, RNTL imports, mentions of
10+
"testing library", "write tests", "component tests", or "RNTL".
11+
---
12+
13+
# RNTL v13 Test Writing Guide
14+
15+
## Core Pattern
16+
17+
```tsx
18+
import { render, screen, userEvent } from '@testing-library/react-native';
19+
20+
jest.useFakeTimers(); // recommended when using userEvent
21+
22+
test('description', async () => {
23+
const user = userEvent.setup();
24+
render(<Component />); // sync in v13 (React 18)
25+
26+
const button = screen.getByRole('button', { name: 'Submit' });
27+
await user.press(button);
28+
29+
expect(screen.getByText('Done')).toBeOnTheScreen();
30+
});
31+
```
32+
33+
## Query Priority
34+
35+
Use in this order: `getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByDisplayValue` > `getByTestId` (last resort).
36+
37+
## Query Variants
38+
39+
| Variant | Use case | Returns | Async |
40+
|-------------|---------------------------|---------------------------------|-------|
41+
| `getBy*` | Element must exist | `ReactTestInstance` (throws) | No |
42+
| `getAllBy*` | Multiple must exist | `ReactTestInstance[]` (throws) | No |
43+
| `queryBy*` | Check non-existence ONLY | `ReactTestInstance \| null` | No |
44+
| `queryAllBy*`| Count elements | `ReactTestInstance[]` | No |
45+
| `findBy*` | Wait for element | `Promise<ReactTestInstance>` | Yes |
46+
| `findAllBy*` | Wait for multiple | `Promise<ReactTestInstance[]>` | Yes |
47+
48+
## Interactions
49+
50+
Prefer `userEvent` over `fireEvent`. userEvent is always async.
51+
52+
```tsx
53+
const user = userEvent.setup();
54+
await user.press(element); // full press sequence
55+
await user.longPress(element, { duration: 800 }); // long press
56+
await user.type(textInput, 'Hello'); // char-by-char typing
57+
await user.clear(textInput); // clear TextInput
58+
await user.paste(textInput, 'pasted text'); // paste into TextInput
59+
await user.scrollTo(scrollView, { y: 100 }); // scroll
60+
```
61+
62+
Use `fireEvent` only when `userEvent` doesn't support the event:
63+
```tsx
64+
fireEvent.press(element); // sync, onPress only
65+
fireEvent.changeText(textInput, 'new text'); // sync, onChangeText only
66+
fireEvent(element, 'blur'); // any event by name
67+
```
68+
69+
## Assertions (Jest Matchers)
70+
71+
Available automatically with any `@testing-library/react-native` import.
72+
73+
| Matcher | Use for |
74+
|---------------------------------|----------------------------------------------|
75+
| `toBeOnTheScreen()` | Element exists in tree |
76+
| `toBeVisible()` | Element visible (not hidden/display:none) |
77+
| `toBeEnabled()` / `toBeDisabled()` | Disabled state via `aria-disabled` |
78+
| `toBeChecked()` / `toBePartiallyChecked()` | Checked state |
79+
| `toBeSelected()` | Selected state |
80+
| `toBeExpanded()` / `toBeCollapsed()` | Expanded state |
81+
| `toBeBusy()` | Busy state |
82+
| `toHaveTextContent(text)` | Text content match |
83+
| `toHaveDisplayValue(value)` | TextInput display value |
84+
| `toHaveAccessibleName(name)` | Accessible name |
85+
| `toHaveAccessibilityValue(val)` | Accessibility value |
86+
| `toHaveStyle(style)` | Style match |
87+
| `toHaveProp(name, value?)` | Prop check (last resort) |
88+
| `toContainElement(el)` | Contains child element |
89+
| `toBeEmptyElement()` | No children |
90+
91+
## Rules
92+
93+
1. **Use `screen`** for queries, not destructuring from `render()`
94+
2. **Use `getByRole` first** with `{ name: '...' }` option
95+
3. **Use `queryBy*` ONLY** for `.not.toBeOnTheScreen()` checks
96+
4. **Use `findBy*`** for async elements, NOT `waitFor` + `getBy*`
97+
5. **Never put side-effects in `waitFor`** (no `fireEvent`/`userEvent` inside)
98+
6. **One assertion per `waitFor`**
99+
7. **Never pass empty callbacks to `waitFor`**
100+
8. **Don't wrap in `act()`** - `render`, `fireEvent`, `userEvent` handle it
101+
9. **Don't call `cleanup()`** - automatic after each test
102+
10. **Prefer ARIA props** (`role`, `aria-label`, `aria-disabled`) over legacy `accessibility*` props
103+
11. **Use RNTL matchers** over raw prop assertions
104+
105+
## React 19 Compatibility (v13.3+)
106+
107+
For React 19 or Suspense, use async variants:
108+
109+
```tsx
110+
import { renderAsync, screen, fireEventAsync } from '@testing-library/react-native';
111+
112+
test('async component', async () => {
113+
await renderAsync(<SuspenseComponent />);
114+
await fireEventAsync.press(screen.getByRole('button'));
115+
expect(screen.getByText('Result')).toBeOnTheScreen();
116+
});
117+
```
118+
119+
Use `rerenderAsync`/`unmountAsync` instead of `rerender`/`unmount` when using `renderAsync`.
120+
121+
## `*ByRole` Quick Reference
122+
123+
Common roles: `button`, `text`, `heading` (alias: `header`), `searchbox`, `switch`, `checkbox`, `radio`, `img`, `link`, `alert`, `menu`, `menuitem`, `tab`, `tablist`, `progressbar`, `slider`, `spinbutton`, `timer`, `toolbar`.
124+
125+
`getByRole` options: `{ name, disabled, selected, checked, busy, expanded, value: { min, max, now, text } }`.
126+
127+
For `*ByRole` to match, the element must be an accessibility element:
128+
- `Text`, `TextInput`, `Switch` are by default
129+
- `View` needs `accessible={true}` (or use `Pressable`/`TouchableOpacity`)
130+
131+
## API Reference
132+
133+
See [references/api-reference.md](references/api-reference.md) for complete API signatures and options for render, screen, queries, userEvent, fireEvent, Jest matchers, waitFor, renderHook, configuration, and accessibility helpers.
134+
135+
## Anti-Patterns Reference
136+
137+
See [references/anti-patterns.md](references/anti-patterns.md) for detailed examples of what NOT to do.
138+
139+
## waitFor
140+
141+
```tsx
142+
// Correct: action first, then wait for result
143+
fireEvent.press(button);
144+
await waitFor(() => {
145+
expect(screen.getByText('Result')).toBeOnTheScreen();
146+
});
147+
148+
// Better: use findBy* instead
149+
fireEvent.press(button);
150+
expect(await screen.findByText('Result')).toBeOnTheScreen();
151+
```
152+
153+
Options: `waitFor(cb, { timeout: 1000, interval: 50 })`. Works with Jest fake timers automatically.
154+
155+
## Fake Timers
156+
157+
Recommended with `userEvent` (press/longPress involve real durations):
158+
159+
```tsx
160+
jest.useFakeTimers();
161+
162+
test('with fake timers', async () => {
163+
const user = userEvent.setup();
164+
render(<Component />);
165+
await user.press(screen.getByRole('button'));
166+
// ...
167+
});
168+
```
169+
170+
## Custom Render
171+
172+
Wrap providers using `wrapper` option:
173+
174+
```tsx
175+
function renderWithProviders(ui: React.ReactElement) {
176+
return render(ui, {
177+
wrapper: ({ children }) => (
178+
<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>
179+
),
180+
});
181+
}
182+
```
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# RNTL v13 Anti-Patterns
2+
3+
## Table of Contents
4+
- [Wrong query variant](#wrong-query-variant)
5+
- [Not using *ByRole](#not-using-byrole)
6+
- [Wrong assertions](#wrong-assertions)
7+
- [waitFor misuse](#waitfor-misuse)
8+
- [Unnecessary act()](#unnecessary-act)
9+
- [fireEvent instead of userEvent](#fireevent-instead-of-userevent)
10+
- [Destructuring render](#destructuring-render)
11+
- [Using UNSAFE_root](#using-unsafe_root)
12+
- [Manual cleanup](#manual-cleanup)
13+
- [Legacy accessibility props](#legacy-accessibility-props)
14+
15+
## Wrong query variant
16+
17+
```tsx
18+
// BAD: queryBy* when element should exist
19+
const button = screen.queryByRole('button');
20+
expect(button).toBeOnTheScreen();
21+
22+
// GOOD: getBy* when element should exist
23+
const button = screen.getByRole('button');
24+
expect(button).toBeOnTheScreen();
25+
26+
// BAD: getBy* for non-existence check (throws instead of failing gracefully)
27+
expect(screen.getByText('Error')).not.toBeOnTheScreen();
28+
29+
// GOOD: queryBy* for non-existence check
30+
expect(screen.queryByText('Error')).not.toBeOnTheScreen();
31+
32+
// BAD: waitFor + getBy* for async elements
33+
await waitFor(() => {
34+
expect(screen.getByText('Loaded')).toBeOnTheScreen();
35+
});
36+
37+
// GOOD: findBy* for async elements
38+
expect(await screen.findByText('Loaded')).toBeOnTheScreen();
39+
```
40+
41+
## Not using *ByRole
42+
43+
```tsx
44+
// BAD: testID when accessible query works
45+
<Pressable testID="submit-btn" role="button">
46+
<Text>Submit</Text>
47+
</Pressable>
48+
screen.getByTestId('submit-btn');
49+
50+
// GOOD: query by role and accessible name
51+
screen.getByRole('button', { name: 'Submit' });
52+
53+
// BAD: getByText for a button (less semantic)
54+
screen.getByText('Submit');
55+
56+
// GOOD: getByRole with name (more semantic, tests accessibility)
57+
screen.getByRole('button', { name: 'Submit' });
58+
```
59+
60+
## Wrong assertions
61+
62+
```tsx
63+
// BAD: asserting on props directly
64+
expect(button.props['aria-disabled']).toBe(true);
65+
expect(button.props.style.backgroundColor).toBe('red');
66+
67+
// GOOD: use RNTL matchers
68+
expect(button).toBeDisabled();
69+
expect(button).toHaveStyle({ backgroundColor: 'red' });
70+
71+
// BAD: redundant null check (getBy already throws)
72+
const el = screen.getByText('Hello');
73+
expect(el).not.toBeNull();
74+
75+
// GOOD: use toBeOnTheScreen
76+
expect(screen.getByText('Hello')).toBeOnTheScreen();
77+
```
78+
79+
## waitFor misuse
80+
81+
```tsx
82+
// BAD: side-effect inside waitFor (press runs on every retry)
83+
await waitFor(() => {
84+
fireEvent.press(screen.getByRole('button'));
85+
expect(screen.getByText('Result')).toBeOnTheScreen();
86+
});
87+
88+
// GOOD: side-effect outside, assertion inside
89+
fireEvent.press(screen.getByRole('button'));
90+
await waitFor(() => {
91+
expect(screen.getByText('Result')).toBeOnTheScreen();
92+
});
93+
94+
// BETTER: use findBy*
95+
fireEvent.press(screen.getByRole('button'));
96+
expect(await screen.findByText('Result')).toBeOnTheScreen();
97+
98+
// BAD: empty waitFor callback
99+
await waitFor(() => {});
100+
101+
// BAD: multiple assertions in single waitFor
102+
await waitFor(() => {
103+
expect(screen.getByText('Title')).toBeOnTheScreen();
104+
expect(screen.getByText('Subtitle')).toBeOnTheScreen();
105+
});
106+
107+
// GOOD: one assertion per waitFor, rest after
108+
await waitFor(() => {
109+
expect(screen.getByText('Title')).toBeOnTheScreen();
110+
});
111+
expect(screen.getByText('Subtitle')).toBeOnTheScreen();
112+
```
113+
114+
## Unnecessary act()
115+
116+
```tsx
117+
// BAD: wrapping render in act
118+
act(() => {
119+
render(<Component />);
120+
});
121+
122+
// GOOD: render handles act internally
123+
render(<Component />);
124+
125+
// BAD: wrapping fireEvent in act
126+
act(() => {
127+
fireEvent.press(button);
128+
});
129+
130+
// GOOD: fireEvent handles act internally
131+
fireEvent.press(button);
132+
133+
// BAD: wrapping userEvent in act
134+
await act(async () => {
135+
await user.press(button);
136+
});
137+
138+
// GOOD: userEvent handles act internally
139+
await user.press(button);
140+
```
141+
142+
## fireEvent instead of userEvent
143+
144+
```tsx
145+
// BAD: fireEvent.press (only fires onPress, no pressIn/pressOut)
146+
fireEvent.press(button);
147+
148+
// GOOD: userEvent.press (full press lifecycle)
149+
const user = userEvent.setup();
150+
await user.press(button);
151+
152+
// BAD: fireEvent.changeText (sets text all at once, no focus/blur/keyPress)
153+
fireEvent.changeText(input, 'Hello');
154+
155+
// GOOD: user.type (char-by-char with full event sequence)
156+
await user.type(input, 'Hello');
157+
```
158+
159+
## Destructuring render
160+
161+
```tsx
162+
// BAD: destructuring queries from render
163+
const { getByText, getByRole } = render(<Component />);
164+
getByText('Hello');
165+
166+
// GOOD: use screen object
167+
render(<Component />);
168+
screen.getByText('Hello');
169+
```
170+
171+
## Using UNSAFE_root
172+
173+
```tsx
174+
// BAD: traversing the tree manually
175+
const { UNSAFE_root } = render(<Component />);
176+
const el = UNSAFE_root.findAll(node => node.props.testID === 'foo')[0];
177+
178+
// GOOD: use proper queries
179+
render(<Component />);
180+
screen.getByTestId('foo');
181+
```
182+
183+
## Manual cleanup
184+
185+
```tsx
186+
// BAD: calling cleanup manually (it's automatic)
187+
afterEach(() => {
188+
cleanup();
189+
});
190+
191+
// GOOD: just don't - RNTL auto-cleans after each test
192+
```
193+
194+
## Legacy accessibility props
195+
196+
```tsx
197+
// BAD: legacy accessibility props
198+
<Pressable accessibilityRole="button" accessibilityLabel="Submit">
199+
<Text>Submit</Text>
200+
</Pressable>
201+
202+
// GOOD: ARIA-compatible props
203+
<Pressable role="button" aria-label="Submit">
204+
<Text>Submit</Text>
205+
</Pressable>
206+
207+
// BAD: legacy state props
208+
<Pressable accessibilityState={{ disabled: true, checked: true }}>
209+
210+
// GOOD: ARIA state props
211+
<Pressable aria-disabled aria-checked>
212+
```

0 commit comments

Comments
 (0)