name: enzyme-to-rtl description: Migrate Enzyme tests to React Testing Library (RTL). Use when converting shallow/mount enzyme tests to RTL render, replacing enzyme selectors with RTL queries, updating snapshot tests, or when the user mentions enzyme migration, RTL migration, or react-testing-library. disable-model-invocation: true
Enzyme to React Testing Library Migration
Goal
Migrate enzyme tests to @testing-library/react as a 1:1 port — preserve existing test intent without refactoring toward integration-style testing or removing mocks.
Core principles
- Preserve test intent. Do not rewrite test logic or remove mocks. Add mocks where enzyme's shallow rendering previously hid missing providers/contexts.
- Cut dead tests. Enzyme tests component trees, not DOM. Tests that assert on elements never actually rendered in the DOM should be removed with a comment explaining why.
- No new
data-test-subjfor snapshots. Usecontainer.children[0]for root-element snapshots instead of adding a test locator just for snapshotting.
Migration workflow
- Replace enzyme imports with RTL imports.
- Replace
shallow()/mount()withrender(). - Migrate selectors and assertions.
- Update or delete snapshots (
--updateSnapshot). - Run the test and fix any missing mocks/providers that enzyme's shallow rendering was hiding.
Import changes
Before
import { shallow, mount } from 'enzyme';
import { shallowWithIntl, mountWithIntl, findTestSubject } from '@kbn/test-jest-helpers';
After
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
Keep @kbn/test-jest-helpers only for non-enzyme utilities (e.g. nextTick, StubBrowserStorage) and RTL render helpers (renderWithKibanaRenderContext, renderWithI18n, renderWithEuiTheme). Remove findTestSubject — use screen.getByTestId instead.
When the component needs i18n or EUI theme context, prefer the RTL helpers from @kbn/test-jest-helpers instead of manually wrapping in providers:
| Helper | Wraps with |
|---|---|
renderWithKibanaRenderContext(<Comp />) |
EuiThemeProvider + I18nProvider — preferred default for most migrations |
renderWithI18n(<Comp />) |
I18nProvider only |
renderWithEuiTheme(<Comp />) |
EuiThemeProvider only |
These are drop-in replacements for RTL's render() and accept the same arguments (including renderOptions). When the component needs additional providers (Redux, Router, custom contexts), add them as a wrapper option or inline in JSX.
Rendering
| Enzyme | RTL |
|---|---|
shallow(<Comp />) |
render(<Comp />) or renderWithKibanaRenderContext(<Comp />) |
mount(<Comp />) |
render(<Comp />) or renderWithKibanaRenderContext(<Comp />) |
shallowWithIntl(<Comp />) |
renderWithI18n(<Comp />) or renderWithKibanaRenderContext(<Comp />) |
mountWithIntl(<Comp />) |
renderWithI18n(<Comp />) or renderWithKibanaRenderContext(<Comp />) |
Use screen for queries (queries document.body, so portals are reachable too):
render(<MyComponent />);
expect(screen.getByTestId('foo')).toBeInTheDocument();
Selector migration
Test subject selectors
Note: In Kibana Jest setup, RTL uses testIdAttribute: 'data-test-subj', so getByTestId('x') queries data-test-subj="x" (not data-testid).
| Enzyme | RTL |
|---|---|
wrapper.find('[data-test-subj="x"]') |
screen.getByTestId('x') |
findTestSubject(wrapper, 'x') |
screen.getByTestId('x') |
wrapper.find('[data-test-subj="x"]').exists() |
screen.queryByTestId('x') (returns null if absent) |
Nested: wrapper.find('[data-test-subj="a"] [data-test-subj="b"]') |
within(screen.getByTestId('a')).getByTestId('b') |
Make sure findTestSubject matcher behavior is preserved with a getByTestId RegExp matcher when data-test-subj contains multiple tokens.
After interactions that trigger async updates, prefer findByTestId over getByTestId to avoid act() warnings from unresolved updates.
Kibana-specific fallback: subj() from @kbn/test-subj-selector converts test-subject selector syntax to a CSS selector (supports ~/*/>). Prefer RTL queries first; use this when you truly need CSS selection:
import { subj } from '@kbn/test-subj-selector';
const el = container.querySelector(subj('foo > ~bar'));
Note: Some EUI components reuse the same data-test-subj on both a wrapper and the actual control. If getByTestId throws “Found multiple elements”, use getAllByTestId/queryAllByTestId and narrow (or scope with within(...)) instead of switching to brittle CSS selectors.
CSS selectors
| Enzyme | RTL |
|---|---|
wrapper.find('.my-class') |
container.querySelector('.my-class') |
wrapper.find('button') |
container.querySelector('button') or screen.getByRole('button') |
wrapper.findAll('.item') |
container.querySelectorAll('.item') |
Complex traversals
Enzyme chains like wrapper.find('tbody tr td a').at(3).find('div span').at(2).text() become:
const links = container.querySelectorAll('tbody tr td a');
links[3]?.querySelectorAll('div span')[2]?.textContent;
Global selectors (modals, popovers)
For elements rendered outside the component's container (portals), prefer screen / within(document.body):
// Portal content is in document.body, so screen queries can find it
expect(screen.getByTestId('modal-confirm')).toBeInTheDocument();
// Or scope explicitly
within(document.body).getByTestId('modal-confirm');
Targeting the last element in a NodeList
const items = container.querySelectorAll('.item');
expect(Array.from(items).at(-1)).toHaveTextContent('last');
Assertion migration
| Enzyme | RTL |
|---|---|
expect(wrapper).toMatchSnapshot() |
expect(container.children[0]).toMatchSnapshot() |
wrapper.text() |
screen.getByText('...') or element.textContent |
wrapper.find(X).exists() |
If X is a test subject: screen.queryByTestId('x') !== null. If X is a CSS selector string: container.querySelector(X) !== null. If X is a React component (e.g. wrapper.find(EuiCallOut)), assert on DOM output (role/text/test subject) instead of component selectors. |
wrapper.find(X).length |
If X is a CSS selector string: container.querySelectorAll(X).length. If X is a React component (e.g. wrapper.find(EuiCallOut)), assert on DOM output (role/text/test subject) instead of component selectors. |
wrapper.find(X).prop('foo') |
See "Testing component props" below |
wrapper.find(X).props() |
See "Testing component props" below |
wrapper.find(X).simulate('click') |
fireEvent.click(element) |
wrapper.find('input').simulate('change', { target: { value: 'x' } }) |
fireEvent.change(input, { target: { value: 'x' } }) and fireEvent.blur(input) when validation is blur-driven. Use userEvent.type only when per-keystroke behavior matters. |
wrapper.update() |
Not needed — RTL re-queries the DOM automatically. Wrap state updates in act() if needed. |
wrapper.setProps({ foo: 'bar' }) |
Re-render: rerender(<Comp foo="bar" />) |
Testing component props (mock-based pattern)
When tests assert on props passed to child components, mock the child and inspect mock calls:
jest.mock('@elastic/charts', () => {
const actual = jest.requireActual('@elastic/charts');
return {
...actual,
AreaSeries: jest.fn(() => <div data-test-subj="area-series-mock" />),
Axis: jest.fn(() => <div data-test-subj="axis-mock" />),
};
});
const MockedAreaSeries = jest.mocked(AreaSeries);
const MockedAxis = jest.mocked(Axis);
it('passes yScaleType to AreaSeries', () => {
render(<MyChart {...defaultProps} />);
expect(MockedAreaSeries.mock.calls[0][0].yScaleType).toEqual(configs.series.yScaleType);
});
it('passes tickFormat to xAxis', () => {
render(<MyChart {...defaultProps} />);
expect(MockedAxis).toHaveBeenCalledWith(
expect.objectContaining({ tickFormat: mockTimeFormatter }),
expect.anything()
);
});
Use this pattern instead of enzyme's .find(Component).prop('propName'). Clear mocks between tests with jest.clearAllMocks() in beforeEach.
Async & state updates
Wait for a UI boundary with
findBy*(preferred) orwaitFor()when you need a custom assertion. Useact()for explicit timer advancement/flush (e.g.jest.runOnlyPendingTimersAsync()in fake-timer suites) or imperative callbacks that trigger React updates.Replace
wrapper.update()+nextTick()patterns withawait waitFor(...).For promises that resolve in tests, prefer
findByTestId(auto-waits) overgetByTestId+waitFor. Prefer reusing the element returned fromfindBy*instead of re-querying withgetBy*immediately after (re-query only when you expect the DOM to change/replace the element).For elements that should disappear, prefer
waitForElementToBeRemoved(...)(example:await waitForElementToBeRemoved(screen.getByTestId('loading'))).Don't wrap
fireEvent/userEventinact(); instead, perform the interaction and then wait on the relevant UI boundary (see bullets above).Avoid “fixing” failures by increasing
waitFortimeouts; tighten the UI boundary you wait for instead.
Snapshot strategy
shallow+toMatchSnapshot()→render()+expect(container.children[0]).toMatchSnapshot().- Snapshots will be larger since RTL renders full DOM. This is expected — do not add mocks just to shrink snapshots during this migration.
- Delete old
.snapfiles and regenerate:yarn test:jest --updateSnapshot <path>. - Default to
container.children[0]snapshots. When the snapshot is too large or noisy, fall back to targeted assertions instead:
expect(screen.getByTestId('chart-title')).toHaveTextContent('Revenue');
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(screen.queryByTestId('error-banner')).not.toBeInTheDocument();
- Portal-based components (popovers, modals, tooltips, toasts) must use targeted assertions, not snapshots. Their panel content renders outside
containervia portals, socontainer.children[0]only captures the trigger/anchor — not the actual content. Usescreenqueries (which search the full document body) ordocument.querySelectorfor portals:
// Popover: render with isOpen, assert on panel content via screen
render(
<MyPopover isOpen button={<button>Toggle</button>}>
<SelectableList options={options} />
</MyPopover>
);
expect(screen.getByText('Toggle')).toBeInTheDocument();
expect(screen.getByText('Panel Title')).toBeInTheDocument();
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(document.querySelector('[id^="searchInput"]')).toBeInTheDocument();
// Modal: content renders in a portal, use document.querySelector
expect(document.querySelector('[data-test-subj="confirmModal"]')).toBeInTheDocument();
// Tooltip: content only appears on hover
await userEvent.hover(screen.getByText('Hover me'));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent('Tooltip text');
});
Common pitfalls
- Missing providers after removing shallow. Enzyme
shallowdoesn't render children deeply, hiding missing context providers. After switching torender(), add required providers (I18n, Redux, Router, Theme, etc.) or mock them. - EUI component explosions. Some EUI components render complex DOM. Mock them if the test doesn't care about their internals. Prefer shared mocks when available (e.g.
import "@kbn/code-editor-mock/jest_helper"), then plugin-local__mocks__/files (e.g.<pluginRoot>/__mocks__/@elastic/charts/index.tsx), then an inline mock factory as a fallback. When stubbing components to<div>s, add adata-test-subjonly when the test needs a stable query for "mock rendered". - Portal-based elements. Modals, toasts, and popovers render outside
container. Usedocument.querySelectororscreen(which queries the whole document body). Never snapshot these — use targeted assertions instead (see Snapshot strategy). act()warnings. Usually caused by missingawait/ missing async UI boundary after an interaction. Preferawait screen.findBy.../await waitFor(...)over wrapping events inact()(events are already wrapped). Useact()for explicit timer advancement/flush (e.g.jest.runOnlyPendingTimersAsync()in fake-timer suites) or imperative callbacks that trigger React updates. Never use emptyact()blocks (e.g.await act(async () => {})).userEventperformance.userEventsimulates full event sequences and scales poorly in CI (geometrically with interaction count). PreferfireEventfor simple clicks and value changes. ReplaceuserEvent.type(input, 'text')withfireEvent.change(input, { target: { value: 'text' } })+fireEvent.blur(input)unless the test is specifically exercising per-keystroke behavior (e.g. keydown handlers, typeahead suggestions, input masking/formatting, debounce-on-each-char). WhenfireEvent.changecauses act warnings inside portals/overlays, preferuserEvent.pasteoveruserEvent.type— it sets the full value in one step without per-character overhead.- Timer-based tests. Replace
jest.advanceTimersByTimepatterns carefully — RTL'suserEventuses real timers by default. UseuserEvent.setup({ advanceTimers: jest.advanceTimersByTime })when fake timers are needed.
Running tests
yarn test:jest <path-to-test-file> --updateSnapshot
Checklist
- All
enzymeimports removed - All
shallow()/mount()replaced withrender() -
shallowWithIntl/mountWithIntlreplaced withrender()+I18nProviderwrapper -
findTestSubjectreplaced with equivalent selector semantics (exact vs token~=match) - Selectors migrated to RTL queries or
container.querySelector -
.simulate()replaced withuserEventorfireEvent -
.prop()/.props()replaced with mock-based pattern - Snapshots regenerated
- Dead tests (passing only due to shallow rendering) removed
- Test passes:
yarn test:jest <path>