Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: migrate enzyme to rtl #915

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,465 changes: 677 additions & 788 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
],
"dependencies": {
"@edx/brand": "npm:@openedx/[email protected]",
"@edx/frontend-component-footer": "13.0.2",
"@edx/frontend-component-footer": "13.0.4",
"@edx/frontend-enterprise-catalog-search": "9.0.0",
"@edx/frontend-enterprise-hotjar": "6.0.0",
"@edx/frontend-enterprise-logistration": "8.0.0",
"@edx/frontend-enterprise-utils": "8.0.0",
"@edx/frontend-platform": "7.1.0",
"@edx/frontend-platform": "7.1.1",
"@edx/openedx-atlas": "^0.6.0",
"@edx/reactifex": "^2.2.0",
"@loadable/component": "5.16.3",
Expand Down Expand Up @@ -61,16 +61,15 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.1.1",
"@edx/react-unit-test-utils": "^2.0.0",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform/suggestion] Note, while @edx/react-unit-test-utils is acceptable as a short-term replacement for previous Enzyme tests to minimize initial refactoring, we generally would prefer not to use this NPM package in favor of using React Testing Library (RTL) directly.

Rationale: RTL's testing philosophy is vastly different from Enzyme, and while @edx/react-unit-test-utils is using RTL under-the-hood, it generally continues to propagate Enzyme's testing philosophy of asserting based on implementation details (no longer desired).

It does look like you're using RTL directly in all but 2 test cases, which is great. Perhaps considering refactoring those 2 test cases to also use RTL directly.

"@openedx/frontend-build": "13.0.28",
"@tanstack/eslint-plugin-query": "4.29.9",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "12.1.5",
"@testing-library/react-hooks": "3.7.0",
"@testing-library/user-event": "13.5.0",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"acorn": "8.5.0",
"axios-mock-adapter": "1.19.0",
"enzyme": "3.11.0",
"jest-canvas-mock": "^2.4.0",
"match-media-mock": "0.1.1",
"mockdate": "3.0.5",
Expand Down
10 changes: 5 additions & 5 deletions src/components/app/AuthenticatedUserSubsidyPage.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { shallow } from 'enzyme';
import { shallow } from '@edx/react-unit-test-utils';
import '@testing-library/jest-dom/extend-expect';

import AuthenticatedUserSubsidyPage from './AuthenticatedUserSubsidyPage';
Expand All @@ -16,15 +16,15 @@ describe('<AuthenticatedUserSubsidyPage />', () => {
);
});
it('renders <AuthenticatedPage>', () => {
expect(wrapper.find(AuthenticatedPage)).toBeTruthy();
expect(wrapper.instance.findByType(AuthenticatedPage)).toBeTruthy();
});
it('renders <UserSubsidy>', () => {
expect(wrapper.find(UserSubsidy)).toBeTruthy();
expect(wrapper.instance.findByType(UserSubsidy)).toBeTruthy();
});
it('renders <AutoActivateLicense>', () => {
expect(wrapper.find(AutoActivateLicense)).toBeTruthy();
expect(wrapper.instance.findByType(AutoActivateLicense)).toBeTruthy();
});
it('renders children', () => {
expect(wrapper.find('div.did-i-render')).toBeTruthy();
expect(wrapper.instance.findByType('div.did-i-render')).toBeTruthy();
Comment on lines +19 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] You don't need @edx/react-unit-test-utils to have these same style assertions. With RTL, it'd be preferable to mock the relevant components to render (e.g., with a data-testid) and then use normal RTL assertions to ensure the various sub-components are rendered (e.g., getByTestId()).

That said, I defer to you if you want to update given this component and its tests will be removed in the next week or two as part of our ongoing work to rearchitect this MFE to improve frontend performance with route loaders and more use of React Query. I'd be OK with leaving it as is for now.

(We would like to avoid shallow rendering moving forward).

});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import { mount } from 'enzyme';
import { StatefulButton } from '@openedx/paragon';
import { fireEvent, render, screen } from '@testing-library/react';

import { EmailSettingsModal } from '../EmailSettingsModal';
import { updateEmailSettings } from '../data';
Expand All @@ -11,7 +10,7 @@ describe('<EmailSettingsModal />', () => {
let wrapper;

beforeEach(() => {
wrapper = mount((
wrapper = render((
<EmailSettingsModal
onClose={() => {}}
courseRunId="my+course+key"
Expand All @@ -23,28 +22,38 @@ describe('<EmailSettingsModal />', () => {
// is initially `false` until the modal is opened, at which point it's
// set to whatever the correct value is for that particular course run.
// Setting the `hasEmailsEnabled` prop here simulates that behavior.
wrapper.setProps({
hasEmailsEnabled: true,
});
wrapper.rerender((
<EmailSettingsModal
onClose={() => {}}
courseRunId="my+course+key"
hasEmailsEnabled
/>
));
Comment on lines +25 to +31
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note] It's unfortunate we will need to rerender() here, but don't seem an immediate way around it without refactoring the underlying EmailSettingsModal itself, which is likely out of scope for this particular work to replace Enzyme. Just wanted to leave a note that, while OK to leave as is, this generally seems to be a code smell.

});

it('statefulbutton component state is initially set to default and disabled', () => {
const defaultState = 'default';
expect(wrapper.find(StatefulButton).prop('state')).toEqual(defaultState);
expect(wrapper.find(StatefulButton).prop('disabledStates')).toContain(defaultState);
const buttonElement = screen.getAllByRole('button');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[curious/suggestion] Why use .getAllByRole when you're only expecting/asserting on a single button? I would recommend making this query for the specific button in question, not all buttons...

For example, something like:

const buttonElement = screen.getByRole('button', { name: 'Save' });

Same feedback for similar usages in other test cases as well.


expect(buttonElement[buttonElement.length - 1].getAttribute('aria-disabled')).toEqual('true');
expect(buttonElement[buttonElement.length - 1].disabled).toEqual(false);
Comment on lines +37 to +38
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Would .toBeDisabled() simplify these lines?

expect(buttonElement).toBeDisabled();

expect(screen.getByText('Save')).toBeTruthy();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When making assertions with .getByText, it's recommended to use .toBeInTheDocument(), not .toBeTruthy(). This would be more consistent with other usages of getByText throughout this repo and more aligned with the best practices recommended with RTL (e.g., https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-get-variants-as-assertions).

});

it('statefulbutton component state is set to complete after click event', async () => {
// Note: The following line is needed to properly resolve the
// `updateEmailSettings` promise.
const flushPromises = () => new Promise(setImmediate);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With tests using RTL, any use of this approach with flushPromises can likely be replaced with await waitFor(...) from RTL instead of relying on custom techniques to await asynchronous operations.

const buttonElement = screen.getAllByRole('button');

expect(wrapper.find(StatefulButton).prop('state')).toEqual('default');
wrapper.find('input[type="checkbox"]').simulate('change', { target: { checked: false } });
wrapper.find(StatefulButton).simulate('click');
expect(screen.getByText('Save')).toBeTruthy();
expect(screen.queryByText('Saved')).toBeFalsy();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to using .toBeInTheDocument() for such assertions, this should be using .not.toBeInTheDocument(), not toBeFalsy().


fireEvent.click(screen.getByRole('checkbox'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userEvent is generally preferred over fireEvent. userEvent utilizes fireEvent but userEvent more closely mimics real user behavior (which is one of the overall benefits of RTL).

The only time fireEvent should be called, per the following resources, is if the user interaction is not yet implemented in userEvent.

There are, however, some user interactions or aspects of these that aren't yet implemented and thus can't yet be described with user-event. In these cases you can use fireEvent to dispatch the concrete events that your software relies on.

See these related resources for more details:

fireEvent.click(buttonElement[buttonElement.length - 1]);
await flushPromises();
wrapper.update();

expect(updateEmailSettings).toHaveBeenCalledTimes(1);
expect(wrapper.find(StatefulButton).prop('state')).toEqual('complete');
expect(screen.getByText('Saved')).toBeTruthy();
expect(screen.queryByText('Save')).toBeFalsy();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { mount } from 'enzyme';
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { AppContext } from '@edx/frontend-platform/react';

Expand Down Expand Up @@ -30,56 +30,59 @@ describe('<MarkCompleteModal />', () => {
course_run_status: 'completed',
},
}));
const wrapper = mount((
render((
<AppContext.Provider value={{ enterpriseConfig }}>
<MarkCompleteModal
{...initialProps}
/>
</AppContext.Provider>
));
wrapper.find('.confirm-mark-complete-btn').hostNodes().simulate('click');
const buttonElement = screen.getAllByRole('button');
fireEvent.click(buttonElement[buttonElement.length - 1]);
expect(service.updateCourseCompleteStatusRequest).toBeCalledWith({
course_id: initialProps.courseId,
enterprise_id: enterpriseConfig.uuid,
saved_for_later: true,
});
expect(wrapper.find('.confirm-mark-complete-btn').hostNodes().text()).toEqual(MARK_SAVED_FOR_LATER_PENDING_LABEL);
expect(buttonElement[buttonElement.length - 1].textContent).toEqual(MARK_SAVED_FOR_LATER_PENDING_LABEL);
});

it('handles confirm click with error', async () => {
// eslint-disable-next-line no-import-assign
service.markCourseAsCompleteRequest = jest.fn()
.mockImplementation(() => Promise.reject(new Error('test error')));
const wrapper = mount((
render((
<AppContext.Provider value={{ enterpriseConfig }}>
<MarkCompleteModal
{...initialProps}
/>
</AppContext.Provider>
));
const buttonElement = screen.getAllByRole('button');
await act(async () => {
wrapper.find('.confirm-mark-complete-btn').hostNodes().simulate('click');
fireEvent.click(buttonElement[buttonElement.length - 1]);
});
expect(service.updateCourseCompleteStatusRequest).toBeCalledWith({
course_id: initialProps.courseId,
enterprise_id: enterpriseConfig.uuid,
saved_for_later: true,
});
expect(wrapper.find('.confirm-mark-complete-btn').hostNodes().text()).toEqual(MARK_SAVED_FOR_LATER_DEFAULT_LABEL);
expect(buttonElement[buttonElement.length - 1].textContent).toEqual(MARK_SAVED_FOR_LATER_DEFAULT_LABEL);
});

it('handles close modal', () => {
const mockOnClose = jest.fn();
const wrapper = mount((
render((
<AppContext.Provider value={{ enterpriseConfig }}>
<MarkCompleteModal
{...initialProps}
onClose={mockOnClose}
/>
</AppContext.Provider>
));
const buttonElement = screen.getAllByRole('button');
act(() => {
wrapper.find('.modal-footer button.btn-link').hostNodes().simulate('click');
fireEvent.click(buttonElement[1]);
});
Comment on lines 84 to 86
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With RTL, side effects such as this .click() should not be wrapped in act. See https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#wrapping-things-in-act-unnecessarily for more details.

expect(mockOnClose).toBeCalledTimes(1);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import { mount } from 'enzyme';
import { fireEvent, render, screen } from '@testing-library/react';
import { AppContext } from '@edx/frontend-platform/react';
import { Skeleton } from '@openedx/paragon';

import dayjs from '../../../../../../utils/dayjs';
import BaseCourseCard from '../BaseCourseCard';
Expand All @@ -27,7 +26,7 @@ describe('<BaseCourseCard />', () => {
beforeEach(() => {
jest.clearAllMocks();

wrapper = mount((
wrapper = render((
<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<BaseCourseCard
type="completed"
Expand All @@ -39,14 +38,15 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));
// open email settings modal
wrapper.find('Dropdown').find('button.btn-icon').simulate('click');
wrapper.find('Dropdown').find('button.dropdown-item').simulate('click');
expect(wrapper.find('BaseCourseCard').state('modals').emailSettings.open).toBeTruthy();
fireEvent.click(wrapper.container.querySelector('button.btn-icon'));
fireEvent.click(wrapper.container.querySelectorAll('button.dropdown-item')[0]);
Comment on lines +41 to +42
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of querying for implementation details (class names), can we query as if you're the user? I.e., query for the button / dropdown item using the rendered text, not the class names. This should help make testing less brittle (e.g., Paragon's 'dropdown-item class name could change and it wouldn't break this test).


expect(screen.getAllByText('Email settings')).toHaveLength(2);
});

it('test modal close/cancel', () => {
wrapper.find('EmailSettingsModal').find('.modal-footer .btn-link').first().simulate('click');
expect(wrapper.find('BaseCourseCard').state('modals').emailSettings.open).toBeFalsy();
fireEvent.click(screen.getAllByText('Close')[1]);
expect(screen.getAllByText('Email settings')).toHaveLength(2);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[clarification] With these changes, does this test have the same semantics as the existing test? Previously, it was asserting that the email settings modal is closed; however, in the new assertions, I'm not sure asserting that "Email settings" rendered twice is really testing that the modal is closed...

});
});

Expand All @@ -56,7 +56,7 @@ describe('<BaseCourseCard />', () => {
beforeEach(() => {
jest.clearAllMocks();

wrapper = mount((
wrapper = render((
<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<ToastsContext.Provider value={{ addToast: mockAddToast }}>
<CourseEnrollmentsContext.Provider value={{ removeCourseEnrollment: jest.fn() }}>
Expand All @@ -73,19 +73,19 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));
// open unenroll modal
wrapper.find('Dropdown').find('button.btn-icon').simulate('click');
wrapper.find('Dropdown').find('button.dropdown-item').at(1).simulate('click');
expect(wrapper.find('BaseCourseCard').state('modals').unenroll.open).toBeTruthy();
fireEvent.click(wrapper.container.querySelector('button.btn-icon'));
fireEvent.click(wrapper.container.querySelectorAll('button.dropdown-item')[1]);
Comment on lines +76 to +77
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback about not using implementation details like class names when using RTL (and userEvent).

expect(screen.getByText('Unenroll from course?')).toBeTruthy();
});

it('test modal close/cancel', () => {
wrapper.find('UnenrollModal').find('.btn-tertiary').simulate('click');
expect(wrapper.find('BaseCourseCard').state('modals').unenroll.open).toBeFalsy();
fireEvent.click(screen.getAllByText('Close')[1]);
expect(screen.queryByText('Unenroll from course?')).toBeFalsy();
});
});

it('should render Skeleton if isLoading = true', () => {
wrapper = mount((
render((
<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<BaseCourseCard
type="completed"
Expand All @@ -98,7 +98,7 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));

expect(wrapper.find(Skeleton)).toBeTruthy();
expect(screen.getByText('Loading...')).toBeTruthy();
});

it('renders with different startDate values', () => {
Expand All @@ -112,7 +112,7 @@ describe('<BaseCourseCard />', () => {
const formattedStartDate = dayjs(startDate).format('MMMM Do, YYYY');
const isCourseStarted = dayjs(startDate) <= dayjs();

wrapper = mount((
wrapper = render((
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[clarification] If wrapper is not used in the below test case, no need to assign the output of render to wrapper.

<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<BaseCourseCard
type="in_progress"
Expand All @@ -130,12 +130,11 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));

const renderedStartDate = wrapper.instance().renderStartDate();
const expectedOutput = formattedStartDate && !isCourseStarted
? <span className="font-weight-light">Starts {formattedStartDate}</span>
: null;

expect(renderedStartDate).toEqual(expectedOutput);
if (formattedStartDate && !isCourseStarted) {
expect(screen.getByText(`Starts ${formattedStartDate}`)).toBeTruthy();
} else {
expect(screen.queryByText(`Starts ${formattedStartDate}`)).toBeFalsy();
}
});
});

Expand All @@ -146,7 +145,7 @@ describe('<BaseCourseCard />', () => {
const formattedEndDate = dayjs(endDate).format('MMMM Do, YYYY');
const type = 'in_progress';

wrapper = mount((
wrapper = render((
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback about not needing wrapper here.

<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<BaseCourseCard
type={type}
Expand All @@ -163,12 +162,11 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));

const renderedEndDate = wrapper.instance().renderEndDate();
const expectedOutput = formattedEndDate && dayjs(startDate) <= dayjs() && type !== 'completed'
? <span className="font-weight-light">Ends {formattedEndDate}</span>
: null;

expect(renderedEndDate).toEqual(expectedOutput);
if (formattedEndDate && dayjs(startDate) <= dayjs() && type !== 'completed') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Similar to the test above with isCourseStarted, it might be good to abstract the dayjs(startDate) <= dayjs() into a variable that can be used here to simplify the conditional a bit

expect(screen.getByText(`Ends ${formattedEndDate}`)).toBeTruthy();
} else {
expect(screen.queryByText(`Ends ${formattedEndDate}`)).toBeFalsy();
}
});

it('renders Enroll By Date if the user is not enrolled', () => {
Expand All @@ -177,7 +175,7 @@ describe('<BaseCourseCard />', () => {
const formattedEnrollByDate = dayjs(enrollBy).format('MMMM Do, YYYY');
const courseRunStatus = 'assigned';

wrapper = mount((
wrapper = render((
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems wrapper is unused and could be removed for this test?

<AppContext.Provider value={{ enterpriseConfig, authenticatedUser }}>
<BaseCourseCard
courseRunStatus={courseRunStatus}
Expand All @@ -190,11 +188,10 @@ describe('<BaseCourseCard />', () => {
</AppContext.Provider>
));

const isNotEnrolled = wrapper.instance().renderEnrollByDate();
const expectedOutput = formattedEnrollByDate && courseRunStatus === 'assigned'
? <span className="font-weight-light">Enroll by {formattedEnrollByDate}</span>
: null;

expect(isNotEnrolled).toEqual(expectedOutput);
if (formattedEnrollByDate && courseRunStatus === 'assigned') {
expect(screen.getByText(`Enroll by ${formattedEnrollByDate}`)).toBeTruthy();
} else {
expect(screen.queryByText(`Enroll by ${formattedEnrollByDate}`)).toBeFalsy();
}
});
});
Loading
Loading