@testing-library/react-native
Version:
Simple and complete React Native testing utilities that encourage good testing practices.
588 lines (422 loc) • 17 kB
Markdown
# Common Mistakes with React Native Testing Library
> **Note:** This guide is adapted from Kent C. Dodds' article ["Common mistakes with React Testing Library"](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) for React Native Testing Library v14. The original article focuses on web React, but the principles apply to React Native as well. This adaptation includes React Native-specific examples, async API usage (v14), and ARIA-compatible accessibility attributes.
React Native Testing Library guiding principle is:
> "The more your tests resemble the way your software is used, the more confidence they can give you."
This guide outlines some common mistakes people make when using React Native Testing Library and how to avoid them.
## Using the wrong query
**Importance: high**
React Native Testing Library provides several query types. Here's the priority order:
1. **Queries that reflect user experience:**
- `getByRole` - most accessible
- `getByLabelText` - accessible label
- `getByPlaceholderText` - `TextInput` placeholder text
- `getByText` - text content
- `getByDisplayValue` - `TextInput` input value
2. **Semantic queries:**
- `getByTestId` - only if nothing else works
Here's an example of using the right query:
```tsx
import { TextInput, View } from 'react-native';
import { render, screen } from '-library/react-native';
test('finds input by label', async () => {
await render(
<View>
<TextInput aria-label="Username" placeholder="Enter username" value="" />
</View>,
);
// ✅ Good - uses accessible label
const input = screen.getByLabelText('Username');
// ✅ Also good - uses placeholder
const inputByPlaceholder = screen.getByPlaceholderText('Enter username');
// ❌ Bad - uses testID when accessible queries work
// const input = screen.getByTestId('username-input');
});
```
## Not using `*ByRole` query most of the time
**Importance: high**
`getByRole` is the most accessible query and should be your first choice. It queries elements by their semantic role:
```tsx
import { Pressable, Text, TextInput, View } from 'react-native';
import { render, screen } from '-library/react-native';
test('uses role queries', async () => {
await render(
<View>
<Pressable role="button">
<Text>Submit</Text>
</Pressable>
<TextInput role="searchbox" aria-label="Search" placeholder="Search..." />
</View>,
);
// ✅ Good - uses role query
const button = screen.getByRole('button', { name: 'Submit' });
const searchbox = screen.getByRole('searchbox', { name: 'Search' });
expect(button).toBeOnTheScreen();
expect(searchbox).toBeOnTheScreen();
});
```
Common roles in React Native include:
- `button` - pressable elements
- `text` - static text
- `header` / `heading` - headers
- `searchbox` - search inputs
- `switch` - toggle switches
- `checkbox` - checkboxes
- `radio` - radio buttons
- And more...
Note: React Native supports both ARIA-compatible (`role`) and legacy (`accessibilityRole`) props. Prefer `role` for consistency with web standards.
## Using the wrong assertion
**Importance: high**
React Native Testing Library provides built-in Jest matchers. Make sure you're using the right ones:
```tsx
import { Pressable, Text } from 'react-native';
import { render, screen } from '-library/react-native';
test('button is disabled', async () => {
await render(
<Pressable role="button" aria-disabled>
<Text>Submit</Text>
</Pressable>,
);
const button = screen.getByRole('button', { name: 'Submit' });
// ✅ Good - uses RNTL matcher
expect(button).toBeDisabled();
// ❌ Bad - doesn't use RNTL matcher
expect(button.props['aria-disabled']).toBe(true);
});
```
Common matchers include:
- `toBeOnTheScreen()` - checks if element is rendered (replaces `toBeInTheDocument()`)
- `toBeDisabled()` - checks if element is disabled
- `toHaveTextContent()` - checks text content
- `toHaveAccessibleName()` - checks accessible name
- And more...
## Using `query*` variants for anything except checking for non-existence
**Importance: high**
Use `queryBy*` only when checking that an element doesn't exist:
```tsx
import { View, Text } from 'react-native';
import { render, screen } from '-library/react-native';
test('checks non-existence', async () => {
await render(
<View>
<Text>Hello</Text>
</View>,
);
// ✅ Good - uses queryBy for non-existence check
expect(screen.queryByText('Goodbye')).not.toBeOnTheScreen();
// ❌ Bad - uses queryBy when element should exist
// const element = screen.queryByText('Hello');
// expect(element).toBeOnTheScreen();
// ✅ Good - uses getBy when element should exist
expect(screen.getByText('Hello')).toBeOnTheScreen();
});
```
## Using `waitFor` to wait for elements that can be queried with `find*`
**Importance: high**
Use `findBy*` queries instead of `waitFor` + `getBy*`:
```tsx
import { View, Text } from 'react-native';
import { render, screen, waitFor } from '-library/react-native';
test('waits for element', async () => {
const Component = () => {
const [show, setShow] = React.useState(false);
React.useEffect(() => {
setTimeout(() => setShow(true), 100);
}, []);
return <View>{show && <Text>Loaded</Text>}</View>;
};
await render(<Component />);
// ✅ Good - uses findBy query
const element = await screen.findByText('Loaded');
expect(element).toBeOnTheScreen();
// ❌ Bad - uses waitFor + getBy
// await waitFor(() => {
// expect(screen.getByText('Loaded')).toBeOnTheScreen();
// });
});
```
## Performing side-effects in `waitFor`
**Importance: high**
Don't perform side-effects in `waitFor` callbacks:
```tsx
import { Pressable, Text, View } from 'react-native';
import { render, screen, waitFor, fireEvent } from '-library/react-native';
test('avoids side effects in waitFor', async () => {
const Component = () => {
const [count, setCount] = React.useState(0);
return (
<View>
<Pressable role="button" onPress={() => setCount(count + 1)}>
<Text>Increment</Text>
</Pressable>
<Text>Count: {count}</Text>
</View>
);
};
await render(<Component />);
const button = screen.getByRole('button');
// ❌ Bad - side effect in waitFor
// await waitFor(async () => {
// await fireEvent.press(button);
// expect(screen.getByText('Count: 1')).toBeOnTheScreen();
// });
// ✅ Good - side effect outside waitFor
await fireEvent.press(button);
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeOnTheScreen();
});
});
```
## Using `container` to query for elements
**Importance: high**
React Native Testing Library provides a `container` object that has a `queryAll` method, but you should avoid using it directly:
```tsx
import { View, Text } from 'react-native';
import { render } from '-library/react-native';
test('finds element incorrectly', async () => {
const { container } = await render(
<View>
<Text testID="message">Hello</Text>
</View>,
);
// ❌ Bad - using container.queryAll directly
const element = container.queryAll((node) => node.props.testID === 'message')[0];
// ✅ Good - use proper queries
// const element = screen.getByTestId('message');
});
```
Instead, use the proper query methods from `screen` or the `render` result. The `container` is a low-level API that you rarely need.
## Passing an empty callback to `waitFor`
**Importance: high**
Don't pass an empty callback to `waitFor`:
```tsx
import { View } from 'react-native';
import { render, waitFor } from '-library/react-native';
test('waits correctly', async () => {
await render(<View testID="test" />);
// ❌ Bad - empty callback
// await waitFor(() => {});
// ✅ Good - meaningful assertion
await waitFor(() => {
expect(screen.getByTestId('test')).toBeOnTheScreen();
});
});
```
## Not using `screen`
**Importance: medium**
You can get all the queries from the `render` result:
```tsx
import { View, Text } from 'react-native';
import { render } from '-library/react-native';
test('renders component', async () => {
const { getByText } = await render(
<View>
<Text>Hello</Text>
</View>,
);
expect(getByText('Hello')).toBeOnTheScreen();
});
```
But you can also get them from the `screen` object:
```tsx
import { View, Text } from 'react-native';
import { render, screen } from '-library/react-native';
test('renders component', async () => {
await render(
<View>
<Text>Hello</Text>
</View>,
);
expect(screen.getByText('Hello')).toBeOnTheScreen();
});
```
Using `screen` has several benefits:
1. You don't need to destructure `getByText` from `render`
2. It's more consistent with the Testing Library ecosystem
## Wrapping things in `act` unnecessarily
**Importance: medium**
React Native Testing Library's `render`, `renderHook`, `userEvent`, and `fireEvent` are already wrapped in `act`, so you don't need to wrap them yourself:
```tsx
import { Pressable, Text, View } from 'react-native';
import { render, fireEvent, screen } from '-library/react-native';
test('updates on press', async () => {
const Component = () => {
const [count, setCount] = React.useState(0);
return (
<View>
<Pressable role="button" onPress={() => setCount(count + 1)}>
<Text>Count: {count}</Text>
</Pressable>
</View>
);
};
await render(<Component />);
const button = screen.getByRole('button');
// ✅ Good - fireEvent is already wrapped in act
await fireEvent.press(button);
expect(screen.getByText('Count: 1')).toBeOnTheScreen();
// ❌ Bad - unnecessary act wrapper
// await act(async () => {
// await fireEvent.press(button);
// });
});
```
## Not using User Event API
**Importance: medium**
`userEvent` provides a more realistic way to simulate user interactions:
```tsx
import { Pressable, Text, TextInput, View } from 'react-native';
import { render, screen, userEvent } from '-library/react-native';
test('uses userEvent', async () => {
const user = userEvent.setup();
const Component = () => {
const [value, setValue] = React.useState('');
return (
<View>
<TextInput aria-label="Name" value={value} onChangeText={setValue} />
<Pressable role="button" onPress={() => setValue('')}>
<Text>Clear</Text>
</Pressable>
</View>
);
};
await render(<Component />);
const input = screen.getByLabelText('Name');
const button = screen.getByRole('button', { name: 'Clear' });
// ✅ Good - uses userEvent for realistic interactions
await user.type(input, 'John');
expect(input).toHaveValue('John');
await user.press(button);
expect(input).toHaveValue('');
});
```
`userEvent` methods are async and must be awaited. Available methods include:
- `press()` - simulates a press
- `longPress()` - simulates long press
- `type()` - simulates typing
- `clear()` - clears text input
- `paste()` - simulates pasting
- `scrollTo()` - simulates scrolling
## Not querying by text
**Importance: medium**
In React Native, text is rendered in `<Text>` components. You should query by the text content that users see:
```tsx
import { Text, View } from 'react-native';
import { render, screen } from '-library/react-native';
test('finds text correctly', async () => {
await render(
<View>
<Text>Hello World</Text>
</View>,
);
// ✅ Good - queries by visible text
expect(screen.getByText('Hello World')).toBeOnTheScreen();
// ❌ Bad - queries by testID when text is available
// expect(screen.getByTestId('greeting')).toBeOnTheScreen();
});
```
## Not using Testing Library ESLint plugins
**Importance: medium**
There's an ESLint plugin for Testing Library: [`eslint-plugin-testing-library`](https://github.com/testing-library/eslint-plugin-testing-library). This plugin can help you avoid common mistakes and will automatically fix your code in many cases.
You can install it with:
```bash
yarn add --dev eslint-plugin-testing-library
```
And configure it in your `eslint.config.js` (flat config):
```js
import testingLibrary from 'eslint-plugin-testing-library';
export default [testingLibrary.configs['flat/react']];
```
Note: Unlike React Testing Library, React Native Testing Library has built-in Jest matchers, so you don't need `eslint-plugin-jest-dom`.
## Using `cleanup`
**Importance: medium**
React Native Testing Library automatically cleans up after each test. You don't need to call `cleanup()` manually unless you're using the `pure` export (which doesn't include automatic cleanup).
If you want to disable automatic cleanup for a specific test, you can use:
```tsx
import { render } from '-library/react-native/pure';
test('does not cleanup', async () => {
// This test won't cleanup automatically
await render(<MyComponent />);
// ... your test
});
```
But in most cases, you don't need to worry about cleanup at all - it's handled automatically.
## Using `get*` variants as assertions
**Importance: low**
`getBy*` queries throw errors when elements aren't found, so they work as assertions. However, for better error messages, you might want to combine them with explicit matchers:
```tsx
import { View, Text } from 'react-native';
import { render, screen } from '-library/react-native';
test('uses getBy as assertion', async () => {
await render(
<View>
<Text>Hello</Text>
</View>,
);
// ✅ Good - getBy throws if not found, so it's an assertion
const element = screen.getByText('Hello');
expect(element).toBeOnTheScreen();
// ✅ Also good - more explicit
expect(screen.getByText('Hello')).toBeOnTheScreen();
// ❌ Bad - redundant assertion
// const element = screen.getByText('Hello');
// expect(element).not.toBeNull(); // getBy already throws if null
});
```
## Having multiple assertions in a single `waitFor` callback
**Importance: low**
Keep `waitFor` callbacks focused on a single assertion:
```tsx
import { View, Text } from 'react-native';
import { render, screen, waitFor } from '-library/react-native';
test('waits with single assertion', async () => {
const Component = () => {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setTimeout(() => setCount(1), 100);
}, []);
return (
<View>
<Text>Count: {count}</Text>
</View>
);
};
await render(<Component />);
// ✅ Good - single assertion per waitFor
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeOnTheScreen();
});
// If you need multiple assertions, do them after waitFor
expect(screen.getByText('Count: 1')).toHaveTextContent('Count: 1');
// ❌ Bad - multiple assertions in waitFor
// await waitFor(() => {
// expect(screen.getByText('Count: 1')).toBeOnTheScreen();
// expect(screen.getByText('Count: 1')).toHaveTextContent('Count: 1');
// });
});
```
## Using `wrapper` as the variable name
**Importance: low**
This is not really a "mistake" per se, but it's a common pattern that can be improved. When you use the `wrapper` option in `render`, you might be tempted to name your wrapper component `Wrapper`:
```tsx
import { View } from 'react-native';
import { render, screen } from '-library/react-native';
test('renders with wrapper', async () => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<View testID="wrapper">{children}</View>
);
await render(<View testID="content">Content</View>, {
wrapper: Wrapper,
});
expect(screen.getByTestId('content')).toBeOnTheScreen();
});
```
This works fine, but it's more conventional to name it something more descriptive like `ThemeProvider` or `AllTheProviders` (if you're wrapping with multiple providers). This makes it clearer what the wrapper is doing.
## Summary
The key principles to remember:
1. **Use the right query** - Prefer `getByRole` as your first choice, use `findBy*` for async elements, and `queryBy*` only for checking non-existence
2. **Use proper assertions** - Use RNTL's built-in matchers (`toBeOnTheScreen()`, `toBeDisabled()`, etc.) instead of asserting on props directly
3. **Handle async operations correctly** - Always `await` `render()`, `renderHook`, `fireEvent`,and `userEvent` methods
4. **Use `waitFor` correctly** - Avoid side-effects in callbacks, use `findBy*` instead when possible, and keep callbacks focused
5. **Follow accessibility best practices** - Prefer ARIA attributes (`role`, `aria-label`) over `accessibility*` props
6. **Organize code well** - Use `screen` over destructuring, prefer `userEvent` over `fireEvent`, and don't use `cleanup()`
By following these principles, your tests will be more maintainable, accessible, and reliable.