Skip to content

Commit

Permalink
refactor(testing): migrate from enzyme
Browse files Browse the repository at this point in the history
  • Loading branch information
cdcabrera committed Jul 18, 2023
1 parent 0697312 commit 5443987
Show file tree
Hide file tree
Showing 105 changed files with 7,259 additions and 7,762 deletions.
12 changes: 3 additions & 9 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,9 @@
"jsdoc": {}
},
"globals": {
"mockObjectProperty": "readonly",
"mountHook": "readonly",
"mountHookComponent": "readonly",
"mountHookWrapper": "readonly",
"mockWindowLocation": "readonly",
"shallowHook": "readonly",
"shallowHookComponent": "readonly",
"shallowHookWrapper": "readonly",
"skipIt": "readonly"
"renderHook": "readonly",
"renderComponent": "readonly",
"shallowComponent": "readonly"
},
"rules": {
"arrow-parens": [
Expand Down
303 changes: 221 additions & 82 deletions config/jest.setupTests.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { configure, mount, shallow } from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import { fireEvent, queries, render, screen } from '@testing-library/react';
import { prettyDOM } from '@testing-library/dom';
import { act } from 'react-dom/test-utils';
import * as reactRedux from 'react-redux';
import { setupDotenvFilesForEnv } from './build.dotenv';
Expand All @@ -10,11 +10,6 @@ import { setupDotenvFilesForEnv } from './build.dotenv';
*/
setupDotenvFilesForEnv({ env: process.env.NODE_ENV });

/**
* Set enzyme adapter.
*/
configure({ adapter: new Adapter() });

/**
* Emulate for component checks
*/
Expand All @@ -41,90 +36,160 @@ jest.mock('react-redux', () => ({
}));

/**
* Enzyme for components using hooks.
*
* @param {React.ReactNode} component
* @param {object} options
* @param {Function} options.callback
* @param {object} options.options
* Emulate react router dom useLocation
*/
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => jest.fn(),
useLocation: () => ({ hash: '', search: '' })
}));

/**
* React testing for components. Add a screen render function that outputs HTML.
* Used by "renderComponent".
*
* @returns {Promise<null>}
* @type {{render: Function}} The render output allows the use querySelector, querySelectorALl, but the
* caveat is the "found" elements are not usable with other testing methods since they are recreated HTML.
*/
global.mountHookComponent = async (component, { callback, ...options } = {}) => {
let mountedComponent = null;
await act(async () => {
mountedComponent = mount(component, options);
});
mountedComponent?.update();
global.screenRender = {
...screen,
render: (containerElement = screen) => {
const screenContainer = document.createElement('screen');
screenContainer.innerHTML = prettyDOM(containerElement.innerHTML, undefined, { highlight: false })
.replace(/(\s)+/g, ' ')
.replace(/>\s</g, '><');

if (typeof callback === 'function') {
await act(async () => {
await callback({ component: mountedComponent });
});
mountedComponent?.update();
return screenContainer;
}

return mountedComponent;
};

global.mountHookWrapper = global.mountHookComponent;

/**
* Enzyme for components using hooks.
* React testing for components.
* try "shallowComponent" if results are not expected... see "shallowComponent"
*
* @param {React.ReactNode} component
* @param {object} options
* @param {Function} options.callback
* @param {object} options.options
* try "renderComponent" if
* - hooks are used, and are not being passed in as mock props, and/or you want to skip writing mocks for hooks
* - html output is required
* - events are involved
*
* @returns {Promise<null>}
* @param {React.ReactNode} testComponent
* @param {object} options
* @returns {HTMLElement}
*/
global.shallowHookComponent = async (component, { callback, ...options } = {}) => {
let mountedComponent = null;
await act(async () => {
mountedComponent = shallow(component, options);
global.renderComponent = (testComponent, options = {}) => {
const updatedOptions = { includeInstanceRef: true, ...options };
const getDisplayName = reactComponent =>
reactComponent?.displayName ||
reactComponent?.$$typeof?.displayName ||
reactComponent?.$$typeof?.name ||
reactComponent?.name ||
reactComponent?.type?.displayName ||
reactComponent?.type?.name;

const componentInfo = {
displayName: getDisplayName(testComponent),
props: {
...testComponent?.props,
children: React.Children.toArray(testComponent?.props?.children).map(child => ({
displayName: getDisplayName(child),
props: child?.props,
type: child?.type
}))
}
};

const containerElement = document.createElement(componentInfo?.displayName || 'element');
try {
containerElement.setAttribute('props', JSON.stringify(componentInfo?.props || {}, null, 2));
} catch (e) {
//
}
containerElement.props = componentInfo.props;

const updatedTestComponent = { ...testComponent };
let elementInstance;

if (updatedTestComponent?.type?.prototype?.isReactComponent && updatedOptions.includeInstanceRef === true) {
updatedTestComponent.ref = element => {
elementInstance = element;
};
}

const { container, ...renderRest } = render(updatedTestComponent, {
container: containerElement,
queries,
...updatedOptions
});
mountedComponent?.update();

if (typeof callback === 'function') {
await act(async () => {
await callback({ component: mountedComponent });
const appendProps = obj => {
Object.entries(renderRest).forEach(([key, value]) => {
obj[key] = value; // eslint-disable-line
});
mountedComponent?.update();
}
};

return mountedComponent;
};
const updatedContainer = container;
updatedContainer.screen = global.screenRender;
updatedContainer.instance = elementInstance;
updatedContainer.find = selector => container?.querySelector(selector);
updatedContainer.fireEvent = fireEvent;
updatedContainer.setProps = updatedProps => {
const updatedComponent = { ...updatedTestComponent, props: { ...updatedTestComponent?.props, ...updatedProps } };
let rerender = renderRest.rerender(updatedComponent);

if (rerender === undefined) {
rerender = global.renderComponent(updatedComponent, { queries, ...updatedOptions });
}

if (rerender) {
rerender.find = selector => rerender?.querySelector(selector);
rerender.fireEvent = fireEvent;
rerender.setProps = updatedContainer.setProps;
appendProps(rerender);
}

return rerender;
};

global.shallowHookWrapper = global.shallowHookComponent;
appendProps(updatedContainer);

return updatedContainer;
};

/**
* Fire a hook, return the result.
*
* @param {Function} useHook
* @param {object} options
* @param {Function} options.callback A result callback fired after the hook in the same context.
* An alternative to using the "result" response.
* @param {object} options.state An object representing a mock Redux store's state.
* @returns {*}
*/
global.mountHook = async (useHook = Function.prototype, { state } = {}) => {
global.renderHook = async (useHook = Function.prototype, { callback, state } = {}) => {
let result;
let mountedHook;
let spyUseSelector;
let unmountHook;

const Hook = () => {
result = useHook();
return null;
};

const unmount = async () => {
await act(async () => unmountHook());
};

await act(async () => {
if (state) {
spyUseSelector = jest.spyOn(reactRedux, 'useSelector').mockImplementation(_ => _(state));
}
mountedHook = mount(<Hook />);
});
mountedHook?.update();
const { unmount: unmountRender } = await render(<Hook />);
unmountHook = unmountRender;

const unmount = async () => {
await act(async () => mountedHook.unmount());
};
if (typeof callback === 'function') {
callback({ unmount, result });
}
});

if (state) {
spyUseSelector.mockClear();
Expand All @@ -134,47 +199,121 @@ global.mountHook = async (useHook = Function.prototype, { state } = {}) => {
};

/**
* Fire a hook, return the result.
* Quick React function component results testing. Results may not be helpful.
* try "renderComponent" if results are not expected... see "renderComponent"
*
* @param {Function} useHook
* @param {object} options
* @param {object} options.state An object representing a mock Redux store's state.
* use "shallowComponent" if
* - the component is a function, class results may not be expected
* - all hooks are passed in as props
* - you want a quick component response typically determined by a condition
* - snapshot size needs to be reduced
*
* @param {React.ReactNode} testComponent
* @returns {*}
*/
global.shallowHook = (useHook = Function.prototype, { state } = {}) => {
let result;
let spyUseSelector;
const Hook = () => {
result = useHook();
return null;
};
global.shallowComponent = async testComponent => {
const localRenderHook = async (component, updatedProps) => {
if (typeof component?.type === 'function') {
try {
const { unmount, result } = await global.renderHook(() =>
component.type({ ...component.type.defaultProps, ...component.props, ...updatedProps })
);

if (state) {
spyUseSelector = jest.spyOn(reactRedux, 'useSelector').mockImplementation(_ => _(state));
}
if (!result || typeof result === 'string' || typeof result === 'number') {
return result;
}

const shallowHook = shallow(<Hook />);
const unmount = () => shallowHook.unmount();
const querySelector = (sel, _internalRender = result) => {
const { container } = render(_internalRender);
return container.querySelector(sel);
};

if (state) {
spyUseSelector.mockClear();
}
const querySelectorAll = (sel, _internalRender = result) => {
const { container } = render(_internalRender);
return container.querySelectorAll(sel);
};

return { unmount, result };
const setProps = async p => localRenderHook(component, p);

const renderResult = () => global.renderComponent(result);

if (Array.isArray(result)) {
const updatedR = result;
updatedR.unmount = unmount;
updatedR.render = renderResult;
updatedR.find = querySelector;
updatedR.querySelector = querySelector;
updatedR.querySelectorAll = querySelectorAll;
updatedR.setProps = setProps;
return updatedR;
}

return {
...result,
unmount,
render: renderResult,
find: querySelector,
querySelector,
querySelectorAll,
setProps
};
} catch (e) {
//
}
}

return component;
};

return localRenderHook(testComponent);
};

// ToDo: revisit squashing log and group messaging, redux leaks log messaging
// ToDo: revisit squashing PF4 "popper" alerts
// ToDo: revisit squashing PF4 "validateDOMNesting" select alerts
// ToDo: revisit squashing PF4 "validateDOMNesting" table alerts
/*
* Apply invalid prop, failed prop checks.
* jest-prop-type-error, https://www.npmjs.com/package/jest-prop-type-error
* For applying a global Jest "beforeAll", based on
* - jest-prop-type-error, https://www.npmjs.com/package/jest-prop-type-error
* - renderComponent, test function testComponent messaging
* - SVG syntax
* - PF4 popper alerts and validateDOMNesting for select, table
*/
beforeAll(() => {
const { error } = console;
const { error, group, log } = console;

const interceptConsoleMessaging = (method, callback) => {
console[method.name] = (message, ...args) => {
const isValid = callback(message, ...args);
if (isValid === true) {
method.apply(console, [message, ...args]);
}
};
};

interceptConsoleMessaging(group, () => process.env.CI !== 'true');

interceptConsoleMessaging(log, () => process.env.CI !== 'true');

console.error = (message, ...args) => {
interceptConsoleMessaging(error, (message, ...args) => {
if (/(Invalid prop|Failed prop type)/gi.test(message)) {
throw new Error(message);
}

error.apply(console, [message, ...args]);
};
// ignore SVG and other xml
if (
/<testComponent/gi.test(message) ||
args?.[0] === 'testComponent' ||
/<foreignObject/gi.test(message) ||
args?.[0] === 'foreignObject' ||
/<g/gi.test(message) ||
args?.[0] === 'g' ||
(/validateDOMNesting/gi.test(message) && args?.[0] === '<div>' && args?.[1] === 'select') ||
(/validateDOMNesting/gi.test(message) && args?.[0] === '<div>' && args?.[1] === 'table')
) {
return false;
}

return !/(Not implemented: navigation)/gi.test(message) && !/Popper/gi.test(args?.[0]);
});
});
Loading

0 comments on commit 5443987

Please sign in to comment.