Skip to content

Commit

Permalink
chore: Initial work for migrating tests from Enzyme to React Testing …
Browse files Browse the repository at this point in the history
…Library (microsoft#7090)

#### Details

This PR adds a [jscodeshift](https://github.com/facebook/jscodeshift)
[codemod](https://github.com/facebookarchive/codemod) to the repo that
can be run using `yarn codemod` to migrate test files (set in the paths
variable in tools/codeshift.js) that use
[Enzyme](https://enzymejs.github.io/enzyme/) to use [React Testing
Library](https://testing-library.com/docs/react-testing-library/intro)
instead.

While this codemod will migrate the low hanging fruit in the tests, it
can't cover every scenario perfectly. Tests will need to be
incrementally migrated to cut down on PR size. This PR migrates tests in
the `src/tests/unit/tests/assessments/` directory to show an example of
what that incremental migration will look like.

Because our tests make heavy use of Enzyme's `shallow` rendering and
React Testing Library does a full mounted render into raw HTML, we
needed a solution to keep our snapshots scoped to the actual component
we are testing. To address this, this PR includes new mock helper
utilities:
* `mockReactComponents`: adds a `mockImplementation` for each React
Component in the array passed in that renders a React Element instead of
the full HTML of that component.
* `getMockComponentCall`: retrieves `SomeComponent.mock.calls[callNum]`
for SomeComponent and callNum passed in for a mocked React Component
* `getMockComponentClassPropsForCall`: replaces
`someReactComponent.props()` functionality for a mocked React Component
* `expectMockedComponentPropsToMatchSnapshots`: a convenience method to
assert that a mocked React Component's props match a snapshot

Note: I recommend reviewing this commit by commit in order to understand
the flow and changes best.

##### Motivation

migrating Enzyme to eventually upgrade React

##### Context

After all tests have been migrated and all mentions of Enzyme are gone,
the migration can continue:
* remove enzyme dependency
* upgrade React version
* upgrade React Testing Library version, which requires a newer version
of React

#### Pull request checklist
<!-- If a checklist item is not applicable to this change, write "n/a"
in the checkbox -->
- [n/a] Addresses an existing issue: #0000
- [x] Ran `yarn fastpass`
- [x] Added/updated relevant unit test(s) (and ran `yarn test`)
- [x] Verified code coverage for the changes made. Check coverage report
at: `<rootDir>/test-results/unit/coverage`
- [x] PR title *AND* final merge commit title both start with a semantic
tag (`fix:`, `chore:`, `feat(feature-name):`, `refactor:`). See
`CONTRIBUTING.md`.
- [n/a] (UI changes only) Added screenshots/GIFs to description above
- [n/a] (UI changes only) Verified usability with NVDA/JAWS
  • Loading branch information
madalynrose authored Dec 18, 2023
1 parent 620f646 commit a250ac0
Show file tree
Hide file tree
Showing 20 changed files with 1,828 additions and 364 deletions.
129 changes: 129 additions & 0 deletions docs/enzyme-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<!--
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
-->

# Enzyme Migration

These are cursory notes on the migration to help folks get a sense of the process and some common API differences.

## Codemod usage

1. Edit `tools/codeshift.js`:
1. change the value of the `paths` variable to include the files you want to migrate
1. make sure the `dry: true` line is commented out
1. Run `yarn codeshift`
1. Run `yarn test -u` to update snapshots
1. Commit tests and snapshots that pass and look fairly similar to old snapshots (don’t have a ton of new content due to the full render instead of shallow rendering)
1. Make manual changes as needed to finish migrating the remaining tests.
> [!NOTE]
> Running `yarn test -f -u` and fixing failing tests with each run is helpful
## Manual code changes

### Mocking

A large portion of changes that will need to be manually made are mocking changes. Our tests heavily use Enzyme’s Shallow render functionality. Shallow render does not fully render React components that are used to compose the component being tested but renders them as JSX instead, with their props intact.

Because React Testing Library does not have equivalent functionality, we need to mock any React Elements that are used to compose the component we are testing to make our snapshot tests meaningful without a bunch of redundant content that is tested elsewhere already. This also solves the issue where props from nested React components are not defined in the test because it wasn’t required for a shallow render but a full render expects it to be there.

The one caveat to this is if the test requires that element be fully rendered to interact with it (e.g., the test interacts with a button that is rendered through a React element). These tests will likely not use a snapshot.

#### Mocking components

Add `jest.mock(‘/path/to/file/with/component’)` under the imports of the file.

* If there are multiple components that need mocking from the same path, only one `jest.mock` call is needed.
* If there are multiple components that need mocking from different paths, there should be one `jest.mock` call per path.

In the `describe` block, add `mockReactComponents([ComponentName])`.
* This array should contain all the components that need to be mocked, though it is often only a single component.

#### Snapshot parity

If the test snapshot appears to be missing information because one of the props for the mocked component is rendered as `[Object object]`, an additional snapshot for the props of that component should be added:

`expectMockedComponentPropsToMatchSnapshot([ComponentName])`

or

`expect(getMockComponentClassPropsForCall(ComponentName)).toMatchSnapshot()`

#### Testing if a component is present (e.g., if the original test code has something like `expect(wrapper.find(ComponentName)).not.toBeNull()`)

For this case you should mock the component and then use `expect(ComponentName).toBeCalled()`


### API Differences

The codemod handles all of the straightforward API differences, but many of our tests use custom helpers and have too many interdependent variables for the codemod to be able to correctly migrate it.

#### `.find(ComponentName)`

> [!NOTE]
> the codemod changes all `.find()` calls, as the vast majority are for Enzyme. However, some number of regular `Array.find()` calls will be mistakenly changed. These can just be changed back.
> [!NOTE]
> the codemod automatically changes `.find()` to be `.querySelector()`, as that is most often the correct API call to migrate to. If the selected variable's value should be an array instead of a standalone element, it should be updated to use `.querySelectorAll()` instead. Additionally, all React Testing Library query calls have “All” variations. For example, `getAllByRole`.
The treatment of these calls will depend on what operations are performed on the object after this call.

If the component is some sort of `ButtonComponent` and the old code used `foundComponentVariableName.simulate(‘click’)` after the `.find` call, then this can likely be migrated as `renderResult.getByRole(‘button’)` and then the simulate line can be migrated separately.

If the component is just being used to verify that the content made it to the page (checking if the found component is `null`), you can instead [mock that component](#mocking-components) and `assert` that the component was called.

Alternatively, if the test is checking for specific text to be present, something like `renderResult.findByText(‘text content’)` and `assert` that the result is *not* `null`.

If the test is checking to see if something is *not* there, you will likely need to utilize the `queryBy` API (e.g. `renderResult.queryByRole(‘button’)`), which won’t error if it can’t find a result.

If the element being searched for is outside of the originally rendered element, you may need to use the `screen.getBy` APIs instead, as this will look for the element in the entire screen, and not just inside a small portion. `screen` is part of `@testing-library/react`.

You will likely need to look at the composition of the component you are testing to select which [`getBy` or `queryBy` API call](https://testing-library.com/docs/queries/about#priority) you need. The ones most likely to be useful are `getByRole`, `getByLabelText`, and `getByText`


#### `.is(tag)`

`expect(elementVar.tagName).toBe(tag.toUppercase());`


#### `.simulate`

Simulating events can be achieved in two ways. The preferred method is using [`userEvent`](https://testing-library.com/docs/user-event/intro), imported from `@testing-library/user-event`. This fires off an actual full fledged event as if an actual user interaction had occurred. The other method is by importing [`fireEvent`](https://testing-library.com/docs/dom-testing-library/api-events#fireevent) from `@testing-library/react`, which still calls `dispatchEvent` but may not fire all events in the same order as an actual user interaction.

Many of our enzyme tests construct a stubEvent to pass into the simulate call. This stub will not be necessary as a near-real event will be triggered by the new APIs. Any mock functions expecting the stubEvent will have to be modified.

##### `user-event`

The codemod will automatically change any `button.simulate(‘click’)` to be `await userEvent.click(renderResult.getByRole(‘button’))` because that works for the majority of cases. You’ll just need to delete the obsolete code previously used to delete the button.

To type into an input, use `await userEvent.type(elementVariable, value)`. `type` documentation: [https://testing-library.com/docs/user-event/utility#type](https://testing-library.com/docs/user-event/utility#type)

To press a specific key regardless of element, use `await userEvent.keyboard(keyActions)`. `keyboard` documentation: [https://testing-library.com/docs/user-event/utility#type](https://testing-library.com/docs/user-event/utility#type)

Check out the [user-event documentation](https://testing-library.com/docs/user-event/intro) if you run into other cases.

##### `fireEvent`

`fireEvent` is most useful for when we want to mock an event function like `stopPropagation` and check that it is called because the event can be created and modified before it is passed into `fireEvent`. For example:

```
const event = createEvent.click(link);
event.stopPropagation = stopPropagationMock;
fireEvent(link, event);
expect(stopPropagationMock).toHaveBeenCalledTimes(1);
```

`createEvent` (used in the example above) is also part of `@testing-library/react`.

#### Manipulating React state

Directly modifying React state after initial render is not possible in React Testing Library. Instead, you should figure out how that state change is triggered and use [user-event](#user-event) or updated `props` to achieve the same result by interacting with the rendered component.

To check if an expected React state is produced, test that the UI reflects that state as expected.

#### Updating React `props` after initial render

To update `props` after initial render, use the [`rerender`](https://testing-library.com/docs/react-testing-library/api#rerender) function and pass in the new `props`. This function is available on the `renderResult` object produced by the `render` function.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"build:package:axe-config": "grunt generate-axe-config",
"change-log": "node ./tools/get-change-log-for-release.js",
"clean": "grunt clean:*",
"codeshift": "node ./tools/jscodeshift/codeshift.js",
"copyright:check": "license-check-and-add check -f copyright-header.config.json",
"copyright:fix": "license-check-and-add add -f copyright-header.config.json",
"fastpass": "npm-run-all --print-label scss:build --parallel type:check copyright:check lint:check lint:scss format:check null:check && grunt ada-cat",
Expand Down Expand Up @@ -77,6 +78,7 @@
"@esbuild-plugins/node-resolve": "^0.2.2",
"@swc/core": "^1.3.100",
"@swc/jest": "^0.2.29",
"@testing-library/react": "12.1.2",
"@types/chrome": "0.0.254",
"@types/enzyme": "^3.10.18",
"@types/enzyme-adapter-react-16": "^1.0.9",
Expand Down Expand Up @@ -126,6 +128,7 @@
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"js-yaml": "^4.1.0",
"jscodeshift": "^0.15.1",
"license-check-and-add": "^4.0.5",
"mini-css-extract-plugin": "2.7.6",
"npm-run-all": "^4.1.5",
Expand Down Expand Up @@ -153,6 +156,7 @@
"dependencies": {
"@fluentui/react": "^8.96.1",
"@microsoft/applicationinsights-web": "^2.8.15",
"@testing-library/user-event": "^14.5.1",
"ajv": "^8.12.0",
"axe-core": "4.7.2",
"classnames": "^2.3.2",
Expand Down
88 changes: 88 additions & 0 deletions src/tests/unit/mock-helpers/mock-module-helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import * as React from 'react';

export const expectMockedComponentPropsToMatchSnapshots = (
components: any[],
snapshotName?: string,
) => {
components.forEach(component => {
const componentSnapshotName =
snapshotName ||
`${component.displayName || component.name || 'mocked component'} props`;
expectMockedComponentPropsToMatchSnapshot(component, componentSnapshotName);
});
};

export function getMockComponentCall(obj, callNum = 1) {
const calls = (obj as any).mock?.calls || (obj as any).render.mock.calls;
return calls.length > callNum - 1 ? calls[callNum - 1] : [];
}

export function getMockComponentClassPropsForCall(obj, callNum = 1) {
return getMockComponentCall(obj, callNum)[0];
}

export function mockReactComponents(components: any[]) {
components.forEach(component => {
mockReactComponent(component);
});
}

function mockReactComponent<T extends React.ComponentClass<P>, P = any>(component, elementName?) {
if (component !== undefined) {
const name =
elementName || component.displayName
? `mock-${component.displayName}`
: `mock-${component.name}`;
if (
!(component as any).mockImplementation &&
!(component as any).render?.mockImplementation
) {
throw new Error(
`${name} is not a mockable component. Please add a jest.mock call for this component before using this component in the test function.`,
);
}
const mockFunction = mockReactElement<P>(name);

if (component.prototype && component.prototype.isReactComponent) {
//mock the class
const mockClass = (props: P, context?: any, ...rest: any[]) => ({
render: () => mockFunction(props, ...rest),
props,
context,
...rest,
});
(component as any).mockImplementation(mockClass);
} else {
//functional component
if ((component as any).render?.mockImplementation) {
component.render.mockImplementation(mockFunction);
}
if ((component as any).mockImplementation) {
(component as any).mockImplementation(mockFunction);
}
}
}
}

function expectMockedComponentPropsToMatchSnapshot(component: any, snapshotName?: string) {
snapshotName !== undefined
? expect(getMockComponentClassPropsForCall(component as any)).toMatchSnapshot(snapshotName)
: expect(getMockComponentClassPropsForCall(component as any)).toMatchSnapshot();
}

function mockReactElement<P = any>(elementName: string) {
const element: React.FC<React.PropsWithChildren<P>> = elementProps => {
try {
const { children, ...props } = elementProps;
return React.createElement(elementName, props, children);
} catch (e) {
if (e instanceof TypeError) {
return React.createElement('pre', {}, e.message);
}
throw e;
}
};
return element;
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`AssistedTestRecordYourResults renders 1`] = `
<li>
Record your results:
<ol>
<li>
Select
<Term>
Fail
</Term>
for any instances that do not meet the requirement.
</li>
<li>
Otherwise, select
<Term>
Pass
</Term>
. Or, after you have marked all failures, select
<Term>
Pass unmarked instances.
</Term>
</li>
</ol>
</li>
<DocumentFragment>
<li>
Record your results:
<ol>
<li>
Select
<strong>
Fail
</strong>
for any instances that do not meet the requirement.
</li>
<li>
Otherwise, select
<strong>
Pass
</strong>
. Or, after you have marked all failures, select
<strong>
Pass unmarked instances.
</strong>
</li>
</ol>
</li>
</DocumentFragment>
`;
Original file line number Diff line number Diff line change
@@ -1,55 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ManualTestRecordYourResultsTest render: isMultipleFailurePossible = false 1`] = `
<li>
Record your results:
<ol>
<li>
If you find
a failure
, select
<Term>
Fail
</Term>
,
then add the failure instance
.
</li>
<li>
Otherwise, select
<Term>
Pass
</Term>
.
</li>
</ol>
</li>
<DocumentFragment>
<li>
Record your results:
<ol>
<li>
If you find a failure, select
<strong>
Fail
</strong>
, then add the failure instance.
</li>
<li>
Otherwise, select
<strong>
Pass
</strong>
.
</li>
</ol>
</li>
</DocumentFragment>
`;

exports[`ManualTestRecordYourResultsTest render: isMultipleFailurePossible = true 1`] = `
<li>
Record your results:
<ol>
<li>
If you find
any failures
, select
<Term>
Fail
</Term>
,
then add them as failure instances
.
</li>
<li>
Otherwise, select
<Term>
Pass
</Term>
.
</li>
</ol>
</li>
<DocumentFragment>
<li>
Record your results:
<ol>
<li>
If you find any failures, select
<strong>
Fail
</strong>
, then add them as failure instances.
</li>
<li>
Otherwise, select
<strong>
Pass
</strong>
.
</li>
</ol>
</li>
</DocumentFragment>
`;
Loading

0 comments on commit a250ac0

Please sign in to comment.