From ccbcef88fc36f672925d7b77597ff055833195fa Mon Sep 17 00:00:00 2001 From: blaipr Date: Thu, 11 Jun 2026 12:12:33 +0200 Subject: [PATCH 1/2] Add React Testing Library migration infrastructure and first conversions Groundwork for retiring enzyme (dead upstream, no adapter beyond React 17 - it is the hard blocker for React 18+): - testUtils/rtlContexts.js: renderWithContexts(), the RTL counterpart of mountWithContexts. Same context defaults and override mechanism (i18n, session, config, router with optional custom history), plus a ready userEvent instance and the history object in the return value. - @testing-library/jest-dom matchers are loaded globally in setupTests (individual suites no longer need to import them). - Seven representative suites converted from enzyme to RTL as the pattern: ContentEmpty, ContentLoading, AlertModal, FormActionGroup, ChipGroup, ToolbarSyncSourceButton (interaction) and DeleteButton (modal open, confirm flow, async error). Where the enzyme versions only asserted wrapper length or props, the RTL versions assert rendered behavior; ChipGroup's old test asserted a prop on an empty render and now renders actual chips and asserts the visible overflow text. Conversion recipe: mountWithContexts() -> renderWithContexts() wrapper.find('button').simulate -> await user.click(screen.getByRole('button', { name })) waitForElement(wrapper, 'X') -> await screen.findBy... wrapper.find(...).props() asserts -> assert visible text/roles instead act(...) wrapping -> usually unnecessary (RTL wraps) 545 suites remain on enzyme; they convert incrementally with both helpers coexisting. --- .../components/AlertModal/AlertModal.test.js | 13 +- .../components/ChipGroup/ChipGroup.test.js | 18 ++- .../ContentEmpty/ContentEmpty.test.js | 7 +- .../ContentLoading/ContentLoading.test.js | 7 +- .../DeleteButton/DeleteButton.test.js | 125 ++++++++---------- .../FormActionGroup/FormActionGroup.test.js | 16 ++- .../ToolbarSyncSourceButton.test.js | 12 +- awx/ui/src/setupTests.js | 1 + awx/ui/testUtils/rtlContexts.js | 83 ++++++++++++ 9 files changed, 181 insertions(+), 101 deletions(-) create mode 100644 awx/ui/testUtils/rtlContexts.js diff --git a/awx/ui/src/components/AlertModal/AlertModal.test.js b/awx/ui/src/components/AlertModal/AlertModal.test.js index 0173e5a37..72248b18f 100644 --- a/awx/ui/src/components/AlertModal/AlertModal.test.js +++ b/awx/ui/src/components/AlertModal/AlertModal.test.js @@ -1,13 +1,18 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import AlertModal from './AlertModal'; describe('AlertModal', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts( - Are you sure? + renderWithContexts( + + Are you sure? + ); - expect(wrapper).toHaveLength(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Danger!')).toBeInTheDocument(); + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/ChipGroup/ChipGroup.test.js b/awx/ui/src/components/ChipGroup/ChipGroup.test.js index e9d843e77..0cc737ffb 100644 --- a/awx/ui/src/components/ChipGroup/ChipGroup.test.js +++ b/awx/ui/src/components/ChipGroup/ChipGroup.test.js @@ -1,14 +1,18 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { Chip } from '@patternfly/react-core'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ChipGroup from './ChipGroup'; describe('ChipGroup', () => { - test('should mount properly', () => { - const wrapper = mountWithContexts( - - ); - expect(wrapper.find('ChipGroup').at(1).props().collapsedText).toEqual( - '5 more' + test('should show the collapsed-chip count', () => { + renderWithContexts( + + {Array.from({ length: 10 }, (v, i) => ( + {`chip ${i}`} + ))} + ); + expect(screen.getByText('5 more')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/ContentEmpty/ContentEmpty.test.js b/awx/ui/src/components/ContentEmpty/ContentEmpty.test.js index 6498dc76c..2f3c6231c 100644 --- a/awx/ui/src/components/ContentEmpty/ContentEmpty.test.js +++ b/awx/ui/src/components/ContentEmpty/ContentEmpty.test.js @@ -1,11 +1,12 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ContentEmpty from './ContentEmpty'; describe('ContentEmpty', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); + renderWithContexts(); + expect(screen.getByText('No items found.')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/ContentLoading/ContentLoading.test.js b/awx/ui/src/components/ContentLoading/ContentLoading.test.js index c2816e258..9b1f942e7 100644 --- a/awx/ui/src/components/ContentLoading/ContentLoading.test.js +++ b/awx/ui/src/components/ContentLoading/ContentLoading.test.js @@ -1,11 +1,12 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ContentLoading from './ContentLoading'; describe('ContentLoading', () => { test('renders the expected content', () => { - const wrapper = mountWithContexts(); - expect(wrapper).toHaveLength(1); + renderWithContexts(); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); }); }); diff --git a/awx/ui/src/components/DeleteButton/DeleteButton.test.js b/awx/ui/src/components/DeleteButton/DeleteButton.test.js index 12744cfe5..0b83532e0 100644 --- a/awx/ui/src/components/DeleteButton/DeleteButton.test.js +++ b/awx/ui/src/components/DeleteButton/DeleteButton.test.js @@ -1,56 +1,42 @@ import React from 'react'; -import { act } from 'react-dom/test-utils'; +import { screen, waitFor } from '@testing-library/react'; import { CredentialsAPI } from 'api'; -import { - mountWithContexts, - waitForElement, -} from '../../../testUtils/enzymeHelpers'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import DeleteButton from './DeleteButton'; jest.mock('../../api'); describe('', () => { test('should render button', () => { - const wrapper = mountWithContexts( - {}} name="Foo" /> - ); - expect(wrapper.find('button')).toHaveLength(1); + renderWithContexts( {}} name="Foo" />); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); }); test('should open confirmation modal', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - {}} - name="Foo" - deleteDetailsRequests={[ - { - label: 'job', - request: CredentialsAPI.read.mockResolvedValue({ - data: { count: 1 }, - }), - }, - ]} - deleteMessage="Delete this?" - warningMessage="Are you sure to want to delete this" - /> - ); - }); - - await act(async () => { - wrapper.find('button').prop('onClick')(); - }); - - await waitForElement(wrapper, 'Modal', (el) => el.length > 0); - expect(wrapper.find('Modal')).toHaveLength(1); - - expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1); + const { user } = renderWithContexts( + {}} + name="Foo" + deleteDetailsRequests={[ + { + label: 'job', + request: CredentialsAPI.read.mockResolvedValue({ + data: { count: 1 }, + }), + }, + ]} + deleteMessage="Delete this?" + warningMessage="Are you sure to want to delete this" + /> + ); + await user.click(screen.getByRole('button', { name: 'Delete' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Delete this?')).toBeInTheDocument(); }); test('should invoke onConfirm prop', async () => { const onConfirm = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ', () => { deleteMessage="Delete this?" /> ); - await act(async () => wrapper.find('button').simulate('click')); - wrapper.update(); - await act(async () => - wrapper - .find('ModalBoxFooter button[aria-label="Confirm Delete"]') - .simulate('click') + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click( + await screen.findByRole('button', { name: 'Confirm Delete' }) ); - wrapper.update(); expect(onConfirm).toHaveBeenCalled(); }); test('should show delete details error', async () => { const onConfirm = jest.fn(); - let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - ); + data: 'An error occurred', + status: 403, + }, + }) + ), + }, + ]} + /> + ); + await user.click(screen.getByRole('button', { name: 'Delete' })); + await waitFor(() => { + expect(screen.getByText('Error!')).toBeInTheDocument(); }); - await act(async () => wrapper.find('button').simulate('click')); - wrapper.update(); - - expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1); }); }); diff --git a/awx/ui/src/components/FormActionGroup/FormActionGroup.test.js b/awx/ui/src/components/FormActionGroup/FormActionGroup.test.js index 5068a8522..b65e92818 100644 --- a/awx/ui/src/components/FormActionGroup/FormActionGroup.test.js +++ b/awx/ui/src/components/FormActionGroup/FormActionGroup.test.js @@ -1,13 +1,19 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import FormActionGroup from './FormActionGroup'; describe('FormActionGroup', () => { - test('should render the expected content', () => { - const wrapper = mountWithContexts( - {}} onCancel={() => {}} /> + test('should render save and cancel buttons and invoke their handlers', async () => { + const onSubmit = jest.fn(); + const onCancel = jest.fn(); + const { user } = renderWithContexts( + ); - expect(wrapper).toHaveLength(1); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(onSubmit).toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalled(); }); }); diff --git a/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js b/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js index c6b71dca5..7a5a4862b 100644 --- a/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js +++ b/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js @@ -1,16 +1,16 @@ import React from 'react'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { screen } from '@testing-library/react'; +import { renderWithContexts } from '../../../testUtils/rtlContexts'; import ToolbarSyncSourceButton from './ToolbarSyncSourceButton'; describe('', () => { - test('should render button', () => { + test('should render button and invoke onClick', async () => { const onClick = jest.fn(); - const wrapper = mountWithContexts( + const { user } = renderWithContexts( ); - const button = wrapper.find('button'); - expect(button).toHaveLength(1); - button.simulate('click'); + const button = screen.getByRole('button'); + await user.click(button); expect(onClick).toHaveBeenCalled(); }); }); diff --git a/awx/ui/src/setupTests.js b/awx/ui/src/setupTests.js index 6ba79216e..3696a2562 100644 --- a/awx/ui/src/setupTests.js +++ b/awx/ui/src/setupTests.js @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom'; import React from 'react'; import { configure } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; diff --git a/awx/ui/testUtils/rtlContexts.js b/awx/ui/testUtils/rtlContexts.js new file mode 100644 index 000000000..a97e55b04 --- /dev/null +++ b/awx/ui/testUtils/rtlContexts.js @@ -0,0 +1,83 @@ +/* + * React Testing Library counterpart of testUtils/enzymeHelpers. + * + * renderWithContexts(ui, { context }) renders a component inside the app's + * top-level providers (i18n, session, config, router) with the same context + * defaults and the same override mechanism as mountWithContexts, so enzyme + * suites can be converted incrementally: + * + * const { history, user } = renderWithContexts(, { + * context: { router: { history: createMemoryHistory(...) } }, + * }); + * await user.click(screen.getByRole('button', { name: 'Save' })); + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { I18nProvider } from '@lingui/react'; +import { i18n } from '@lingui/core'; +import english from '../src/locales/en/messages'; +import { SessionProvider } from '../src/contexts/Session'; +import { ConfigProvider } from '../src/contexts/Config'; + +i18n.load({ en: english.messages }); +i18n.activate('en'); + +const defaultContexts = { + config: { + ansible_version: null, + version: null, + me: { is_superuser: true }, + toJSON: () => '/config/', + license_info: { + valid_key: true, + }, + }, + router: {}, + session: { + isSessionExpired: false, + logout: () => {}, + setAuthRedirectTo: () => {}, + }, +}; + +function applyDefaultContexts(context) { + if (!context) { + return defaultContexts; + } + const newContext = {}; + Object.keys(defaultContexts).forEach((key) => { + newContext[key] = { + ...defaultContexts[key], + ...context[key], + }; + }); + return newContext; +} + +// eslint-disable-next-line import/prefer-default-export +export function renderWithContexts(ui, options = {}) { + const { context: userContext, ...renderOptions } = options; + const { config, router, session } = applyDefaultContexts(userContext); + const history = router.history || createMemoryHistory(); + + function Wrapper({ children }) { + return ( + + + + {children} + + + + ); + } + + return { + history, + user: userEvent.setup(), + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + }; +} From 0ffc3beffd9152e3b5710c3809631e9662d6b0fb Mon Sep 17 00:00:00 2001 From: blaipr Date: Fri, 12 Jun 2026 14:56:02 +0200 Subject: [PATCH 2/2] Address review: plural rules, rtlContexts tests, sturdier queries - rtlContexts now loads Lingui plural rules like enzymeHelpers does, behind a guard that degrades cleanly when the Lingui 6 upgrade removes loadLocaleData and the make-plural dependency - adds testUtils/rtlContexts.test.js covering context defaults and overrides, router/history wiring and plural rendering - and extends the jest roots/testMatch to /testUtils, which revealed that enzymeHelpers.test.js had never been executed (the old async-done test style is rejected by modern Jest; both tests fixed, 8 tests revived) - ToolbarSyncSourceButton test queries the button by accessible name - DeleteButton test attaches .response to a real Error so the network error path is actually exercised; fixes the URL typo --- awx/ui/package.json | 6 +- .../DeleteButton/DeleteButton.test.js | 4 +- .../ToolbarSyncSourceButton.test.js | 2 +- .../__snapshots__/enzymeHelpers.test.js.snap | 53 +++++++++++++ awx/ui/testUtils/enzymeHelpers.test.js | 6 +- awx/ui/testUtils/rtlContexts.js | 13 ++++ awx/ui/testUtils/rtlContexts.test.js | 75 +++++++++++++++++++ 7 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 awx/ui/testUtils/__snapshots__/enzymeHelpers.test.js.snap create mode 100644 awx/ui/testUtils/rtlContexts.test.js diff --git a/awx/ui/package.json b/awx/ui/package.json index 444d70d6f..4650485b1 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -150,7 +150,8 @@ }, "jest": { "roots": [ - "/src" + "/src", + "/testUtils" ], "collectCoverageFrom": [ "src/**/*.{js,jsx}", @@ -164,7 +165,8 @@ ], "testMatch": [ "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", - "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" + "/src/**/*.{spec,test}.{js,jsx,ts,tsx}", + "/testUtils/**/*.{spec,test}.{js,jsx,ts,tsx}" ], "testEnvironment": "jsdom", "transform": { diff --git a/awx/ui/src/components/DeleteButton/DeleteButton.test.js b/awx/ui/src/components/DeleteButton/DeleteButton.test.js index 0b83532e0..18f3fdb97 100644 --- a/awx/ui/src/components/DeleteButton/DeleteButton.test.js +++ b/awx/ui/src/components/DeleteButton/DeleteButton.test.js @@ -68,11 +68,11 @@ describe('', () => { { label: 'job', request: CredentialsAPI.read.mockRejectedValue( - new Error({ + Object.assign(new Error('An error occurred'), { response: { config: { method: 'get', - url: '/api/v2/credentals', + url: '/api/v2/credentials', }, data: 'An error occurred', status: 403, diff --git a/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js b/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js index 7a5a4862b..c308f93f7 100644 --- a/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js +++ b/awx/ui/src/components/PaginatedTable/ToolbarSyncSourceButton.test.js @@ -9,7 +9,7 @@ describe('', () => { const { user } = renderWithContexts( ); - const button = screen.getByRole('button'); + const button = screen.getByRole('button', { name: 'Sync all' }); await user.click(button); expect(onClick).toHaveBeenCalled(); }); diff --git a/awx/ui/testUtils/__snapshots__/enzymeHelpers.test.js.snap b/awx/ui/testUtils/__snapshots__/enzymeHelpers.test.js.snap new file mode 100644 index 000000000..8ebb59395 --- /dev/null +++ b/awx/ui/testUtils/__snapshots__/enzymeHelpers.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`mountWithContexts injected ConfigProvider should mount and render with custom Config value 1`] = ` + +
+ 1.1 +
+
+`; + +exports[`mountWithContexts injected ConfigProvider should mount and render with default values 1`] = ` + +
+ +`; + +exports[`mountWithContexts injected I18nProvider should mount and render 1`] = ` +
+ + Text content + +
+`; + +exports[`mountWithContexts injected I18nProvider should mount and render deeply nested consumer 1`] = ` + + +
+ Text content +
+
+
+`; + +exports[`mountWithContexts injected Router should mount and render 1`] = ` +
+ + + + home + + + +
+`; diff --git a/awx/ui/testUtils/enzymeHelpers.test.js b/awx/ui/testUtils/enzymeHelpers.test.js index 21b6a773c..92d62082a 100644 --- a/awx/ui/testUtils/enzymeHelpers.test.js +++ b/awx/ui/testUtils/enzymeHelpers.test.js @@ -120,7 +120,7 @@ class TestAsyncComponent extends Component { } describe('waitForElement', () => { - it('waits for the element and returns it', async done => { + it('waits for the element and returns it', async () => { const selector = '#test-async-component'; const wrapper = mountWithContexts(); expect(wrapper.exists(selector)).toEqual(false); @@ -128,10 +128,9 @@ describe('waitForElement', () => { const elem = await waitForElement(wrapper, selector); expect(elem.props().id).toEqual('test-async-component'); expect(wrapper.exists(selector)).toEqual(true); - done(); }); - it("eventually throws an error for elements that don't exist", async done => { + it("eventually throws an error for elements that don't exist", async () => { const wrapper = mountWithContexts(
); let error; @@ -144,7 +143,6 @@ describe('waitForElement', () => { 'Expected condition for <#does-not-exist> not met' ); expect(error.message).toContain('el.length === 1'); - done(); } }); }); diff --git a/awx/ui/testUtils/rtlContexts.js b/awx/ui/testUtils/rtlContexts.js index a97e55b04..8b68b09d7 100644 --- a/awx/ui/testUtils/rtlContexts.js +++ b/awx/ui/testUtils/rtlContexts.js @@ -22,6 +22,19 @@ import english from '../src/locales/en/messages'; import { SessionProvider } from '../src/contexts/Session'; import { ConfigProvider } from '../src/contexts/Config'; +// Match mountWithContexts' i18n defaults. Lingui v5 needs explicit plural +// rules (loadLocaleData); Lingui v6 derives them from Intl.PluralRules and +// removes both the API and the make-plural dependency, so this block is +// guarded to degrade cleanly once the toolchain upgrade lands. +try { + if (typeof i18n.loadLocaleData === 'function') { + // eslint-disable-next-line global-require, import/no-extraneous-dependencies + const { en } = require('make-plural/plurals'); + i18n.loadLocaleData({ en: { plurals: en } }); + } +} catch { + // make-plural is gone (Lingui v6); Intl.PluralRules covers plurals. +} i18n.load({ en: english.messages }); i18n.activate('en'); diff --git a/awx/ui/testUtils/rtlContexts.test.js b/awx/ui/testUtils/rtlContexts.test.js new file mode 100644 index 000000000..e2a4daa95 --- /dev/null +++ b/awx/ui/testUtils/rtlContexts.test.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import { useHistory, useLocation } from 'react-router-dom'; +import { Plural } from '@lingui/react/macro'; +import { useConfig } from '../src/contexts/Config'; +import { useSession } from '../src/contexts/Session'; +import { renderWithContexts } from './rtlContexts'; + +function ConfigProbe() { + const config = useConfig(); + return ( +
+ {config.me.is_superuser ? 'superuser' : 'normal user'} + {config.custom_field ?? 'no custom field'} +
+ ); +} + +function SessionProbe() { + const { isSessionExpired } = useSession(); + return {isSessionExpired ? 'expired' : 'active'}; +} + +function RouterProbe() { + const location = useLocation(); + const history = useHistory(); + return ( + + ); +} + +describe('renderWithContexts', () => { + test('provides the default config context', () => { + renderWithContexts(); + expect(screen.getByText('superuser')).toBeInTheDocument(); + expect(screen.getByText('no custom field')).toBeInTheDocument(); + }); + + test('merges config overrides into the defaults', () => { + renderWithContexts(, { + context: { config: { me: { is_superuser: false }, custom_field: 'qux' } }, + }); + expect(screen.getByText('normal user')).toBeInTheDocument(); + expect(screen.getByText('qux')).toBeInTheDocument(); + }); + + test('provides the default session context', () => { + renderWithContexts(); + expect(screen.getByText('active')).toBeInTheDocument(); + }); + + test('returns the router history it renders with', async () => { + const { history, user } = renderWithContexts(); + expect(screen.getByRole('button')).toHaveTextContent('/'); + await user.click(screen.getByRole('button')); + expect(history.location.pathname).toBe('/somewhere-else'); + expect(screen.getByRole('button')).toHaveTextContent('/somewhere-else'); + }); + + test('uses a caller-supplied history', () => { + const history = createMemoryHistory({ initialEntries: ['/credentials'] }); + renderWithContexts(, { context: { router: { history } } }); + expect(screen.getByRole('button')).toHaveTextContent('/credentials'); + }); + + test('renders plural messages without console errors', () => { + renderWithContexts( + + ); + expect(screen.getByText('2 items')).toBeInTheDocument(); + }); +});