Skip to main content

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, getByLabelText vs find('.class')
  • No shallow rendering: Testing Library renders the full component tree
  • Async by default: findBy* queries and waitFor for 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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.