How to Migrate from Enzyme to Testing Library
·PkgPulse Team
TL;DR
The philosophy shift is the hardest part — Testing Library tests behavior, not implementation. With Enzyme, you'd test that a component's internal state changed. With Testing Library, you test what the user sees and interacts with. Most Enzyme tests need to be rewritten (not just syntax-translated) because the approach is fundamentally different. The payoff: tests that don't break on refactoring and that actually verify user experience.
Key Takeaways
- Philosophy shift: implementation details → user behavior
- Query by accessibility:
getByRole,getByText,getByLabelTextvsfind('.class') - No shallow rendering: Testing Library renders the full component tree
- Async by default:
findBy*queries andwaitForfor async operations - userEvent over fireEvent:
userEvent.click()simulates real browser interactions
The Mindset Shift
// ENZYME MINDSET: Test implementation details
test('sets loading state when fetch starts', () => {
const wrapper = shallow(<UserList />);
wrapper.find('Button').simulate('click');
expect(wrapper.state('isLoading')).toBe(true); // ← Testing internal state
expect(wrapper.find('Spinner').exists()).toBe(true);
});
// TESTING LIBRARY MINDSET: Test what the user sees
test('shows loading spinner when fetching users', async () => {
render(<UserList />);
await userEvent.click(screen.getByRole('button', { name: /load users/i }));
expect(screen.getByRole('progressbar')).toBeInTheDocument(); // ← Testing UI
});
// The Testing Library version:
// - Doesn't care if loading is stored in useState or a store
// - Doesn't care about component structure changes
// - Fails only if the user can't see a loading indicator
// - More resilient to refactoring
Query Migration
Enzyme → Testing Library Query Mapping
// ─── FINDING ELEMENTS ─────────────────────────────────────────────────
// Enzyme: find by CSS selector, component class, displayName
wrapper.find('.btn-primary')
wrapper.find(Button)
wrapper.find('[data-testid="submit"]')
// Testing Library: prefer accessible queries
screen.getByRole('button', { name: /submit/i }) // ✅ Best — accessible
screen.getByText('Submit') // ✅ Good — visible text
screen.getByLabelText('Email') // ✅ Good — form labels
screen.getByPlaceholderText('Search...') // ⚠️ Avoid if possible
screen.getByTestId('submit-button') // ⚠️ Last resort
// Priority order (from Testing Library docs):
// 1. getByRole — most accessible
// 2. getByLabelText — form fields
// 3. getByPlaceholderText — fallback
// 4. getByText — visible text
// 5. getByDisplayValue — form values
// 6. getByAltText — images
// 7. getByTitle — title attribute
// 8. getByTestId — when nothing else works
// ─── MULTIPLE ELEMENTS ───────────────────────────────────────────────
// Enzyme:
wrapper.find('li').length;
// Testing Library:
screen.getAllByRole('listitem').length;
Query Variants
// getBy* — throws if not found (use when element should exist)
screen.getByRole('button') // Throws if 0 or 2+ matches
// queryBy* — returns null if not found (use to test absence)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// findBy* — async, waits for element to appear (use for async renders)
const button = await screen.findByRole('button', { name: /save/i });
// All* variants for multiple elements:
screen.getAllByRole('listitem') // throws if none
screen.queryAllByRole('listitem') // returns [] if none
screen.findAllByRole('listitem') // async, returns array
Event Handling Migration
// Install:
npm install -D @testing-library/user-event
// Enzyme:
wrapper.find('button').simulate('click');
wrapper.find('input').simulate('change', { target: { value: 'test' } });
wrapper.find('form').simulate('submit');
// Testing Library — userEvent (preferred):
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(screen.getByRole('button'));
await user.type(screen.getByRole('textbox'), 'test input');
await user.clear(screen.getByRole('textbox'));
await user.selectOptions(screen.getByRole('combobox'), 'Option 2');
await user.keyboard('{Enter}');
// Testing Library — fireEvent (simpler, no async, for synthetic events):
import { fireEvent } from '@testing-library/react';
fireEvent.click(screen.getByRole('button'));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } });
fireEvent.submit(screen.getByRole('form'));
Async Testing Migration
// Enzyme: manually wait or use done callback
test('loads data', (done) => {
const wrapper = mount(<UserList />);
setImmediate(() => {
wrapper.update();
expect(wrapper.find('li')).toHaveLength(3);
done();
});
});
// Testing Library: waitFor + findBy
test('loads and displays users', async () => {
render(<UserList />);
// findBy* waits up to 1000ms for element to appear
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
// For complex async scenarios:
test('shows success message after form submit', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello');
await user.click(screen.getByRole('button', { name: /send/i }));
// Wait for API call to resolve
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Message sent!');
});
});
Mocking in Testing Library
// Enzyme: mock module and check if called
const mockFn = jest.fn();
wrapper.find('Button').props().onClick(mockFn);
expect(mockFn).toHaveBeenCalled();
// Testing Library: mock fetch/API calls
import { server } from './mocks/server'; // MSW server
import { http, HttpResponse } from 'msw';
test('displays users from API', async () => {
// Override API response for this test
server.use(
http.get('/api/users', () =>
HttpResponse.json([{ id: '1', name: 'Alice' }])
)
);
render(<UserList />);
await screen.findByText('Alice');
expect(screen.getByText('Alice')).toBeInTheDocument();
});
Setup: Testing Library + Vitest + MSW
// src/test/setup.ts
import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
});
Compare testing library package health on PkgPulse.
See the live comparison
View vitest vs. jest on PkgPulse →