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.
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.
data-test-subj for snapshots. Use container.children[0] for root-element snapshots instead of adding a test locator just for snapshotting.shallow() / mount() with render().--updateSnapshot).import { shallow, mount } from 'enzyme';
import { shallowWithIntl, mountWithIntl, findTestSubject } from '@kbn/test-jest-helpers';
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.
| 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();
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.
| 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') |
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;
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');
const items = container.querySelectorAll('.item');
expect(Array.from(items).at(-1)).toHaveTextContent('last');
| 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" />) |
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.
Wait for a UI boundary with findBy* (preferred) or waitFor() when you need a custom assertion. Use act() 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 with await waitFor(...).
For promises that resolve in tests, prefer findByTestId (auto-waits) over getByTestId + waitFor. Prefer reusing the element returned from findBy* instead of re-querying with getBy* 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/userEvent in act(); instead, perform the interaction and then wait on the relevant UI boundary (see bullets above).
Avoid “fixing” failures by increasing waitFor timeouts; tighten the UI boundary you wait for instead.
shallow + toMatchSnapshot() → render() + expect(container.children[0]).toMatchSnapshot()..snap files and regenerate: yarn test:jest --updateSnapshot <path>.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();
container via portals, so container.children[0] only captures the trigger/anchor — not the actual content. Use screen queries (which search the full document body) or document.querySelector for 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');
});
shallow doesn't render children deeply, hiding missing context providers. After switching to render(), add required providers (I18n, Redux, Router, Theme, etc.) or mock them.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 a data-test-subj only when the test needs a stable query for "mock rendered".container. Use document.querySelector or screen (which queries the whole document body). Never snapshot these — use targeted assertions instead (see Snapshot strategy).act() warnings. Usually caused by missing await / missing async UI boundary after an interaction. Prefer await screen.findBy... / await waitFor(...) over wrapping events in act() (events are already wrapped). Use act() for explicit timer advancement/flush (e.g. jest.runOnlyPendingTimersAsync() in fake-timer suites) or imperative callbacks that trigger React updates. Never use empty act() blocks (e.g. await act(async () => {})).userEvent performance. userEvent simulates full event sequences and scales poorly in CI (geometrically with interaction count). Prefer fireEvent for simple clicks and value changes. Replace userEvent.type(input, 'text') with fireEvent.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). When fireEvent.change causes act warnings inside portals/overlays, prefer userEvent.paste over userEvent.type — it sets the full value in one step without per-character overhead.jest.advanceTimersByTime patterns carefully — RTL's userEvent uses real timers by default. Use userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) when fake timers are needed.yarn test:jest <path-to-test-file> --updateSnapshot
enzyme imports removedshallow() / mount() replaced with render()shallowWithIntl / mountWithIntl replaced with render() + I18nProvider wrapperfindTestSubject replaced with equivalent selector semantics (exact vs token ~= match)container.querySelector.simulate() replaced with userEvent or fireEvent.prop() / .props() replaced with mock-based patternyarn test:jest <path>