How to Migrate from Enzyme to Testing Library
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.
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 →