diff --git a/jest.config.js b/jest.config.js index 8072974..aaa76d0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,5 +9,6 @@ module.exports = createConfig('jest', { 'src/segment.js', 'src/postcss.config.js', 'testUtils', // don't unit test jest mocking tools + 'testUtilsExtra', // don't unit test jest mocking tools ], }); diff --git a/src/components/GradebookFilters/PercentGroup.test.jsx b/src/components/GradebookFilters/PercentGroup.test.jsx index dad676d..1addab8 100644 --- a/src/components/GradebookFilters/PercentGroup.test.jsx +++ b/src/components/GradebookFilters/PercentGroup.test.jsx @@ -1,7 +1,12 @@ import React from 'react'; -import { shallow } from '@edx/react-unit-test-utils'; +import { render, screen } from 'testUtilsExtra'; import PercentGroup from './PercentGroup'; +import { initializeMocks } from '../../testUtilsExtra'; + +jest.unmock('@openedx/paragon'); +jest.unmock('react'); +jest.unmock('@edx/frontend-platform/i18n'); describe('PercentGroup', () => { let props = { @@ -12,6 +17,7 @@ describe('PercentGroup', () => { }; beforeEach(() => { + initializeMocks(); props = { ...props, onChange: jest.fn().mockName('props.onChange'), @@ -19,15 +25,17 @@ describe('PercentGroup', () => { }); describe('Component', () => { - describe('snapshots', () => { - test('basic snapshot', () => { - const el = shallow(); - expect(el.snapshot).toMatchSnapshot(); - }); - test('disabled', () => { - const el = shallow(); - expect(el.snapshot).toMatchSnapshot(); - }); + test('is displayed', () => { + render(); + expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeInTheDocument(); + expect(screen.getByText('Group Label')).toBeVisible(); + expect(screen.getByText('%')).toBeVisible(); + }); + test('disabled', () => { + render(); + expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeDisabled(); + expect(screen.getByText('Group Label')).toBeVisible(); + expect(screen.getByText('%')).toBeVisible(); }); }); }); diff --git a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap b/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap deleted file mode 100644 index c0958d2..0000000 --- a/src/components/GradebookFilters/__snapshots__/PercentGroup.test.jsx.snap +++ /dev/null @@ -1,57 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PercentGroup Component snapshots basic snapshot 1`] = ` -
- - - Group Label - - - - - % - -
-`; - -exports[`PercentGroup Component snapshots disabled 1`] = ` -
- - - Group Label - - - - - % - -
-`; diff --git a/src/components/GradesView/PageButtons/__snapshots__/index.test.jsx.snap b/src/components/GradesView/PageButtons/__snapshots__/index.test.jsx.snap deleted file mode 100644 index a6728bd..0000000 --- a/src/components/GradesView/PageButtons/__snapshots__/index.test.jsx.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PageButtons component render snapshot 1`] = ` -
- - -
-`; diff --git a/src/components/GradesView/PageButtons/index.test.jsx b/src/components/GradesView/PageButtons/index.test.jsx index d49f328..9d2f014 100644 --- a/src/components/GradesView/PageButtons/index.test.jsx +++ b/src/components/GradesView/PageButtons/index.test.jsx @@ -1,53 +1,64 @@ import React from 'react'; -import { shallow } from '@edx/react-unit-test-utils'; -import { Button } from '@openedx/paragon'; +import { initializeMocks, render, screen } from 'testUtilsExtra'; import usePageButtonsData from './hooks'; import PageButtons from '.'; +jest.unmock('@openedx/paragon'); +jest.unmock('@edx/frontend-platform/i18n'); +jest.unmock('react'); jest.mock('./hooks', () => jest.fn()); const hookProps = { prev: { - disabled: 'prev-disabled', + disabled: false, onClick: jest.fn().mockName('hooks.prev.onClick'), text: 'prev-text', }, next: { - disabled: 'next-disabled', + disabled: false, onClick: jest.fn().mockName('hooks.next.onClick'), text: 'next-text', }, }; -usePageButtonsData.mockReturnValue(hookProps); -let el; describe('PageButtons component', () => { - beforeEach(() => { + beforeAll(() => { jest.clearAllMocks(); - el = shallow(); + initializeMocks(); }); - describe('behavior', () => { - it('initializes component hooks', () => { - expect(usePageButtonsData).toHaveBeenCalled(); + describe('renders enabled buttons', () => { + beforeEach(() => { + usePageButtonsData.mockReturnValue(hookProps); + render(); + }); + test('prev button enabled', () => { + expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument(); + expect(screen.getByText(hookProps.next.text)).toBeEnabled(); + }); + test('next button enabled', () => { + expect(screen.getByText(hookProps.next.text)).toBeInTheDocument(); + expect(screen.getByText(hookProps.prev.text)).toBeEnabled(); }); }); - describe('render', () => { - test('snapshot', () => { - expect(el.snapshot).toMatchSnapshot(); + + describe('renders disabled buttons', () => { + beforeAll(() => { + hookProps.prev.disabled = true; + hookProps.next.disabled = true; }); - test('prev button', () => { - const button = el.instance.findByType(Button)[0]; - expect(button.props.disabled).toEqual(hookProps.prev.disabled); - expect(button.props.onClick).toEqual(hookProps.prev.onClick); - expect(button.children[0].el).toEqual(hookProps.prev.text); + beforeEach(() => { + usePageButtonsData.mockReturnValue(hookProps); + render(); }); - test('next button', () => { - const button = el.instance.findByType(Button)[1]; - expect(button.props.disabled).toEqual(hookProps.next.disabled); - expect(button.props.onClick).toEqual(hookProps.next.onClick); - expect(button.children[0].el).toEqual(hookProps.next.text); + test('prev button disabled', () => { + expect(screen.getByText(hookProps.next.text)).toBeInTheDocument(); + expect(screen.getByText(hookProps.prev.text)).toBeDisabled(); + }); + test('next button disabled', () => { + expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument(); + expect(screen.getByText(hookProps.next.text)).toBeDisabled(); }); }); }); diff --git a/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap b/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap deleted file mode 100644 index c2ec543..0000000 --- a/src/components/GradesView/SearchControls/__snapshots__/index.test.jsx.snap +++ /dev/null @@ -1,20 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SearchControls component render snapshot 1`] = ` -
- - - test-hint-text - -
-`; diff --git a/src/components/GradesView/SearchControls/index.jsx b/src/components/GradesView/SearchControls/index.jsx index e0bf93f..0bdce73 100644 --- a/src/components/GradesView/SearchControls/index.jsx +++ b/src/components/GradesView/SearchControls/index.jsx @@ -21,7 +21,7 @@ export const SearchControls = () => {
jest.fn()); const hookProps = { @@ -17,32 +18,19 @@ const hookProps = { hintText: 'test-hint-text', }; useSearchControlsData.mockReturnValue(hookProps); - -let el; describe('SearchControls component', () => { beforeEach(() => { + initializeMocks(); + render(); jest.clearAllMocks(); - el = shallow(); - }); - describe('behavior', () => { - it('initializes component hooks', () => { - expect(useSearchControlsData).toHaveBeenCalled(); - }); }); describe('render', () => { - test('snapshot', () => { - expect(el.snapshot).toMatchSnapshot(); - }); test('search field', () => { - const { props } = el.instance.findByType(SearchField)[0]; - expect(props.onSubmit).toEqual(hookProps.onSubmit); - expect(props.onBlur).toEqual(hookProps.onBlur); - expect(props.onClear).toEqual(hookProps.onClear); - expect(props.inputLabel).toEqual(hookProps.inputLabel); - expect(props.value).toEqual(hookProps.searchValue); + expect(screen.getByLabelText(hookProps.inputLabel)).toBeInTheDocument(); + expect(screen.getByRole('searchbox')).toHaveValue(hookProps.searchValue); }); test('hint text', () => { - expect(el.instance.findByType('small')[0].children[0].el).toEqual(hookProps.hintText); + expect(screen.getByText(hookProps.hintText)).toBeInTheDocument(); }); }); }); diff --git a/src/components/GradesView/SpinnerIcon.test.jsx b/src/components/GradesView/SpinnerIcon.test.jsx index c981395..8de8427 100644 --- a/src/components/GradesView/SpinnerIcon.test.jsx +++ b/src/components/GradesView/SpinnerIcon.test.jsx @@ -1,35 +1,31 @@ import React from 'react'; -import { shallow } from '@edx/react-unit-test-utils'; +import { render } from '@testing-library/react'; import { selectors } from 'data/redux/hooks'; import SpinnerIcon from './SpinnerIcon'; +jest.unmock('@openedx/paragon'); +jest.unmock('react'); +jest.unmock('@edx/frontend-platform/i18n'); jest.mock('data/redux/hooks', () => ({ selectors: { root: { useShouldShowSpinner: jest.fn() }, }, })); -selectors.root.useShouldShowSpinner.mockReturnValue(true); -let el; describe('SpinnerIcon', () => { beforeEach(() => { jest.clearAllMocks(); - el = shallow(); }); - describe('behavior', () => { - it('initializes redux hook', () => { - expect(selectors.root.useShouldShowSpinner).toHaveBeenCalled(); - }); + it('does not render if show: false', () => { + selectors.root.useShouldShowSpinner.mockReturnValueOnce(false); + const { container } = render(); + expect(container.querySelector('.fa.fa-spinner')).not.toBeInTheDocument(); }); - describe('component', () => { - it('does not render if show: false', () => { - selectors.root.useShouldShowSpinner.mockReturnValueOnce(false); - el = shallow(); - expect(el.isEmptyRender()).toEqual(true); - }); - test('snapshot - displays spinner overlay with spinner icon', () => { - expect(el.snapshot).toMatchSnapshot(); - }); + + test('displays spinner overlay with spinner icon', () => { + selectors.root.useShouldShowSpinner.mockReturnValueOnce(true); + const { container } = render(); + expect(container.querySelector('.fa.fa-spinner')).toBeInTheDocument(); }); }); diff --git a/src/components/GradesView/__snapshots__/SpinnerIcon.test.jsx.snap b/src/components/GradesView/__snapshots__/SpinnerIcon.test.jsx.snap deleted file mode 100644 index c530fb3..0000000 --- a/src/components/GradesView/__snapshots__/SpinnerIcon.test.jsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SpinnerIcon component snapshot - displays spinner overlay with spinner icon 1`] = ` -
- -
-`; diff --git a/src/data/store.js b/src/data/store.js index dfb73b4..47e401e 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -11,7 +11,7 @@ import selectors from './selectors'; import reducers from './reducers'; import eventsMap from './services/segment/mapping'; -export const createStore = () => { +export const createStore = (preloadedState = undefined) => { const loggerMiddleware = createLogger(); const middleware = [thunkMiddleware, loggerMiddleware]; @@ -22,6 +22,7 @@ export const createStore = () => { const store = redux.createStore( reducers, composeWithDevTools(redux.applyMiddleware(...middleware)), + preloadedState, ); /** diff --git a/src/head/Head.test.jsx b/src/head/Head.test.jsx index c765c05..11bcdef 100644 --- a/src/head/Head.test.jsx +++ b/src/head/Head.test.jsx @@ -1,29 +1,24 @@ import React from 'react'; -import { shallow } from '@edx/react-unit-test-utils'; -import { getConfig } from '@edx/frontend-platform'; +import { mockConfigs } from 'setupTest'; +import { initializeMocks, render, waitFor } from 'testUtilsExtra'; + import Head from './Head'; -jest.mock('react-helmet', () => ({ - Helmet: () => 'Helmet', -})); -jest.mock('@edx/frontend-platform', () => ({ - getConfig: jest.fn(), -})); - -const config = { - SITE_NAME: 'test-site-name', - FAVICON_URL: 'test-favicon-url', -}; - -getConfig.mockReturnValue(config); +jest.unmock('@openedx/paragon'); +jest.unmock('@edx/frontend-platform/i18n'); +jest.unmock('react'); describe('Head', () => { - it('should match render title tag and favicon with the site configuration values', () => { - const el = shallow(); - const title = el.instance.findByType('title')[0]; - const link = el.instance.findByType('link')[0]; - expect(title.children[0].el).toEqual(`Gradebook | ${config.SITE_NAME}`); - expect(link.props.rel).toEqual('shortcut icon'); - expect(link.props.href).toEqual(config.FAVICON_URL); + it('should match render title tag and favicon with the site configuration values', async () => { + initializeMocks(); + render(); + + await waitFor(() => { + expect(document.title).toBe(`Gradebook | ${mockConfigs.SITE_NAME}`); + }); + + const favicon = document.querySelector('link[rel="shortcut icon"]'); + expect(favicon).toBeInTheDocument(); + expect(favicon.href).toBe(mockConfigs.FAVICON_URL); }); }); diff --git a/src/setupTest.js b/src/setupTest.js index 4aa6180..e8b5b1d 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -1,10 +1,22 @@ import '@testing-library/jest-dom'; +export const mockConfigs = { + SITE_NAME: 'test-site-name', + FAVICON_URL: 'http://localhost:18000/favicon.ico', + LMS_BASE_URL: 'http://localhost:18000', +}; // These configuration values are usually set in webpack's EnvironmentPlugin however // Jest does not use webpack so we need to set these so for testing -process.env.LMS_BASE_URL = 'http://localhost:18000'; -process.env.SITE_NAME = 'localhost'; -process.env.FAVICON_URL = 'http://localhost:18000/favicon.ico'; +// many are here to prevent warnings on the tests +process.env.LMS_BASE_URL = mockConfigs.LMS_BASE_URL; +process.env.SITE_NAME = mockConfigs.SITE_NAME; +process.env.FAVICON_URL = mockConfigs.FAVICON_URL; +process.env.BASE_URL = mockConfigs.LMS_BASE_URL; +process.env.LOGIN_URL = `${mockConfigs.LMS_BASE_URL}/login`; +process.env.LOGOUT_URL = `${mockConfigs.LMS_BASE_URL}/logout`; +process.env.REFRESH_ACCESS_TOKEN_ENDPOINT = `${mockConfigs.LMS_BASE_URL}/refresh_access_token`; +process.env.ACCESS_TOKEN_COOKIE_NAME = 'edx'; +process.env.CSRF_TOKEN_API_PATH = 'TOKEN_PATH'; jest.mock('@edx/frontend-platform/i18n', () => { const i18n = jest.requireActual('@edx/frontend-platform/i18n'); diff --git a/src/testUtilsExtra.jsx b/src/testUtilsExtra.jsx new file mode 100644 index 0000000..d89b7fb --- /dev/null +++ b/src/testUtilsExtra.jsx @@ -0,0 +1,145 @@ +/* eslint-disable import/no-extraneous-dependencies */ + +// This a file with extra things required for being able to mock services etc... +// Normally this is on the `testUtils`file, but given the current state of the tests +// It creates a non trivial circular dependency, which is avoided by not having the +// mocks that currently exists on testUtils, and that will be gone after the DEPR of react-unit-test-utils +// so to wrapup the migration this file needs to be integrated in testUtils as the last step. +import React from 'react'; +import PropTypes from 'prop-types'; +import { + MemoryRouter, Route, Routes, generatePath, +} from 'react-router'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; + +import { createStore } from './data/store'; + +/** @deprecated Use React Query and/or regular React Context instead of redux */ +let reduxStore; + +/** + * This component works together with the custom `render()` method we have in + * this file to provide whatever react-router context you need for your + * component. + * + * In the simplest case, you don't need to worry about the router at all, so + * just render your component using `render()`. + * + * The next simplest way to use it is to specify `path` (the route matching rule + * that is normally used to determine when to show the component or its parent + * page) and `params` like this: + * + * ``` + * render(, { path: '/library/:libraryId/*', params: { libraryId: 'lib:Axim:testlib' } }); + * ``` + * + * In this case, components that use the `useParams` hook will get the right + * library ID, and we don't even have to mock anything. + * + * In other cases, such as when you have routes inside routes, you'll need to + * set the router's `initialEntries` (URL history) prop yourself, like this: + * + * ``` + * render(, { + * path: '/library/:libraryId/*', + * // The root component is mounted on the above path, as it is in the "real" + * // MFE. But to access the 'settings' sub-route/component for this test, we + * // need tospecify the URL like this: + * routerProps: { initialEntries: [`/library/${libraryId}/settings`] }, + * }); + * ``` + */ +const RouterAndRoute = ({ + children, + path = '/', + params = {}, + routerProps = {}, +}) => { + if (Object.entries(params).length > 0 || path !== '/') { + const newRouterProps = { ...routerProps }; + if (!routerProps.initialEntries) { + // Substitute the params into the URL so '/library/:libraryId' becomes '/library/lib:org:123' + let pathWithParams = generatePath(path, params); + if (pathWithParams.endsWith('/*')) { + // Some routes (that contain child routes) need to end with /* in the but not in the router + pathWithParams = pathWithParams.substring(0, pathWithParams.length - 1); + } + newRouterProps.initialEntries = [pathWithParams]; + } + return ( + + + + + + ); + } + return ( + {children} + ); +}; + +RouterAndRoute.propTypes = { + children: PropTypes.node, + path: PropTypes.string, + params: PropTypes.shape({}), + routerProps: PropTypes.shape({}), +}; + +export const makeWrapper = ({ extraWrapper, ...routeArgs } = {}) => { + // eslint-disable-next-line react/prop-types + const AllTheProviders = ({ children }) => ( + + + + {extraWrapper ? React.createElement(extraWrapper, undefined, children) : children} + + + + ); + return AllTheProviders; +}; + +/** + * Same as render() from `@testing-library/react` but this one provides all the + * wrappers our React components need to render properly. + */ +function customRender(ui, options = {}) { + return render(ui, { wrapper: makeWrapper(options) }); +} + +const defaultUser = { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], +}; + +/** + * Initialize common mocks that many of our React components will require. + * + * This should be called within each test case, or in `beforeEach()`. + * + * Returns the new `axiosMock` in case you need to mock out axios requests. + */ +export function initializeMocks({ user = defaultUser, initialState = undefined } = {}) { + initializeMockApp({ + authenticatedUser: user, + }); + reduxStore = createStore(initialState); + + // Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history. + jest.clearAllMocks(); + + return { + reduxStore, + }; +} + +export * from '@testing-library/react'; +export { + customRender as render, +};