|
| 1 | +# LLM Guidelines for React Native Testing Library |
| 2 | + |
| 3 | +Actionable guidelines for writing tests with React Native Testing Library (RNTL) v14. |
| 4 | + |
| 5 | +## Query Selection |
| 6 | + |
| 7 | +- **Prefer `getByRole`** as first choice for querying elements |
| 8 | +- **Query priority**: `getByRole` → `getByLabelText` → `getByPlaceholderText` → `getByText` → `getByDisplayValue` → `getByTestId` (last resort) |
| 9 | +- **Use `findBy*`** for elements that appear asynchronously (after API calls, timeouts, state updates) |
| 10 | +- **Use `queryBy*` ONLY** for checking non-existence (with `.not.toBeOnTheScreen()`) |
| 11 | +- **Never use `getBy*`** for non-existence checks |
| 12 | +- **Avoid `container.queryAll()`** - use `screen` queries instead |
| 13 | +- **Query by visible text**, not `testID` when text is available |
| 14 | + |
| 15 | +## Assertions |
| 16 | + |
| 17 | +- **Use RNTL matchers**: `toBeOnTheScreen()`, `toBeDisabled()`, `toHaveTextContent()`, `toHaveAccessibleName()` |
| 18 | +- **Don't assert on props directly** - use semantic matchers instead |
| 19 | +- **Combine queries with matchers**: `expect(screen.getByText('Hello')).toBeOnTheScreen()` |
| 20 | +- **No redundant null checks** - `getBy*` already throws if not found |
| 21 | + |
| 22 | +## Async/Await (v14) |
| 23 | + |
| 24 | +- **Always `await`**: `render()`, `fireEvent.*`, `renderHook()`, `userEvent.*` |
| 25 | +- **Make test functions `async`**: `test('name', async () => { ... })` |
| 26 | +- **Don't wrap in `act()`** - `render` and `fireEvent` handle it internally |
| 27 | + |
| 28 | +## waitFor Usage |
| 29 | + |
| 30 | +- **Use `findBy*`** instead of `waitFor` + `getBy*` when waiting for elements |
| 31 | +- **Never perform side-effects** (like `fireEvent.press()`) inside `waitFor` callbacks |
| 32 | +- **One assertion per `waitFor`** callback |
| 33 | +- **Never pass empty callbacks** - always include a meaningful assertion |
| 34 | +- **Place side-effects before `waitFor`** - perform actions, then wait for result |
| 35 | + |
| 36 | +## Accessibility |
| 37 | + |
| 38 | +- **Prefer ARIA attributes** over `accessibility*` props: |
| 39 | + - `role` instead of `accessibilityRole` |
| 40 | + - `aria-label` instead of `accessibilityLabel` |
| 41 | + - `aria-disabled` instead of `accessibilityState={{ disabled: true }}` |
| 42 | + - `aria-checked` instead of `accessibilityState={{ checked: true }}` |
| 43 | + - `aria-selected` instead of `accessibilityState={{ selected: true }}` |
| 44 | + - `aria-expanded` instead of `accessibilityState={{ expanded: true }}` |
| 45 | + - `aria-busy` instead of `accessibilityState={{ busy: true }}` |
| 46 | +- **Only add necessary attributes** - don't add unnecessary accessibility props |
| 47 | +- **Use `role` prop** on interactive elements for better querying |
| 48 | + |
| 49 | +## Code Organization |
| 50 | + |
| 51 | +- **Use `screen`** instead of destructuring from `render()`: `screen.getByText()` not `const { getByText } = render()` |
| 52 | +- **Prefer `userEvent`** over `fireEvent` for realistic interactions |
| 53 | +- **Don't use `cleanup()`** - handled automatically |
| 54 | +- **Name wrappers descriptively**: `ThemeProvider` not `Wrapper` |
| 55 | +- **Install ESLint plugin**: `eslint-plugin-testing-library` |
| 56 | + |
| 57 | +## Quick Checklist |
| 58 | + |
| 59 | +- ✅ Using `getByRole` as first choice? |
| 60 | +- ✅ Using `await` for all async operations? |
| 61 | +- ✅ Using `findBy*` for async elements (not `waitFor` + `getBy*`)? |
| 62 | +- ✅ Using `queryBy*` only for non-existence? |
| 63 | +- ✅ Using RNTL matchers (`toBeOnTheScreen()`, `toBeDisabled()`, etc.)? |
| 64 | +- ✅ Using ARIA attributes (`role`, `aria-label`) not `accessibility*` props? |
| 65 | +- ✅ Using `screen` not destructuring from `render()`? |
| 66 | +- ✅ Avoiding side-effects in `waitFor`? |
| 67 | +- ✅ Using `userEvent` when appropriate? |
| 68 | + |
| 69 | +## Example: Good Pattern |
| 70 | + |
| 71 | +```tsx |
| 72 | +import { render, screen } from '@testing-library/react-native'; |
| 73 | +import userEvent from '@testing-library/react-native'; |
| 74 | +import { Pressable, Text, TextInput, View } from 'react-native'; |
| 75 | + |
| 76 | +test('user can submit form', async () => { |
| 77 | + const user = userEvent.setup(); |
| 78 | + |
| 79 | + const Component = () => { |
| 80 | + const [name, setName] = React.useState(''); |
| 81 | + const [submitted, setSubmitted] = React.useState(false); |
| 82 | + |
| 83 | + return ( |
| 84 | + <View> |
| 85 | + <TextInput |
| 86 | + role="textbox" |
| 87 | + aria-label="Name" |
| 88 | + value={name} |
| 89 | + onChangeText={setName} |
| 90 | + /> |
| 91 | + <Pressable |
| 92 | + role="button" |
| 93 | + aria-label="Submit" |
| 94 | + onPress={() => setSubmitted(true)} |
| 95 | + > |
| 96 | + <Text>Submit</Text> |
| 97 | + </Pressable> |
| 98 | + {submitted && <Text role="alert">Form submitted!</Text>} |
| 99 | + </View> |
| 100 | + ); |
| 101 | + }; |
| 102 | + |
| 103 | + await render(<Component />); |
| 104 | + |
| 105 | + // ✅ getByRole as first choice |
| 106 | + const input = screen.getByRole('textbox', { name: 'Name' }); |
| 107 | + const button = screen.getByRole('button', { name: 'Submit' }); |
| 108 | + |
| 109 | + // ✅ userEvent for realistic interactions |
| 110 | + await user.type(input, 'John Doe'); |
| 111 | + await user.press(button); |
| 112 | + |
| 113 | + // ✅ findBy* for async elements |
| 114 | + const successMessage = await screen.findByRole('alert'); |
| 115 | + |
| 116 | + // ✅ RNTL matchers |
| 117 | + expect(successMessage).toBeOnTheScreen(); |
| 118 | + expect(successMessage).toHaveTextContent('Form submitted!'); |
| 119 | +}); |
| 120 | +``` |
| 121 | + |
| 122 | +## Example: Anti-Patterns |
| 123 | + |
| 124 | +```tsx |
| 125 | +// ❌ Missing await |
| 126 | +test('bad', () => { |
| 127 | + render(<Component />); |
| 128 | + fireEvent.press(screen.getByText('Submit')); |
| 129 | +}); |
| 130 | + |
| 131 | +// ❌ getBy* for non-existence |
| 132 | +expect(screen.getByText('Error')).not.toBeOnTheScreen(); |
| 133 | + |
| 134 | +// ❌ waitFor + getBy* instead of findBy* |
| 135 | +await waitFor(() => { |
| 136 | + expect(screen.getByText('Loaded')).toBeOnTheScreen(); |
| 137 | +}); |
| 138 | + |
| 139 | +// ❌ Side-effect in waitFor |
| 140 | +await waitFor(async () => { |
| 141 | + await fireEvent.press(button); |
| 142 | + expect(screen.getByText('Result')).toBeOnTheScreen(); |
| 143 | +}); |
| 144 | + |
| 145 | +// ❌ accessibility* props instead of ARIA |
| 146 | +<Pressable accessibilityRole="button" accessibilityLabel="Submit" /> |
| 147 | + |
| 148 | +// ❌ Destructuring from render |
| 149 | +const { getByText } = await render(<Component />); |
| 150 | +``` |
| 151 | + |
| 152 | +By following these guidelines, your tests will be more maintainable, accessible, and reliable. |
0 commit comments