Common Mistakes with React Native Testing Library
Note: This guide is adapted from Kent C. Dodds' article "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:
-
Queries that reflect user experience:
getByRole - most accessible
getByLabelText - accessible label
getByPlaceholderText - TextInput placeholder text
getByText - text content
getByDisplayValue - TextInput input value
-
Semantic queries:
getByTestId - only if nothing else works
Here's an example of using the right query:
import { TextInput, View } from 'react-native';
import { render, screen } from '@testing-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:
import { Pressable, Text, TextInput, View } from 'react-native';
import { render, screen } from '@testing-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:
import { Pressable, Text } from 'react-native';
import { render, screen } from '@testing-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:
import { View, Text } from 'react-native';
import { render, screen } from '@testing-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*:
import { View, Text } from 'react-native';
import { render, screen, waitFor } from '@testing-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();
// });
});
Importance: high
Don't perform side-effects in waitFor callbacks:
import { Pressable, Text, View } from 'react-native';
import { render, screen, waitFor, fireEvent } from '@testing-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:
import { View, Text } from 'react-native';
import { render } from '@testing-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:
import { View } from 'react-native';
import { render, waitFor } from '@testing-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:
import { View, Text } from 'react-native';
import { render } from '@testing-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:
import { View, Text } from 'react-native';
import { render, screen } from '@testing-library/react-native';
test('renders component', async () => {
await render(
<View>
<Text>Hello</Text>
</View>
);
expect(screen.getByText('Hello')).toBeOnTheScreen();
});
Using screen has several benefits:
- You don't need to destructure
getByText from render
- 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:
import { Pressable, Text, View } from 'react-native';
import { render, fireEvent, screen } from '@testing-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:
import { Pressable, Text, TextInput, View } from 'react-native';
import { render, screen, userEvent } from '@testing-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:
import { Text, View } from 'react-native';
import { render, screen } from '@testing-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. This plugin can help you avoid common mistakes and will automatically fix your code in many cases.
You can install it with:
yarn add --dev eslint-plugin-testing-library
And configure it in your eslint.config.js (flat config):
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:
import { render } from '@testing-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:
import { View, Text } from 'react-native';
import { render, screen } from '@testing-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:
import { View, Text } from 'react-native';
import { render, screen, waitFor } from '@testing-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:
import { View } from 'react-native';
import { render, screen } from '@testing-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:
- Use the right query - Prefer
getByRole as your first choice, use findBy* for async elements, and queryBy* only for checking non-existence
- Use proper assertions - Use RNTL's built-in matchers (
toBeOnTheScreen(), toBeDisabled(), etc.) instead of asserting on props directly
- Handle async operations correctly - Always
await render(), renderHook, fireEvent,and userEvent methods
- Use
waitFor correctly - Avoid side-effects in callbacks, use findBy* instead when possible, and keep callbacks focused
- Follow accessibility best practices - Prefer ARIA attributes (
role, aria-label) over accessibility* props
- 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.