Testing Library vs Enzyme 2026
TL;DR
Testing Library has won. Enzyme is effectively dead — no React 19 support exists. @testing-library/react (~15M weekly downloads) is the standard for React component testing. Enzyme (~3M downloads, mostly legacy projects) never received an official React 18 adapter and has no React 19 support at all. If you're on React 18+, use Testing Library. The only reason to use Enzyme today is if you're maintaining a legacy codebase on React 16/17 that can't migrate.
Key Takeaways
- @testing-library/react: ~15M weekly downloads — Enzyme: ~3M (npm, March 2026)
- Enzyme has no React 19 support — the unofficial React 18 adapter was the last attempt, no React 19 adapter exists
- Testing Library tests user behavior — Enzyme tests implementation details
- Testing Library is framework-agnostic — works for Vue, Angular, Svelte, DOM
- The philosophy difference matters — Testing Library's approach produces better tests
The Philosophy Difference
This is not just an API preference — it is a fundamental difference in what you believe tests should verify.
Enzyme was built around the idea that testing a React component means inspecting it from the inside out: checking what state it holds, what props it passes to children, whether lifecycle methods are called in the right order, and whether the component tree looks correct structurally. This approach feels natural if you think of components as units of code with observable internal behavior.
Testing Library was built around a different principle articulated by Kent C. Dodds: the more your tests resemble the way your software is used, the more confidence they give you. That means finding elements the way users find them — by label, by role, by visible text — and interacting with them the way users do. It means asserting what appears on screen, not what exists in component state.
The practical consequence shows up whenever you refactor. Consider a form component that submits data and shows a success message. Here is the same test written in both styles:
// Enzyme — testing implementation details
import { shallow } from 'enzyme';
import LoginForm from './LoginForm';
test('submits credentials and shows success', () => {
const wrapper = shallow(<LoginForm />);
// Find inputs by class name or component structure
wrapper.find('input[name="email"]').simulate('change', {
target: { value: 'user@example.com' }
});
wrapper.find('input[name="password"]').simulate('change', {
target: { value: 'secret123' }
});
// Find submit button by component type or class
wrapper.find('.submit-btn').simulate('click');
// Assert on internal state
expect(wrapper.state('submitted')).toBe(true);
expect(wrapper.find('.success-message').text()).toBe('Login successful');
});
// Problem: if you rename the class, change state management, or refactor
// the internal structure, this test breaks — even if the feature still works
// Testing Library — testing user behavior
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('submits credentials and shows success', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Find inputs the way users find them — by label text
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
// Click button the way users do — by its accessible name
await user.click(screen.getByRole('button', { name: /sign in/i }));
// Assert on what the user sees
expect(await screen.findByText('Login successful')).toBeInTheDocument();
});
// This test survives: state management refactors, class name changes,
// internal component restructuring — as long as the user experience is intact
The Testing Library version tests whether the feature works from the user's perspective. The Enzyme version tests whether a particular implementation still exists. The Testing Library test will survive a rewrite from useState to useReducer to Zustand to a server action — none of those changes affect what the user sees.
React 18+ Compatibility
The compatibility question is where Enzyme's fate was decided. When React 18 shipped with concurrent mode, the new root API (createRoot), and changes to the act() batching behavior, Enzyme needed an adapter to work with the new rendering internals.
The official Enzyme team did not ship one. A community contributor (@wojtekmaj) built an unofficial adapter for React 18 that works for basic cases, but it has known issues with concurrent features, and there is no adapter for React 19 at all.
// Enzyme with React 19 — no support at all
// The unofficial @wojtekmaj/enzyme-adapter-react-18 was the last adapter
// No React 19 adapter exists. Enzyme development has completely stalled.
// Even with React 18, the community adapter had issues:
// enzyme.config.js (unofficial React 18 adapter — last available)
import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-18';
Enzyme.configure({ adapter: new Adapter() });
// React 19's changes to refs, context, and the rendering pipeline
// make an Enzyme adapter even less feasible than React 18 was
// Testing Library with React 19 — fully supported
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// @testing-library/react v16+ handles React 19 features seamlessly
// No configuration needed — it wraps renders in act() automatically
// Concurrent mode, Server Components, new ref behavior — all handled
Testing Library's architecture works at the DOM level rather than the React fiber level. It renders the component and then queries the resulting DOM. This means Testing Library adapts to React's internal changes without needing low-level adapter updates for each major version.
Querying Elements
The query API is where Testing Library's philosophy becomes most visible. Every query method corresponds to how a real user or assistive technology finds an element on a page.
// Enzyme — CSS selector and component-based queries
wrapper.find('.submit-button'); // By class name
wrapper.find('button'); // By element type
wrapper.find(SubmitButton); // By component type (implementation detail)
wrapper.find({ 'data-testid': 'submit' }); // By test attribute
wrapper.find('input[type="email"]'); // By attribute selector
wrapper.find('[aria-label="Close"]'); // By ARIA attribute (less common)
// Testing Library — accessibility-first queries
// Priority order (best to least accessible):
screen.getByRole('button', { name: /submit/i }); // BEST — by ARIA role + name
screen.getByLabelText('Email'); // Form inputs by label
screen.getByPlaceholderText('Enter email...'); // Inputs by placeholder
screen.getByText('Submit'); // By visible text content
screen.getByDisplayValue('user@example.com'); // Current form values
screen.getByAltText('User avatar'); // Images by alt text
screen.getByTitle('Close dialog'); // By title attribute
screen.getByTestId('submit-button'); // LAST RESORT — data-testid
The priority order in Testing Library is intentional and documented. Queries that would fail if an element is inaccessible (missing label, missing ARIA role) are preferred over queries that only look at the DOM structure. Using getByRole('button', { name: /submit/i }) fails if the button has no accessible name — which means the test catches an accessibility bug at the same time it tests functionality.
Code Examples: Side-by-Side Login Form
Here is a complete login form test in both libraries to make the API contrast concrete:
// Enzyme version
import React from 'react';
import { mount } from 'enzyme';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('renders email and password fields', () => {
const wrapper = mount(<LoginForm />);
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
});
it('submits form with entered credentials', () => {
const onSubmit = jest.fn();
const wrapper = mount(<LoginForm onSubmit={onSubmit} />);
// Simulate by finding internal input elements
wrapper.find('input[type="email"]').simulate('change', {
target: { name: 'email', value: 'alice@example.com' }
});
wrapper.find('input[type="password"]').simulate('change', {
target: { name: 'password', value: 'password123' }
});
// Find submit by class or type
wrapper.find('button[type="submit"]').simulate('click');
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'password123',
});
});
});
// Testing Library version
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('renders email and password fields', () => {
render(<LoginForm />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('submits form with entered credentials', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
// Type into fields found by label — same as how a user would
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
// Click submit by its accessible role and name
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'password123',
});
});
});
The Testing Library version is slightly longer but notably more robust. If the component's internal structure changes — if the inputs are wrapped in additional divs, if the state management changes, if the button gains an icon — the test keeps passing as long as the labels and button names remain.
Component Testing Examples
// Enzyme — shallow rendering (tests component in isolation)
import { shallow, mount } from 'enzyme';
describe('UserCard', () => {
it('renders user name', () => {
const wrapper = shallow(<UserCard user={{ name: 'Alice', role: 'Admin' }} />);
expect(wrapper.find('.user-name').text()).toBe('Alice');
expect(wrapper.find('.role-badge').text()).toBe('Admin');
});
it('calls onEdit when edit button clicked', () => {
const onEdit = jest.fn();
const wrapper = mount(<UserCard user={{ name: 'Alice' }} onEdit={onEdit} />);
wrapper.find('[data-testid="edit-button"]').simulate('click');
expect(onEdit).toHaveBeenCalledWith({ name: 'Alice' });
});
});
// Testing Library — user-centric testing
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('UserCard', () => {
it('renders user name and role', () => {
render(<UserCard user={{ name: 'Alice', role: 'Admin' }} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('calls onEdit when edit button clicked', async () => {
const onEdit = jest.fn();
render(<UserCard user={{ name: 'Alice' }} onEdit={onEdit} />);
await userEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(onEdit).toHaveBeenCalledWith({ name: 'Alice' });
});
});
Migration from Enzyme
The mechanics of migration are straightforward. The conceptual shift is the harder part: stopping the habit of testing component structure and starting the habit of testing user-visible behavior.
The most common mechanical replacements:
wrapper.find('.class-name')→screen.getByText(...)orscreen.getByRole(...)wrapper.state('fieldName')→ DOM assertion on what the state affects visuallywrapper.setProps({ ... })→rerender(<Component newProp={...} />)wrapper.simulate('click')→await userEvent.click(screen.getByRole(...))shallow(<Component />)→ no equivalent, test with full render instead
// Step 1: Install Testing Library alongside Enzyme
// npm install --save-dev @testing-library/react @testing-library/user-event
// Step 2: Start writing new tests with Testing Library
// Old Enzyme tests still run — both can coexist
// Step 3: Migrate test by test during normal maintenance
// When a component changes, migrate its Enzyme test to Testing Library
// Step 4: Remove Enzyme when all tests migrated
// @testing-library/jest-dom provides the extra matchers:
import '@testing-library/jest-dom';
// Enables: toBeInTheDocument, toBeVisible, toHaveValue, toHaveTextContent, etc.
The biggest hurdle is the shift away from testing state. Many Enzyme tests assert on wrapper.state('isLoading') or wrapper.state('hasError'). These need to be replaced with assertions on what the loading or error state produces in the DOM — a spinner element, an error message, a disabled button. This is a better test because it verifies the UI that users actually experience.
Async Testing
Async behavior is where the ergonomic gap between the two libraries is most pronounced in practice. React apps regularly involve data fetching, animations, timers, and conditional rendering — all of which require tests to wait for state changes before asserting.
Enzyme's async testing requires manual coordination: you call wrapper.update() after state changes, use setImmediate to yield to the event queue, and manually manage act() wrappers to batch React updates. Getting this right is non-trivial, and the patterns differ between class components and function components.
Testing Library's async queries (findBy*) and the waitFor utility handle this automatically. findByText('Alice') will poll the DOM for up to one second by default, retrying until the element appears or the timeout expires. This matches the natural flow of testing an async operation: render, wait for the result to appear, assert.
// Enzyme — async testing is verbose
it('loads user data', async () => {
const wrapper = mount(<UserProfile userId="123" />);
await act(async () => {
await Promise.resolve();
wrapper.update();
});
expect(wrapper.find('.user-name').text()).toBe('Alice');
});
// Testing Library — async testing is ergonomic
it('loads user data', async () => {
render(<UserProfile userId="123" />);
// findBy* waits automatically — no act(), no wrapper.update()
const name = await screen.findByText('Alice');
expect(name).toBeInTheDocument();
});
it('shows error on failed load', async () => {
server.use(rest.get('/api/user/:id', (req, res, ctx) => res(ctx.status(500))));
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
The findBy* pattern works especially well with Mock Service Worker (MSW), which has become the standard for mocking API calls in React tests. The combination of Testing Library + MSW + Vitest covers the full React testing stack without requiring separate async plumbing.
Testing Library Beyond React
One underappreciated advantage of Testing Library is that it is not a React library. The @testing-library organization maintains adapters for Vue, Angular, Svelte, Preact, and plain DOM. The querying API (getByRole, getByLabelText, getByText) is identical across all adapters. A developer who knows Testing Library for React can immediately write tests for a Vue component or a Svelte component without re-learning the testing API.
Enzyme has no equivalent. It is built specifically around React's component model and cannot be extended to other frameworks. If your project uses multiple frontend frameworks — a React SPA with a Svelte component library, or a Vue app with some React islands — Testing Library is the only option that covers all of them.
Package Health
| Package | Weekly Downloads | Maintainer | Status |
|---|---|---|---|
@testing-library/react | ~15M | Kent C. Dodds + community | Active, React 19 supported |
enzyme | ~3M | Airbnb (maintenance mode) | No new development, no React 18/19 adapter |
The download gap between Testing Library and Enzyme reflects new projects starting on Testing Library and legacy projects staying on Enzyme out of inertia rather than choice. Enzyme's downloads are declining slowly as teams migrate or their projects reach end-of-life. The trajectory is clear: Testing Library has won in every measurable dimension — adoption, framework compatibility, React version support, and philosophical alignment with how modern React is written. Enzyme's remaining downloads are a maintenance tail, not a growth signal.
When to Choose
Choose Testing Library when:
- Starting any new React project (always — this is the default choice)
- Using React 18+ or React 19 (Enzyme has no official adapter for either)
- You value tests that survive refactoring
- Accessibility testing matters to your team
- Using Vue, Angular, or Svelte (Testing Library has adapters for all three)
- You want tests that document user-facing behavior rather than component internals
Keep Enzyme when:
- Large legacy codebase on React 16/17 with thousands of Enzyme tests
- Migration cost cannot be justified right now
- You specifically need shallow rendering to isolate components from their children
For full download trend charts and health scores, see the Testing Library vs Enzyme comparison on PkgPulse. If you're also evaluating your test environment setup, the happy-dom vs jsdom comparison covers the DOM simulation layer that Testing Library runs on. You can track Testing Library's adoption curve directly on the Testing Library package page.
See the live comparison
View testing library vs. enzyme on PkgPulse →