Migrating from Enzyme to Modern React Testing Libraries

React testing practices are shifting. Teams are moving away from Enzyme and adopting tools like React Testing Library (RTL). This change is not just about tooling; it’s about testing philosophy, developer productivity, and future readiness.
Enzyme gave developers deep control over component internals. That control came at a cost. As React evolved, with hooks, suspense, concurrent rendering, and stricter architectural boundaries, Enzyme failed to keep up. It started breaking. It discouraged tests that focused on what the user sees or does. It encouraged tests that poked through implementation details.
This guide walks you through the “why” and the “how” of the migration. Why does Enzyme no longer fit with modern React? How does RTL align with current best practices? How to start replacing your old tests without breaking everything.
What is Enzyme and Why It Was Widely Used
Enzyme was built by Airbnb to solve a real problem, testing React components in a controlled way. It became the go-to library for years, especially during the React 15 and React 16 era.
Here’s what made Enzyme popular:
- Shallow rendering: Enzyme allows you to isolate components without rendering their children. This helped unit test pure components in isolation.
- DOM traversal: You could use familiar jQuery-like selectors (.find(), .children()) to drill into component trees.
- Direct access to internals: You could inspect and assert props, state, lifecycle methods, and instance methods.
- Flexible control: The API supported fine-tuned control over mounting strategies, shallow, full DOM, and static rendering.
- Broad community usage: Enzyme was baked into tutorials, codebases, and even starter templates.
But Enzyme relied too heavily on React’s private APIs. When React changed its internals in v17 and later in v18, Enzyme couldn’t adapt. No official Enzyme adapter exists for React 18. As of today, the library remains unmaintained, and its ecosystem is frozen.
Testing with Enzyme in modern React apps often requires workarounds, forks, or brittle configurations. It’s a sign to move on.
Why Modern React Abandoned Enzyme
React evolved. Enzyme didn’t.
Enzyme’s development has stalled. Its last release, Enzyme React, was over two years ago, and there’s no official support for React 18. Many developers have resorted to forks or community patches just to keep Enzyme working, an obvious red flag in production workflows.
React’s rendering logic also changed under the hood. With concurrent rendering, suspense, and stricter component boundaries, tools like Enzyme, built on internal, unofficial APIs, began to break. Adapter support became unreliable, and shallow rendering stopped behaving predictably.
Modern features such as hooks, context, and concurrent UI patterns made the gap even wider. Testing hooks in Enzyme often required clunky workarounds, such as mount() hacks or third-party helpers. Context tests became harder to maintain, and Suspense was essentially unsupported.
Enzyme’s core issue lies in its philosophy. It encourages testing how the component works, props, internal state, or method calls. An Enzyme test can reveal these details. That approach creates fragile tests. As components evolve, even small refactors can break dozens of tests that shouldn’t have cared about internals.
Modern testing tools flipped the model. They test what the user sees and does, not how the component is wired. That shift reflects React’s modern direction, strong encapsulation, decoupled logic, and more declarative design.
Enzyme vs React Testing Library: A Practical Comparison
Let’s compare the two libraries based on real-world developer concerns:
Feature | Enzyme | React Testing Library (RTL) |
Rendering Strategy | Shallow, Full, Static | Full DOM only |
Component Access | Direct access to props, state, and refs | No access to internals |
Hook Testing | Requires workarounds | Handled via full component rendering. |
Maintenance | Unmaintained | Actively maintained |
Ecosystem Alignment | Legacy patterns | Modern React (hooks, Suspense, etc.) |
Testing Philosophy | Implementation-focused | Behavior-focused (user-first) |
Real-World Example
Here’s how you might query an element in both libraries:
Enzyme (implementation-focused):
const wrapper = shallow(<Greeting name="Ainan" />);
expect(wrapper.find('h1').text()).toEqual('Hello, Ainan');
React Testing Library (behavior-focused):
render(<Greeting name="Ainan" />);
expect(screen.getByText('Hello, Ainan')).toBeInTheDocument();
Impact during refactors:
In the Enzyme version, changing the h1 to a p or wrapping it in a div breaks the test, even if the UI and behavior remain correct. Consider scenarios using React Testing Library with Downshift; the experience can be quite different. In the RTL version, as long as the text remains visible, the test passes. That flexibility makes tests more stable and aligned with user expectations.
Behavior-focused tests tend to outlive component refactors. They are easier to trust, require fewer updates, and better reflect what actually matters to the user.
Migration Strategy: Moving from Enzyme to RTL
We’ll break the migration process into five focused steps, starting from uninstalling Enzyme and ending with async and shared utility migration. Each step includes practical code examples and tips.

Step 1: Install & Set Up React Testing Library
Start by removing Enzyme and its adapters from your project.
npm uninstall enzyme enzyme-adapter-react-16 enzyme-to-json
Now install the React Testing Library (RTL) and its companion libraries:
npm install @testing-library/react @testing-library/jest-dom
Update your testing environment by modifying or creating a setupTests.js file (typically configured via jest.config.js or package.json).
// setupTests.js
import '@testing-library/jest-dom';
This setup brings in RTL’s custom matchers like .toBeInTheDocument() and .toHaveTextContent() for more readable assertions.
Step 2: Migrate a Basic Component Test
Let’s say you have a button test in Enzyme:
Enzyme:
import { shallow } from 'enzyme';
import Button from './Button';
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
const wrapper = shallow(<Button onClick={handleClick} />);
wrapper.find('button').simulate('click');
expect(handleClick).toHaveBeenCalled();
});
React Testing Library:
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} />);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalled();
});
Key differences:
- RTL uses render() to mount the component into a virtual DOM.
- screen.getByRole() queries the button as a user would.
- fireEvent.click() simulates a real DOM event.
Step 3: Migrate Tests with Hooks, Context, or Effects
Testing components that use useContext, useEffect, or custom hooks in Enzyme often required full mount() rendering and manual provider setup. RTL simplifies this with native support for context.
Example with Context:
import { render, screen } from '@testing-library/react';
import ThemeContext from './ThemeContext';
import ThemedComponent from './ThemedComponent';
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
);
it('shows correct theme', () => {
render(<ThemedComponent />, { wrapper });
expect(screen.getByText(/dark/i)).toBeInTheDocument();
});
Tips:
- Use the wrapper option in render() to inject context, Redux, or custom providers.
- For global providers, abstract this wrapper and reuse it across tests.
Step 4: Handle Async Tests
Enzyme typically uses manual setTimeout() or await workarounds for testing async UI states. RTL simplifies async testing using waitFor, findByRole, and related utilities.
Example with a Loading Spinner:
import { render, screen } from '@testing-library/react';
import fetchMock from 'jest-fetch-mock';
import UserList from './UserList';
beforeEach(() => fetchMock.resetMocks());
it('displays users after loading', async () => {
fetchMock.mockResponseOnce(JSON.stringify([{ name: 'Alice' }]));
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const userItem = await screen.findByText(/Alice/);
expect(userItem).toBeInTheDocument();
});
Highlights:
- findByText() waits automatically.
- waitFor() can be used for complex async side effects.
- RTL discourages arbitrary waits and promotes user-observable effects.
Step 5: Replace Global Helpers
Many codebases using Enzyme also rely on global test helpers and custom assertions. During migration, refactor those utilities to work with RTL.
Replace Enzyme Matchers:
Instead of:
expect(wrapper.exists('.some-class')).toBe(true);
Use:
expect(screen.getByText('Some Label')).toBeInTheDocument();
Add this to your test environment to unlock RTL matchers:
import '@testing-library/jest-dom';
Common matcher replacements:

Next step: Clean up or rewrite any global Enzyme helpers (e.g., mountWithTheme) to use RTL’s render logic and wrapper configuration.
Use Cases & Examples
Real-world components like forms and Redux-connected views often reveal the practical challenges (and benefits) of moving away from Enzyme. These examples focus on translating Enzyme’s imperative style into React Testing Library’s behavior-first approach.
Migrating a Form Component
Form tests in Enzyme often relied on .simulate() to trigger synthetic events. RTL promotes interacting with forms like a user would, by firing real browser events or using userEvent utilities.
Enzyme:
React Testing Library:
import { shallow } from 'enzyme';
import LoginForm from './LoginForm';
it('submits the form with entered values', () => {
const onSubmit = jest.fn();
const wrapper = shallow(<LoginForm onSubmit={onSubmit} />);
wrapper.find('input[name="email"]').simulate('change', {
target: { name: 'email', value: 'user@example.com' }
});
wrapper.find('form').simulate('submit', { preventDefault: () => {} });
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com'
});
});
React Testing Library:
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';
it('submits the form with entered values', () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
const input = screen.getByLabelText(/email/i);
await userEvent.type(input, 'user@example.com');
const form = screen.getByRole('form');
fireEvent.submit(form);
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com'
});
});
Key Notes:
- Prefer userEvent.type() or fireEvent.input() for text inputs.
- Always use accessible queries, such as getByLabelText() or getByRole().
- Avoid targeting DOM nodes via selectors or classes, and mimic user intent.
Migrating a Redux-connected Component
Redux-connected components were typically tested with Enzyme’s mount() function and manual wrapping using the <Provider> component. RTL makes this cleaner by allowing custom render helpers and scoped store mocks.
Enzyme:
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import Dashboard from './Dashboard';
const mockStore = configureStore();
const store = mockStore({ user: { name: 'Alice' } });
it('renders the dashboard', () => {
const wrapper = mount(
<Provider store={store}>
<Dashboard />
</Provider>
);
expect(wrapper.find('.username').text()).toContain('Alice');
});
React Testing Library:
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../redux/userSlice';
import Dashboard from './Dashboard';
const customRender = (ui, { preloadedState } = {}) => {
const store = configureStore({ reducer: { user: userReducer }, preloadedState });
return render(<Provider store={store}>{ui}</Provider>);
};
it('renders the dashboard with user name', () => {
customRender(<Dashboard />, {
preloadedState: { user: { name: 'Alice' } }
});
expect(screen.getByText(/Alice/)).toBeInTheDocument();
});
Migration Tips:
- Wrap Redux logic into a customRender() utility for consistency.
- Use @reduxjs/toolkit’s configureStore() to avoid deprecated APIs.
- Mock dispatch using jest.fn() when testing effects or actions.
Conclusion
Migrating from Enzyme to React Testing Library isn’t just about swapping syntax. It’s about shifting how you think about testing React components, from focusing on how components are built to how they behave.
RTL aligns better with modern React practices, hooks, suspense, context, and functional components. It promotes accessible querying, resilient test design, and a developer experience that mirrors real user interaction.
This migration may require upfront effort, especially for large test suites, but it pays off in terms of stability, maintainability, and forward compatibility.
Explore how Aviator’s agents can take your workflows even further: aviator.co/agents
Frequently Asked Questions
Q: Can I still use both Enzyme React 18?
Not reliably. Enzyme hasn’t been updated to support React 18. Its last official release was over two years ago, and core maintainers have moved on.
Q: What is Enzyme in React testing?
Enzyme is a JavaScript testing utility designed for React. It makes component-level testing easier by offering methods to traverse, manipulate, and simulate events on component trees. While powerful, Enzyme relies heavily on React’s internal APIs, which limits compatibility with newer versions.
Q: What is the difference between Enzyme and React Testing Library?
React Testing Library focuses on what the user sees and does, not internal component logic. It encourages writing tests that are more resilient to changes in implementation.
Use RTL when you care about behavior and accessibility.
Use Enzyme (if still supported) when you need direct access to state or methods, but expect more brittle tests.