How to Migrate from Enzyme to Testing Library 2026
TL;DR
Testing Library tests user behavior; Enzyme tests implementation details. Most Enzyme tests need rewriting, not just syntax translation — the approach is fundamentally different. With Enzyme, you test internal state (wrapper.state('loading')) and component structure (wrapper.find(ChildComponent)). With Testing Library, you test what a user can see and interact with (screen.getByRole('progressbar')). The payoff: tests that survive refactoring, tests that actually verify user experience, and tests that catch real accessibility regressions.
Key Takeaways
- Enzyme ~5M weekly downloads, declining —
@testing-library/react~20M, dominant - No
wrapper.state()in Testing Library — you cannot inspect internal component state - No shallow rendering — Testing Library always renders the full component tree
- Queries by role/text/label — not by CSS class or component class reference
userEventoverfireEvent— more realistic browser interaction simulationawait waitFor()replaceswrapper.update()for async state changes
The Mindset Shift (The Hard Part)
The hardest part of this migration is not the syntax. It is accepting that you can no longer inspect component internals. Enzyme was designed to let you peer inside a component and verify its state changed. Testing Library deliberately prevents this.
// ENZYME APPROACH: Test implementation details
test('sets loading to true when button is clicked', () => {
const wrapper = shallow(<UserList />);
wrapper.find('Button').simulate('click');
// These simply do not exist in Testing Library:
expect(wrapper.state('isLoading')).toBe(true); // ← internal state
expect(wrapper.instance().fetchUsers).toHaveBeenCalled(); // ← instance method
expect(wrapper.find('Spinner').exists()).toBe(true); // ← component class
});
// TESTING LIBRARY APPROACH: Test what the user sees
test('shows a loading spinner when fetching users', async () => {
render(<UserList />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /load users/i }));
// Test the visible UI, not the internal state
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// OR: expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
The Testing Library version does not care:
- Whether loading state lives in
useState, a Redux store, or a React Query cache - Whether the Spinner is a
<div>or a<Spinner>component - What the component's internal method names are
It only cares that a user clicking the button can see a loading indicator. That is the behavior worth testing.
Installation
# Install Testing Library
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom
# For Vitest (recommended in 2026)
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
# Uninstall Enzyme (once migration is complete)
npm uninstall enzyme enzyme-adapter-react-16 @wojtekmaj/enzyme-adapter-react-17 @cfaester/enzyme-adapter-react-18
// vitest.config.ts (or jest.config.ts)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // Adds .toBeInTheDocument(), .toHaveTextContent(), etc.
Query Migration Reference
The most common migration task is replacing Enzyme's find() calls with Testing Library queries.
Core Query Mapping Table
| Enzyme | Testing Library equivalent | Notes |
|---|---|---|
wrapper.find('.btn-primary') | screen.getByRole('button') | Prefer role over class |
wrapper.find('[data-testid="x"]') | screen.getByTestId('x') | Direct equivalent |
wrapper.find('input[type="email"]') | screen.getByRole('textbox', {name: /email/i}) | Or getByLabelText |
wrapper.find(ComponentName) | No equivalent — use visible output | Find by text, role, or label |
wrapper.text() | element.textContent | Or screen.getByText() |
wrapper.prop('onClick') | userEvent.click(element) | Simulate the interaction |
wrapper.find('li').length | screen.getAllByRole('listitem').length | |
wrapper.find('h1').text() | screen.getByRole('heading', {level: 1}).textContent |
// Full query reference:
// ─── FINDING ELEMENTS ──────────────────────────────────────────────────
screen.getByRole('button', { name: /submit/i }) // Most accessible query
screen.getByRole('textbox', { name: /email/i }) // Form input by label association
screen.getByRole('heading', { level: 2 }) // h2 elements
screen.getByRole('link', { name: /learn more/i }) // Anchor tags
screen.getByRole('combobox') // select elements
screen.getByRole('checkbox', { name: /agree/i }) // Labeled checkboxes
screen.getByRole('listitem') // li elements
screen.getByRole('alert') // Error/warning messages
screen.getByLabelText(/email address/i) // Input by its <label>
screen.getByText(/welcome back/i) // Any element with this text
screen.getByPlaceholderText(/search.../i) // Input by placeholder
screen.getByTestId('error-banner') // data-testid (last resort)
// ─── QUERY VARIANTS ────────────────────────────────────────────────────
// getBy* → throws if not found or if 2+ found (element must exist)
// queryBy* → returns null if not found (use to assert absence)
// findBy* → async, waits up to 1000ms (use for async renders)
// getAllBy* → returns array, throws if none found
// queryAllBy* → returns array or [] if none found
// findAllBy* → async array
// Examples:
const button = screen.getByRole('button', { name: /save/i });
expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // Assert absence
const items = await screen.findAllByRole('listitem'); // Wait for render
Query Priority (From Testing Library Docs)
Follow this priority to write the most accessible and resilient tests:
getByRole— mirrors how assistive technologies navigate pagesgetByLabelText— form fields always have labels; test with themgetByPlaceholderText— fallback for form inputsgetByText— visible text contentgetByDisplayValue— current value of a form elementgetByAltText— images with alt textgetByTitle— title attributegetByTestId— when nothing else works; adddata-testidto component
Event Handling Migration
Enzyme's simulate() was a synthetic approximation of browser events. Testing Library's userEvent fires actual browser events in the correct sequence (pointerdown, mousedown, focus, mouseup, pointerup, click, etc.).
import userEvent from '@testing-library/user-event';
// ENZYME:
wrapper.find('button').simulate('click');
wrapper.find('input').simulate('change', { target: { value: 'new text' } });
wrapper.find('input').simulate('focus');
wrapper.find('form').simulate('submit');
// TESTING LIBRARY — always await userEvent calls:
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /submit/i }));
await user.type(screen.getByRole('textbox', { name: /name/i }), 'Alice');
await user.clear(screen.getByRole('textbox'));
await user.selectOptions(screen.getByRole('combobox'), 'Option B');
await user.keyboard('{Enter}');
await user.tab(); // Tab to next focusable element
// For checkboxes:
await user.click(screen.getByRole('checkbox', { name: /agree to terms/i }));
// fireEvent is synchronous — use for simple synthetic events when userEvent is overkill:
import { fireEvent } from '@testing-library/react';
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } });
The userEvent.setup() call returns an instance that maintains pointer state across interactions, making multi-step interactions accurate.
Full Migration Examples
Example 1: Simple Form Submission
// BEFORE (Enzyme):
test('submits form with correct data', () => {
const onSubmit = jest.fn();
const wrapper = mount(<LoginForm onSubmit={onSubmit} />);
wrapper.find('input[name="email"]').simulate('change', {
target: { value: 'alice@example.com' }
});
wrapper.find('input[name="password"]').simulate('change', {
target: { value: 'secret123' }
});
wrapper.find('form').simulate('submit');
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
// AFTER (Testing Library):
test('submits form with correct data', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /log in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
Example 2: Async Data Loading
// BEFORE (Enzyme):
test('loads and displays users', (done) => {
const wrapper = mount(<UserList />);
setImmediate(() => {
wrapper.update();
expect(wrapper.find('li')).toHaveLength(3);
done();
});
});
// AFTER (Testing Library):
test('loads and displays users', async () => {
render(<UserList />);
// findAllByRole waits up to 1000ms for items to appear
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
Example 3: Conditional Rendering
// BEFORE (Enzyme):
test('shows error message on failed login', async () => {
const wrapper = mount(<LoginForm />);
wrapper.find('form').simulate('submit');
await new Promise(r => setTimeout(r, 100));
wrapper.update();
expect(wrapper.find('.error-message').text()).toBe('Invalid credentials');
});
// AFTER (Testing Library):
test('shows error message on failed login', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /log in/i }));
// waitFor retries the assertion until it passes or times out
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});
});
Example 4: Component With Context
// Testing Library — wrapping with providers
import { render } from '@testing-library/react';
function renderWithProviders(ui: React.ReactElement) {
return render(
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>
{ui}
</AuthProvider>
</QueryClientProvider>
);
}
test('shows user name when authenticated', async () => {
renderWithProviders(<Header />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
Async Patterns: Replacing wrapper.update()
Enzyme's wrapper.update() forces a re-render after state changes. Testing Library has purpose-built async utilities:
// waitFor — retry assertion until it passes (up to 1000ms by default)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Saved!');
});
// waitForElementToBeRemoved — wait for an element to disappear
await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
// findBy* — shorthand for getBy* wrapped in waitFor
const heading = await screen.findByRole('heading', { name: /dashboard/i });
Handling Snapshots
Enzyme snapshots with enzyme-to-json were often brittle because they serialized the full component tree including internals. Replace them with more targeted assertions:
// BEFORE (Enzyme + enzyme-to-json):
test('renders correctly', () => {
const wrapper = shallow(<Card title="Hello" />);
expect(wrapper).toMatchSnapshot();
});
// AFTER — Option A: still use snapshots, but from rendered HTML
test('renders correctly', () => {
const { container } = render(<Card title="Hello" />);
expect(container.firstChild).toMatchSnapshot();
});
// AFTER — Option B (preferred): assert specific visible content
test('renders the card title', () => {
render(<Card title="Hello" />);
expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument();
});
Option B is more resilient: it will not fail when you change internal markup, only when the visible output changes.
Why Testing Library Won
The testing debate between Enzyme and Testing Library is settled in 2026, but understanding why matters for the migration. The answer is not that Enzyme was technically inferior. The answer is that Enzyme was optimized for a different definition of what a "good test" means.
Enzyme's philosophy came from a time when component designs were unstable and teams wanted fine-grained control. The shallow() renderer — which renders one component without its children — was explicitly designed for fast, isolated unit testing, treating React components the same way you'd mock dependencies in backend unit tests. This made tests fast and predictable, and for a certain style of development it worked well.
The problem is that isolation works against you in UI development. A shallow-rendered component that passes all its tests tells you nothing about whether the rendered output is useful to a user. You can have a perfect wrapper.state() assertion and ship a button that's invisible, unlabeled, and unreachable by keyboard navigation.
Kent C. Dodds formalized the alternative with the Testing Trophy model: more integration tests, fewer pure unit tests. The key insight is that integration tests — those that render real trees and test visible output — catch more real bugs per test than isolated unit tests. A test that finds an element by its ARIA role is simultaneously verifying that the element has the correct accessibility role, covering two concerns with one assertion: functionality and accessibility compliance.
React's own team shifted to this philosophy. The ecosystem followed. By 2024, Enzyme's React 18 adapter was community-maintained and lagging. By 2026, most component libraries have dropped Enzyme from their test suites. The philosophy won; the download numbers followed: Testing Library's @testing-library/react now logs four times Enzyme's weekly downloads and the gap is widening.
Migration Strategy: File-by-File
The "big bang" migration — convert all Enzyme tests to Testing Library at once, then delete Enzyme — sounds clean but is rarely practical for codebases with hundreds of tests. The better approach is incremental: run both libraries in parallel while migrating one file at a time.
Both Enzyme and Testing Library coexist in the same test suite without conflicts. Install Testing Library alongside Enzyme rather than replacing it immediately. Mark each file migrated when complete and keep CI passing throughout. The migration becomes a rolling background task rather than a high-risk big-bang event.
The order of migration matters. Start with the simplest test files — components with no async behavior, no context providers, and no complex event handling. These give you practice with the API before you encounter harder cases. After ten files done manually, the patterns become automatic and the harder files go faster.
Next, tackle files associated with features you're actively working on. When you touch a component for a feature or bug fix, convert its tests in the same PR. This amortizes migration cost into existing work rather than making it a separate initiative with its own planning burden.
Save the most complex files for last: files with many mocked modules, files with complex async patterns, and files that test class components with lifecycle methods. These require more significant rewrites — not just syntax conversion but rethinking what the tests are actually verifying. Some Enzyme tests that test implementation details should not be rewritten in Testing Library at all; they should be deleted, because the behavior they tested was never meaningful to a user.
Set a firm deadline for the migration to complete. Without one, the incremental approach stalls once the easy wins are done. A reasonable timeline for a mid-sized codebase of 100–300 test files is one to three months alongside normal feature work.
Accessibility as a Testing Benefit
The shift to getByRole queries has an underappreciated side effect: it drives accessibility improvements in the components you're testing.
When you write screen.getByRole('button', { name: /submit/i }), you're writing a test that will fail if the button has no accessible name. Accessible name can come from text content, an aria-label, an aria-labelledby reference, or a <label> association. If your button contains only an icon and no accessible name, the test fails — and now your CI pipeline is catching missing accessibility attributes at test time, not in an audit after release.
The priority order of Testing Library queries (getByRole → getByLabelText → getByPlaceholderText → getByText → getByTestId) is also an accessibility hierarchy. Queries that require good semantic HTML are at the top. getByTestId — which requires you to add a data-testid attribute with no accessibility benefit — is the last resort. This nudges developers toward markup that screen readers can navigate without requiring a separate accessibility review step.
Teams that complete the Enzyme-to-Testing Library migration consistently report finding accessibility issues they did not know existed: unlabeled form inputs, interactive elements with wrong ARIA roles, images without alt text. Testing Library's queries surface these during development rather than in a post-release audit. For teams targeting WCAG compliance, this effectively builds a partial compliance check into normal test workflow at no additional tooling cost.
Common Pitfalls to Avoid
Overusing getByTestId. Every getByTestId call is a sign that the element lacks a proper semantic identity. Use it as a last resort for elements that genuinely have no better identifier, not as the first query you reach for.
Forgetting to await userEvent calls. userEvent returns Promises. await user.click(button) fires the complete event sequence. Omitting await causes non-deterministic test failures that are difficult to diagnose because the test may pass or fail depending on timing.
Writing act() wrappers manually. If you find yourself wrapping things in act() manually, that is usually a sign that you should be using waitFor() or findBy* queries instead. Testing Library wraps renders and user events in act() automatically. Manual act() is only needed for code that triggers state updates outside of Testing Library's utilities, which is rare in practice.
Testing too much in a single test. Enzyme tests often verified many things in one it() block because each render was expensive to set up. In Testing Library, the setup cost is similar, but tests are easier to read and diagnose when they're focused. Prefer one test per user interaction or rendered state. Longer tests are harder to read when they fail and harder to maintain when requirements change.
Not cleaning up between tests. Testing Library automatically calls cleanup() after each test via the @testing-library/react auto-cleanup feature — but only if you're using Jest or Vitest globals. If you're running tests in a non-standard environment, you may need to call cleanup() in afterEach. Uncleaned renders between tests cause state to bleed across test cases in ways that are very hard to debug.
Package Health
| Package | Weekly Downloads | Trend |
|---|---|---|
enzyme | ~5M | Declining |
enzyme-adapter-react-18 | ~800K | Unmaintained |
@testing-library/react | ~20M | Growing |
@testing-library/user-event | ~18M | Growing |
@testing-library/jest-dom | ~17M | Growing |
Enzyme's React 18 adapter is community-maintained and not officially supported. New projects in 2026 should use Testing Library from the start.
When to Migrate vs Maintain
Migrate immediately if:
- Starting a new project — use Testing Library from day one
- Your test suite is small (under 50 tests)
- You are upgrading from React 17 to React 18 (Enzyme's adapter is community-maintained)
- Your tests are already breaking on refactors — that signals they are testing implementation
Take it one file at a time if:
- You have hundreds of existing Enzyme tests
- Tests are currently green and providing value
- Migrate file-by-file as you touch components for other reasons
Internal Links
See the live comparison
View vitest vs. jest on PkgPulse →