Recommended practices for React Native Testing Library in 2024

·

5 min read

Recommended practices for React Native Testing Library in 2024

React Native Testing Library has come a long way since I started contributing to it after joining Callstack in the pandemic year of 2020. Together with other contributors, we added many useful features over time, enabling a better developer experience. Therefore, in this post, I want to present the current recommended practices, as many examples found on the Web may be outdated. Here are my top 5 recommendations for using RNTL in 2024.

1. Use the latest version

The first recommendation is to upgrade to the latest RNTL version, 12.4, at the time of writing. Most RNTL downloads are for various v12 versions, but there are still a lot of downloads for v11, v9, and even v7.

RNTL is evolving along with React Native, so ideally, you should be updating them together. Older versions of RNTL may not be fully supporting later versions of React Native. We also fixed many bugs and added useful improvements described in the following paragraphs.

2. Use screen object for queries

Added in v10.1.0.

In the past, you had to capture the output from the render() function and use object destructuring to capture relevant queries:

// Old way
const { getByText, queryByText } = render(<UiToRender />);
const text = getByText(/Hello/i);

This required constant tweaking of render result destructuring, distracting you from writing your tests. Some people used a clever hack to avoid that hassle. Heck, we even have it in the RNTL test base as well.

// Old way 2: the clever hack
const view = render(<UiToRender />);
const text = view.getByText(/Hello/i);

That was somewhat better, but since 2022, you can use the Screen API.

// Modern way
render(<UiToRender />)
const text = screen.getByText(/Hello/i);

Just don’t forget to import it along with other utils from the RNTL package:

import { render, screen } from '@testing-library/react-native';

3. Use semantic queries

Added in v11.2.0 (name option for *ByRole() query).

Traditionally, the most popular queries were getByText() and getByTestId(). They are simple and easy to understand. Each of them has their own problems:

getByText() will find the given text in the rendered element tree, which will always be low-level host Text component. This causes a problem if you are looking, e.g., for a button with a given text and want to make some assertion on it.

<Pressable onPress={...}>
  <Text>Next</Text> // <-- getByText() will return this
</Pressable>

getByTestId(), on the other hand allowed you to find exactly the component you wanted to find, but had nothing with what the user could observe on the screen.

<Pressable testID="button-next" onPress={...}>
  <Text>Next</Text>
</Pressable>

The recommended approach nowadays is to use so called semantic queries, the most important of them being getByRole() with name option:

// Modern way
const button = screen.getByRole("button", { name: "Next" });

This approach addresses the above pain points: it will return a button with the given user-visible text. At the same time, it will guide you in applying proper accessibility props to your components, which will also improve the UX of your app for users with assistive technology like screen readers.

Besides getByRole(), other semantic queries include:

  • getByLabelText()

  • getByPlaceholderText()

  • getByDisplayValue()

You can learn more about the recommended queries in the RNTL documentation.

4. Use Integrated Jest matchers

Added in v12.4.0.

In 2023, together with the RNTL team, we decided it would be a good idea to rewrite legacy Jest Native matches and integrate them into the RNTL codebase. The exact reasoning behind this decision deserves a short post on its own. From the user's perspective, these matches allow writing high-level queries in a way that will have long-term support from RNTL maintainers.

// Old way
expect(getByText("Start")).toBeTruthy(); // Or .not.toBeNull()

It’s worth noting that the getBy* queries are assertions on their own, checking that exactly one element matches the predicate, so toBeTruthy() is essentially a no-op assertion.

// Modern way:
expect(getByText("Start")).toBeOnTheScreen();

The recommended assertion has two benefits. The obvious one is it's more eventing what you are trying to test. The second, less obvious, is that it actually does something useful; it checks that the element is still mounted in the element tree, allowing you to pass previously captured elements that exist as objects but may have been unmounted.

const button = getByRole('button');

// Lots of things happening in between

// This checks if the 'button' didn't get unmounted
expect(button).toBeOnTheScreen();

You can learn more about the integrated Jest matchers in the RNTL documentation.

5. Use User Event

Added in v12.2.0.

For a long time, RNTL recommended Fire Event API for triggering events. It’s a pretty simple API: it will traverse the element tree up, starting with the passed element, and try to find a matching (and enabled) event handler. There are two variants of the API:

// Base variant
fireEvent(element, 'press', eventData...);

// Handy alias for: press, changeText, and scroll events
fireEvent.press(element, eventData...);

There are a couple of problems with Fire Event API:

  1. It triggered only a single event, even when typical event interaction comprises multiple events (e.g., pressIn, press, pressOut).

  2. It required you to explicitly specify the event object or otherwise passed undefined value.

  3. For various reasons, it triggered events on both host and composite elements, which sometimes could result in unexpected behavior.

// Modern way
const user = userEvent.setup(); // Do once at the start of the test

await user.press(element);

There are few crucial differences:

  1. User Event will send several events corresponding to React Native runtime behavior for a given interaction. For press(), that will be pressIn, press and pressOut events. However, for type(), there will be several events for each letter being typed, resulting in a much more realistic testing experience.

  2. User Event will create event data objects for each passed event, which will have the same structure as the RN runtime ones.

  3. User Event will only call events on host components, realistically simulating the RN runtime behavior.

  4. User Event interactions are async functions, as there is a pause after each event, allowing JavaScript microtasks to resolve as they would in the RN runtime.

To remove any uncertainty, the Fire Event API is still supported. It will be kept for a long time, but we recommend using User Event when available for a given interaction, making your tests more realistic.

You can learn more about the User Event API in the RNTL documentation

Summary

While there are many more relevant best practices, using the above recommendations will make your tests more solid and future-proof. Happy testing.