From Enzyme to React Native Testing Library: How I Changed My Way of Unit Testing

Authors
Ferran Negre
Software Engineer
@
Callstack

How I went from Enzyme to React Native Testing Library and made my unit test more effective and closer to what the users can interact with.

From Enzyme to React Native Testing Library

I’ve been recently refactoring several React components from classes to functions in order to be able to use Hooks as I really needed to reuse logic between components in order to add a huge new feature into one of my React Native apps.

One of the main pain points of doing so was failing tests. In theory, tests should only break when the functionality is broken but the code was actually doing the same, just written in a different way.

So what was the problem then?

The way I wrote my tests. They were mostly checking the implementation details instead of testing input/output or action/result.

In this article, I’ll go through three examples of unit tests written with Enzyme related to:

and how they have been "cleared up" and refactored to use react-native-testing-library instead.

Note: In case you missed it, React Native Testing Library 2.0 is out now. Read the article and find out what’s new.

Accessing state

Let’s have a look at the following component where you can increase or decrease a number using the right (“+”) and left (“-”) buttons and where you can also press the input and write any number.

accessing state in init testing

There were several tests here and this is how some of them looked with Enzyme (partially):

it('has correct default values if there is no exercise', () => {
const wrapper = shallow(<EditSetsWithControls ... />);
expect(wrapper.state()).toEqual({
weight: defaultWeight.toString(),
reps: defaultReps.toString(),
selectedId: '',
});
...
view raw enzyme.js hosted with ❤ by GitHub

The first issue here was checking <rte-code>wrapper.state()<rte-code>:

  • It is an implementation detail. Does the final user know about the concept of state?
  • As the component is a function now and it uses the useState hook, all the work that Enzyme does under the hood (accessing class instance and so on) won't work anymore.

We want to test here if the user sees what is expected to see, not the “initial state”. Let’s check the actual value (20) that prints on the screen. I was actually doing that in the same test (repetitive) in this way:

const inputControls = wrapper
.find('withTheme(EditSetsInputControls)')
.at(0)
.dive();
expect(weightControls.find('TextInput').props().value).toEqual('20' );
expect(weightControls
.find('Caption')
.children()
.text()
).toEqual('Weight (kgs)');
view raw wrapper.js hosted with ❤ by GitHub

Again, there are several issues here:

  • Because the component was wrapped in a HOC withTheme(EditSetsInputControls), finding it by displayName became trickier and easy to break.
  • at(0) means there are more than one of those inputs, which means if we change the order of them, the test will also break (even that the functionality is working!).
  • dive is because of the HOC (to go one level deeper). So if you remove the HOC or wrap it in another one, it will also break.
  • Basically, for writing such a test, most of the time was spent thinking “How to access those elements?”

So, here is the rewritten version using react-native-testing-library:

const { getByA11yLabel, getByTestId } = _render(<EditSetsWithControls ... />);
const weightInput = getByA11yLabel('Weight input');
const weightLabel = getByTestId('weightInputLabel');
expect(weightInput.props.value).toEqual(defaultWeight.toString());
expect(weightLabel.props.children).toEqual('Weight (kgs)');

This had a wow effect for me. Why?

  • The test is so much easier to read. The library takes care of finding the elements and I had no problem dealing with HOCs or hooks.
  • I am only testing what the user sees / can interact with, like text or accessibility labels.
  • If I changed this component to a class again, this test would not break.

That was the simplest one. Let’s continue checking more examples.

Accessing elements

In the next example, we want to check the value of an element (a button) in a screen where there are multiple buttons (same element/component).

What was the issue here:

it('switches between Add and Update text if a set is selected', () => {
expect(
getActionButtons(wrapper)
.find('withTheme(Button)')
.at(0)
.children()
.text()
).toEqual('Add');
wrapper.setState({ selectedId: exercise.sets[0].id });
expect(
getActionButtons(wrapper)
.find('withTheme(Button)')
.at(0)
.children()
.text()
).toEqual('Update');
});
view raw button_value.js hosted with ❤ by GitHub
  • Using <rte-code>setState<rte-code> directly, which obviously breaks now that is a functional component using hooks + users don’t do that!
  • Again, a lot of time is spent by being able to find the element and also very easy to break as it uses <rte-code>at(position)<rte-code>.

How does it look now, rewritten in React Native Testing Library?

it('switches between Add and Update text if a set is selected', () => {
const { getByA11yLabel } = render(<EditSetsWeightReps exercise={exercise} ... />);
expect(getByA11yLabel('Add new set button').props.children).toBe('Add');
fireEvent.press(getByA11yLabel('editSetItem-1'));
expect(getByA11yLabel('Add new set button').props.children).toBe('Update');
});
view raw rntl.js hosted with ❤ by GitHub

The most important thing in the above example (apart from readability) is that we do what the user would do. We do not mess with the component instance internal state but rather we simulate a press event and check if the result of such is what we expected. Again, this test would continue to work if we switch this component back to a class component.

Let’s continue with a less trivial example.

Simulating events

In the following scenario, we want to test if when we type something in the search input, the displayed data in the list is correctly filtered.

simulating events in unit testing

First, we’ll test it with Enzyme (apologies for the code but it’s going to be messy):

const wrapper = shallow(
<ExercisesScreen ... />
);
const List = wrapper.find('SectionList');
const state = wrapper.state();
const createState = (searchQuery, tagSelection) => ({
...state,
...{
searchQuery,
tagSelection: {
...state.tagSelection,
...tagSelection,
},
},
});
it('filters by search and tag - match', () => {
const newState = createState('Air bike', { core: true });
const data = wrapper
.instance()
._getData(exercises, newState.searchQuery, newState.tagSelection);
expect(data).toEqual([
expect.objectContaining({
id: 'air-bike',
primary: ['abs'],
secondary: [],
}),
]);
});

Can you see how tedious it looks? At first glance, I found it beautiful. But not anymore.

What was the issue here:

  • I spent most of the time on creating an environment where we can test something.
  • We would deal with an implementation detail (component state) and also triggering a component method directly <rte-code>.instance._getData()<rte-code>.
  • This test has another funny thing. Imagine you disable the search input, the test would still pass!

Here is the refactored test with the help of react-native-testing-library:

const { getByA11yLabel } = render(<ExercisesScreen ... />);
fireEvent.press(getByA11yLabel('core'));
fireEvent.changeText(getByA11yLabel('Search exercises bar'), 'Air bike');
const data = getByA11yLabel('Exercises list').props.sections[1].data;
expect(data).toEqual([
expect.objectContaining({
id: 'air-bike',
primary: ['abs'],
secondary: [],
}),
]);

Ridiculously different, right? It’s caused by the fact that accessing internals (like before) is hard. Here we just:

  • simulate a press on a tag (core). Previously it was setting <rte-code>state.tagSelection<rte-code>,
  • simulate that the user is writing a text in the search input (Air bike). Previously it was setting <rte-code>state.searchQuery<rte-code>,
  • check the final output.

Again, this test would not break if we switch the component to a class. Or even better, if we lifted the whole local state to something like Redux, the test would not break as the result/functionality would be the same for the user.

Conclusions

All the aforementioned examples taught me a lot and based on this experience, I prepared a shortlist of the most relevant conclusions that came to my mind:

  • Try to write your tests in a way that they will not break if the implementation is totally different (but the functionality/result will be the same).
  • Try to simulate only what the users can do.
  • Tests shouldn’t be harder to write than the actual code.
  • Hooks can also be tested separately now (functionality) and I also find it useful to extract and test them to its own file (like for longer algorithms or complicated if-else code).
  • react-native-testing-library helps not only because of its API, but it does not let you access internals and enforces you to test in one particular way.
  • You can get used to making your apps more accessible using accessibilityLabel. Then you can use the getByA11yLabel method too. With that, you made your components also capable to be e2e tested by using, for example, Detox. Please keep in mind that the “accessibility label” is no replacement to “test ID”, use it only if it makes sense for your users. See more on Accessibility from the React Native docs.

If I had to wrap the whole article up in one sentence, I’d say that:

Whatever you think was correct in the past, it can always turn out to be wrong after all. This is how I learn.

Happy testing!

And if your team could use help from senior developers, check out our React Native development services – and give us a shout!

Insights

Learn more about Testing

How AI Agents Can Cut Manual QA by 30% — cover

Oct 30Article

How AI Agents Can Cut Manual QA by 30%

Explore an agentic QA workflow targeting 30% less manual effort across mobile, web, and TV, with test selection, failure triage, and validation.

Give QA Agents the Controls They Need With Appduct — cover

Oct 20Article

Give QA Agents the Controls They Need With Appduct

Use Appduct to give AI QA agents access to app functions for setting test conditions, triggering events, and checking results with UI automation.

cover

Oct 13Article

Agentic Mobile Testing With e2e and agent-device

See how e2e uses agent-device for AI-driven iOS and Android tests, combines exact assertions with agent actions, and replays cached steps without model calls.

Who Tests AI-Written Code? Inside TesterArmy With Szymon Rybczak — cover

Oct 12Podcast

Who Tests AI-Written Code? Inside TesterArmy With Szymon Rybczak

Szymon Rybczak explains how TesterArmy tests web and mobile apps with AI agents, creates test plans for pull requests, and fits into QA workflows.

cover

Aug 4Article

Automating Mobile QA With Cloud Agents

Code review and green CI checks do not prove a mobile app still works. Learn how cloud agents, agent-device, and runtime evidence can verify app behavior before merge.

cover

Jul 21Webinar

Behavior Review for AI-Native Delivery

AI can increase code output faster than teams can verify the real app. See why behavior review matters before merge.

cover

Jul 6Article

Introducing Sniffler: Run only the E2E tests your changes can affect

Sniffler maps changed files to affected E2E tests, so teams can avoid running the full suite on every pull request.

cover

Jun 17Talk

Automating Mobile QA with Cloud Agents

Michał Pierzchała will present Automating Mobile QA with Cloud Agents at Ship London on 17 June 2026.

cover

May 13Podcast

How AI Is Changing React Native Development and Testing

Zack Ebenfeld joins React Universe On Air to talk about how AI is changing React Native workflows, testing, QA, and day-to-day engineering work.

cover

Apr 14Article

Agent React DevTools: Give AI Agents Access to React Internals

Agent React DevTools gives AI agents access to the React component tree, state, and performance data, enabling deeper debugging and more reliable QA for React and React Native apps.

cover

Mar 19Live Stream

Agent Device, AI workflows, and mobile testing in practice

A discussion on Agent Device, AI-driven workflows, and testing mobile apps using simulators and accessibility data.

cover

Aug 23Podcast

Introducing E2E Tests to React Native Core

This episode of the React Native Show dives into the recent introduction of End-to-End (E2E) testing in RNTester. Join us as we unpack the thought process, technical considerations, and practical implementation of this new approach to testing in React Native.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.