Migrating from Enzyme to React Testing Library in 2024

Modern React applications demand deterministic, user-centric validation, and Enzyme can no longer provide it: there is no officially maintained adapter for React 18, so suites built on shallow and mount either fail outright or pass for the wrong reasons against concurrent rendering. This guide is a production-grade migration blueprint for frontend developers, QA engineers, and platform teams moving a real codebase to React Testing Library on React 18/19 with Vitest as the runner. It favors a phased cutover over a risky big-bang rewrite, gives copy-pasteable selector translations, and shows how to keep the pipeline green throughout. Adopting the broader Testing Library best practices is the destination; this page is the route.

Root Cause Analysis

Enzyme’s model is structural: wrapper.find(), wrapper.state(), and wrapper.instance() all reach into React’s internal tree, coupling every test to component implementation rather than to what a user perceives. That coupling is why minor DOM restructuring or a CSS-in-JS refactor cascades into dozens of red tests that describe no real defect. The deeper problem is version support — the enzyme-adapter-react-16 and enzyme-adapter-react-17 packages predate the createRoot API, and Enzyme’s shallow renderer bypasses React 18’s concurrent scheduler. The result is state-reconciliation mismatches during batched updates, false-positive passes, and hydration warnings in CI that have no stable reproduction.

React Testing Library inverts the model. It queries the accessible output of a fully mounted tree, so a test asserts on roles, labels, and text — the things that survive refactors. The migration is therefore not a mechanical find()-to-getBy substitution but a shift from implementation assertions to behavior assertions, which is exactly the discipline that keeps suites stable under Component & Integration Testing Frameworks.

Reproducible Setup

Establish a clean baseline so legacy adapter conflicts are isolated from the new runner. Create a fresh React 18 project and install the Testing Library toolchain alongside Vitest.

npm create vite@latest rtl-migration-baseline -- --template react-ts
cd rtl-migration-baseline
npm install react@18 react-dom@18
npm install -D vitest jsdom @testing-library/react \
  @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react

Configure the runner for a jsdom environment with global cleanup. The deeper runner concerns — module resolution, App Router specifics — are covered in Vitest Configuration & Setup; this baseline is the minimum needed to run a component test.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
  },
});
// vitest.setup.ts
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => cleanup());

Before writing new tests, confirm a single React copy resolves across the tree — npm ls react-dom must report one instance, since duplicates cause the scheduler desynchronization that produces phantom failures.

Implementation

The migration proceeds in four ordered moves: translate selectors, replace simulated events, drop structural snapshots, and enforce the boundary so no new Enzyme creeps in.

1. Translate selectors to semantic queries. Replace structural lookups with role-based queries. wrapper.find('.close-btn') becomes a query by accessible name that survives class churn.

// BEFORE (Enzyme)
import { shallow } from 'enzyme';
const wrapper = shallow(<Modal onClose={mockFn} />);
wrapper.find('.close-btn').simulate('click');
expect(mockFn).toHaveBeenCalled();
// AFTER (React Testing Library + userEvent)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

it('calls onClose when the close button is clicked', async () => {
  const mockFn = vi.fn();
  const user = userEvent.setup();
  render(<Modal onClose={mockFn} />);

  await user.click(screen.getByRole('button', { name: /close/i }));

  expect(mockFn).toHaveBeenCalledOnce();
});

2. Replace simulate with awaited userEvent. Enzyme’s simulate('click') dispatches one synthetic event and skips pointer, focus, and keyboard sequencing. userEvent reproduces the real interaction and must be awaited — skipping the await is the most common source of “not wrapped in act(…)” warnings, a problem dissected in avoiding act warnings.

3. Drop structural snapshots for targeted assertions. Enzyme snapshots serialize the entire virtual DOM, so they break on trivial prop or React.memo changes. Replace them with assertions on the specific output that matters, resolved asynchronously when data arrives.

// BEFORE (Enzyme): brittle whole-tree snapshot
const wrapper = mount(<DataFetcher />);
expect(wrapper).toMatchSnapshot();
// AFTER (React Testing Library): assert only what the user sees
import { render, screen } from '@testing-library/react';

it('displays loaded data', async () => {
  render(<DataFetcher />);
  // findByText retries until the async update lands, flushing act() work
  expect(await screen.findByText(/data loaded/i)).toBeInTheDocument();
  expect(screen.getByRole('list')).toBeInTheDocument();
});

When a component fetches data, point it at simulated handlers with MSW v2 rather than a live backend so the async assertion resolves deterministically.

4. Enforce the boundary with lint. Stop new Enzyme imports during the transition window with a flat-config rule.

// eslint.config.ts
export default [
  {
    rules: {
      'no-restricted-imports': ['error', {
        paths: [
          { name: 'enzyme', message: 'Enzyme is deprecated. Use @testing-library/react.' },
          { name: 'enzyme-adapter-react-16', message: 'No Enzyme adapter supports React 18.' },
        ],
      }],
    },
  },
];

Verification

Run the new suite and confirm it is both green and genuinely deterministic. A correct migration produces output with zero act warnings and no snapshot regeneration noise.

npx vitest run
# ✓ src/components/Modal.test.tsx (1 test) 41ms
# ✓ src/components/DataFetcher.test.tsx (1 test) 88ms
#
# Test Files  2 passed (2)
#      Tests  2 passed (2)

Then run a phased CI matrix that executes both suites side by side during the transition, so the legacy Enzyme run remains a safety net while RTL coverage grows.

jobs:
  test:
    strategy:
      matrix:
        runner: [vitest, jest-legacy]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: 'npm' }
      - run: npm ci
      - run: npm run test:${{ matrix.runner }}

Confirm correctness by checking three signals: the Vitest job passes, no test emits a “not wrapped in act(…)” warning, and deleting a __snapshots__ directory does not break any RTL test (proving you no longer depend on structural snapshots).

Troubleshooting

“Multiple instances of react-dom” / scheduler desync. Symptom: tests pass alone but fail in a full run with reconciliation errors. Diagnosis: npm ls react-dom shows more than one version. Fix: dedupe with npm dedupe (or a package-manager override) so a single react-dom resolves across the tree.

Stale Enzyme adapter artifacts in CI. Symptom: passing tests reference adapters you already removed. Diagnosis: the runner cache persists across matrix builds. Fix: remove enzyme and adapter packages from package.json, run npm prune, and add --cache=false (or clear the Vitest cache) in the CI command for the cutover run.

Act warnings after simulate is replaced. Symptom: console fills with “not wrapped in act(…)” once tests trigger async state. Diagnosis: a userEvent call or a fetch resolves after the assertion without synchronization. Fix: await every userEvent call and replace getBy* with findBy* for post-fetch elements; the full interplay is covered in avoiding act warnings.

FAQ

Can I migrate one test at a time, or must I convert everything at once?

You should migrate incrementally. Run RTL alongside Enzyme in a CI matrix for a transition window, migrate the highest-churn and flakiest tests first, and only remove Enzyme and its adapters once the RTL suite covers the same behaviors. A hard cutover removes the safety net that large codebases need, so the phased rollout is the recommended path.

How do I replace wrapper.state() and wrapper.instance()?

You do not replace them with an equivalent — Testing Library intentionally has no API to read component state or instances. Instead, assert on the visible consequence of that state: the text that appears, the button that becomes enabled, or the element that is removed. If a behavior is only observable through internal state, that is usually a signal the component needs a more accessible output.

Does this guide work with Jest as well as Vitest?

Yes — Testing Library’s API is runner-agnostic, so the queries, userEvent calls, and findBy* patterns are identical. Only the harness differs: import cleanup wiring and matchers the same way, swap vi for jest in mocks, and point your setupFilesAfterEach (Jest) or setupFiles (Vitest) at the same setup module. Vitest is used here as the primary example for its faster cold start.

Why do my snapshots keep breaking after migrating?

Because whole-tree snapshots serialize implementation detail — generated CSS classes, React.memo wrappers, and prop ordering — none of which a user perceives. Replace toMatchSnapshot() on a mounted tree with targeted assertions on roles and text, and reserve snapshots (if any) for small, stable serialized values rather than entire component trees.