React18 Enzyme To Rtl
GitHubOfficialFirst committed by SaravananRajaramanDistributed by GitHub
- Skill
- Guidelines
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a comprehensive guide for migrating legacy Enzyme tests to React Testing Library (RTL) in the context of React 18 upgrades. It emphasizes a fundamental shift in testing philosophy: moving away from testing internal component implementation details (like state, props, and instance methods) toward testing user-facing behavior. The guide includes API mapping, async handling patterns, and strategies for replacing Enzyme-specific concepts with accessible, user-centric RTL queries.
What it does
- Maps common Enzyme APIs like shallow, mount, find, and simulate to their RTL equivalents.
- Provides patterns for handling asynchronous operations and state updates without wrapper.update().
- Explains the shift from implementation-based testing to behavior-driven testing.
- Offers templates for rendering components and wrapping them with necessary providers.
- Defines a priority order for RTL queries to ensure accessible and robust tests.
- Advises on how to replace direct instance method calls with user-event interactions.
When to use it
- When upgrading a React project to version 18 and needing to replace Enzyme.
- When rewriting brittle tests that rely on internal component state or instance methods.
- When you need to understand the correct RTL approach for complex async data fetching.
- When you are unsure how to map specific Enzyme wrappers or find methods to RTL queries.
Advantages
- Promotes the creation of more resilient tests that do not break during refactoring.
- Ensures tests reflect actual user interactions rather than internal code structure.
- Aligns testing practices with modern React 18 standards and accessibility best practices.
- Reduces reliance on implementation-specific testing patterns.
Usage examples
- “How do I replace wrapper.state('count') in my tests?”
- The skill advises against testing state directly. Instead, you should assert that the rendered output reflects the state, such as using screen.getByText('Count: 3') to verify the UI.
- “How do I handle async data loading that used wrapper.update()?”
- The skill recommends using RTL's findBy* queries or waitFor, which automatically handle re-renders and wait for the DOM to update, replacing the need for manual flushing.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/github/react18-enzyme-to-rtl/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip react18-enzyme-to-rtl.zip -d .agents/skills
Documentation by SaravananRajaraman
React 18 Enzyme → RTL Migration
Enzyme has no React 18 adapter and no React 18 support path. All Enzyme tests must be rewritten using React Testing Library.
The Philosophy Shift (Read This First)
Enzyme tests implementation. RTL tests behavior.
// Enzyme: tests that the component has the right internal state
expect(wrapper.state('count')).toBe(3);
expect(wrapper.instance().handleClick).toBeDefined();
expect(wrapper.find('Button').prop('disabled')).toBe(true);
// RTL: tests what the user actually sees and can do
expect(screen.getByText('Count: 3')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled();This is not a 1:1 translation. Enzyme tests that verify internal state or instance methods don't have RTL equivalents - because RTL intentionally doesn't expose internals. Rewrite the test to assert the visible outcome instead.
API Map
For complete before/after code for each Enzyme API, read:
references/enzyme-api-map.md- full mapping: shallow, mount, find, simulate, prop, state, instance, configurereferences/async-patterns.md- waitFor, findBy, act(), Apollo MockedProvider, loading states, error states
Core Rewrite Template
// Every Enzyme test rewrites to this shape:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MyComponent from './MyComponent';
describe('MyComponent', () => {
it('does the thing', async () => {
// 1. Render (replaces shallow/mount)
render(<MyComponent prop="value" />);
// 2. Query (replaces wrapper.find())
const button = screen.getByRole('button', { name: /submit/i });
// 3. Interact (replaces simulate())
await userEvent.setup().click(button);
// 4. Assert on visible output (replaces wrapper.state() / wrapper.prop())
expect(screen.getByText('Submitted!')).toBeInTheDocument();
});
});RTL Query Priority (use in this order)
getByRole- matches accessible roles (button, textbox, heading, checkbox, etc.)getByLabelText- form fields linked to labelsgetByPlaceholderText- input placeholdersgetByText- visible text contentgetByDisplayValue- current value of input/select/textareagetByAltText- image alt textgetByTitle- title attributegetByTestId-data-testidattribute (last resort)
Prefer getByRole over getByTestId. It tests accessibility too.
Wrapping with Providers
// Enzyme with context:
const wrapper = mount(
<ApolloProvider client={client}>
<ThemeProvider theme={theme}>
<MyComponent />
</ThemeProvider>
</ApolloProvider>
);
// RTL equivalent (use your project's customRender or wrap inline):
import { render } from '@testing-library/react';
render(
<MockedProvider mocks={mocks} addTypename={false}>
<ThemeProvider theme={theme}>
<MyComponent />
</ThemeProvider>
</MockedProvider>
);
// Or use the project's customRender helper if it wraps providersFiles
- ATTRIBUTION.mdAdded by AutoGineer895 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md3.6 KiB
- references/async-patterns.md7.3 KiB
- references/enzyme-api-map.md6.1 KiB
references/async-patterns.md
Async Test Patterns - Enzyme → RTL Migration
Reference for rewriting Enzyme async tests to React Testing Library with React 18 compatible patterns.
The Core Problem
Enzyme's async tests typically used one of these approaches:
wrapper.update()after state changessetTimeout/Promise.resolve()to flush microtaskssetImmediateto flush async queuesDirect instance method calls followed by
wrapper.update()
None of these work in RTL. RTL provides waitFor, findBy*, and act instead.
Pattern 1 - wrapper.update() After State Change
Enzyme required wrapper.update() to force a re-render after async state changes.
// Enzyme:
it('loads data', async () => {
const wrapper = mount(<UserList />);
await Promise.resolve(); // flush microtasks
wrapper.update(); // force Enzyme to sync with DOM
expect(wrapper.find('li')).toHaveLength(3);
});// RTL - waitFor handles re-renders automatically:
import { render, screen, waitFor } from '@testing-library/react';
it('loads data', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
});Pattern 2 - Async Action Triggered by User Interaction
// Enzyme:
it('fetches user on button click', async () => {
const wrapper = mount(<UserCard />);
wrapper.find('button').simulate('click');
await new Promise(resolve => setTimeout(resolve, 0));
wrapper.update();
expect(wrapper.find('.user-name').text()).toBe('John Doe');
});// RTL:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('fetches user on button click', async () => {
render(<UserCard />);
await userEvent.setup().click(screen.getByRole('button', { name: /load/i }));
// findBy* auto-waits up to 1000ms (configurable)
expect(await screen.findByText('John Doe')).toBeInTheDocument();
});Pattern 3 - Loading State Assertion
// Enzyme - asserted loading state synchronously then final state after flush:
it('shows loading then result', async () => {
const wrapper = mount(<SearchResults query="react" />);
expect(wrapper.find('.spinner').exists()).toBe(true);
await new Promise(resolve => setTimeout(resolve, 100));
wrapper.update();
expect(wrapper.find('.spinner').exists()).toBe(false);
expect(wrapper.find('.result')).toHaveLength(5);
});// RTL:
it('shows loading then result', async () => {
render(<SearchResults query="react" />);
// Loading state - check it appears
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Or if loading is text:
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for results to appear (loading disappears, results show)
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
expect(screen.getAllByRole('listitem')).toHaveLength(5);
});Pattern 4 - Apollo MockedProvider Async Tests
// Enzyme with Apollo - used to flush with multiple ticks:
it('renders user from query', async () => {
const wrapper = mount(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile id="1" />
</MockedProvider>
);
await new Promise(resolve => setTimeout(resolve, 0)); // flush Apollo queue
wrapper.update();
expect(wrapper.find('.username').text()).toBe('Alice');
});// RTL with Apollo:
import { render, screen, waitFor } from '@testing-library/react';
import { MockedProvider } from '@apollo/client/testing';
it('renders user from query', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile id="1" />
</MockedProvider>
);
// Wait for Apollo to resolve the query
expect(await screen.findByText('Alice')).toBeInTheDocument();
// OR:
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});Apollo loading state in RTL:
it('shows loading then data', async () => {
render(
<MockedProvider mocks={mocks} addTypename={false}>
<UserProfile id="1" />
</MockedProvider>
);
// Apollo loading state - check immediately after render
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Then wait for data
expect(await screen.findByText('Alice')).toBeInTheDocument();
});Pattern 5 - Error State from Async Operation
// Enzyme:
it('shows error on failed fetch', async () => {
server.use(rest.get('/api/user', (req, res, ctx) => res(ctx.status(500))));
const wrapper = mount(<UserCard />);
wrapper.find('button').simulate('click');
await new Promise(resolve => setTimeout(resolve, 0));
wrapper.update();
expect(wrapper.find('.error-message').text()).toContain('Something went wrong');
});// RTL:
it('shows error on failed fetch', async () => {
// (assuming MSW or jest.mock for fetch)
render(<UserCard />);
await userEvent.setup().click(screen.getByRole('button', { name: /load/i }));
expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});Pattern 6 - act() for Manual Async Control
When you need explicit control over async timing (rare with RTL but occasionally needed for class component tests):
// RTL with act() for fine-grained async control:
import { act } from 'react';
it('handles sequential state updates', async () => {
render(<MultiStepForm />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /next/i }));
await Promise.resolve(); // flush microtask queue
});
expect(screen.getByText('Step 2')).toBeInTheDocument();
});RTL Async Query Guide
| Method | Behavior | Use when |
|---|---|---|
getBy* | Synchronous - throws if not found | Element is always present immediately |
queryBy* | Synchronous - returns null if not found | Checking element does NOT exist |
findBy* | Async - waits up to 1000ms, rejects if not found | Element appears asynchronously |
getAllBy* | Synchronous - throws if 0 found | Multiple elements always present |
queryAllBy* | Synchronous - returns [] if none found | Checking count or non-existence |
findAllBy* | Async - waits for elements to appear | Multiple elements appear asynchronously |
waitFor(fn) | Retries fn until no error or timeout | Custom assertion that needs polling |
waitForElementToBeRemoved(el) | Waits until element disappears | Loading states, removals |
Default timeout: 1000ms. Configure globally in jest.config.js:
// Increase timeout for slow CI environments
// jest.config.js
module.exports = {
testEnvironmentOptions: {
asyncUtilTimeout: 3000,
},
};Common Migration Mistakes
// WRONG - mixing async query with sync assertion:
const el = await screen.findByText('Result');
// el is already resolved here - findBy returns the element, not a promise
expect(await el).toBeInTheDocument(); // unnecessary second await
// CORRECT:
const el = await screen.findByText('Result');
expect(el).toBeInTheDocument();
// OR simply:
expect(await screen.findByText('Result')).toBeInTheDocument();// WRONG - using getBy* for elements that appear asynchronously:
fireEvent.click(button);
expect(screen.getByText('Loaded!')).toBeInTheDocument(); // throws before data loads
// CORRECT:
fireEvent.click(button);
expect(await screen.findByText('Loaded!')).toBeInTheDocument(); // waitsRequirements and compatibility
Written for: GitHub Copilot
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- First committed by
SaravananRajaraman
- Publisher
- GitHub (Official)
- Distributed by
- GitHub
- Source
- github/awesome-copilot · skills/react18-enzyme-to-rtl · commit 797337f
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
- Contributors
- SaravananRajaraman
AutoGineer is not affiliated with or endorsed by SaravananRajaraman or GitHub.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download react18-enzyme-to-rtl.zip