Skip to main content

How to Migrate from Enzyme to Testing Library

·PkgPulse Team
0

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
  • userEvent over fireEvent — more realistic browser interaction simulation
  • await waitFor() replaces wrapper.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

EnzymeTesting Library equivalentNotes
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 outputFind by text, role, or label
wrapper.text()element.textContentOr screen.getByText()
wrapper.prop('onClick')userEvent.click(element)Simulate the interaction
wrapper.find('li').lengthscreen.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:

  1. getByRole — mirrors how assistive technologies navigate pages
  2. getByLabelText — form fields always have labels; test with them
  3. getByPlaceholderText — fallback for form inputs
  4. getByText — visible text content
  5. getByDisplayValue — current value of a form element
  6. getByAltText — images with alt text
  7. getByTitle — title attribute
  8. getByTestId — when nothing else works; add data-testid to 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

PackageWeekly DownloadsTrend
enzyme~5MDeclining
enzyme-adapter-react-18~800KUnmaintained
@testing-library/react~20MGrowing
@testing-library/user-event~18MGrowing
@testing-library/jest-dom~17MGrowing

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

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.