Testing with Redux or React Router
Test Redux Connected Components
Let’s try to test PaymentForm
in src/modules/cart/components/payment-form.jsx
:
payment-form.spec.jsxjsx
import { render } from '@testing-library/react';import * as React from 'react';import { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {render(<PaymentForm />);});
payment-form.spec.jsxjsx
import { render } from '@testing-library/react';import * as React from 'react';import { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {render(<PaymentForm />);});
Woah! The test fails!
bash
FAIL src/modules/cart/components/payment-form.spec.jsx (5.568s)× PaymentForm can be rendered (69ms)● PaymentForm can be renderedCould not find "store" in the context of "Connect(PaymentFormView)". Either wrap the root component in a <Provider>, or pass a custom React context provider to <Provider> and the corresponding React context consumer to Connect(PaymentFormView) in connect options.
bash
FAIL src/modules/cart/components/payment-form.spec.jsx (5.568s)× PaymentForm can be rendered (69ms)● PaymentForm can be renderedCould not find "store" in the context of "Connect(PaymentFormView)". Either wrap the root component in a <Provider>, or pass a custom React context provider to <Provider> and the corresponding React context consumer to Connect(PaymentFormView) in connect options.
As explained in the error message, this is because we use connect
function from redux
, and it expects there is a Provider
as ancestor of the component. Let’s proceed to do that:
payment-form.spec.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../../root-reducer';import { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {const store = configureStore({ reducer: rootReducer });render(<Provider store={store}><PaymentForm /></Provider>);});
payment-form.spec.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../../root-reducer';import { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {const store = configureStore({ reducer: rootReducer });render(<Provider store={store}><PaymentForm /></Provider>);});
And the test passes!
Before we proceed further, let’s abstract the setup so we can use it everytime our test involves components requiring redux store.
Add a src/lib/test-util.jsx
file with the following content:
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../modules/root-reducer';export const renderWithStateMgmt = (ui) => {const store = configureStore({reducer: rootReducer,});const renderResult = render(<Provider store={store}>{ui}</Provider>);return {...renderResult,store,};};
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../modules/root-reducer';export const renderWithStateMgmt = (ui) => {const store = configureStore({reducer: rootReducer,});const renderResult = render(<Provider store={store}>{ui}</Provider>);return {...renderResult,store,};};
renderWithStateMgmt
accepts React element as its parameter, just likerender
from React Testing Library.- In addition of returning all results from
render
,renderWithStateMgmt
also returns the redux store, so your tests can dispatch additional actions as you wish.
Then we can use it in our test like this:
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util'; // highlight-lineimport { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {renderWithStateMgmt(<PaymentForm />); // highlight-line});
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util'; // highlight-lineimport { PaymentForm } from './payment-form';test(`PaymentForm can be rendered`, () => {renderWithStateMgmt(<PaymentForm />); // highlight-line});
And our test still passes!
The next test will be filling up the form. Before we do that, let’s check the rendered UI by using the debug
helper:
payment-form.spec.jsxjsx
...test(`PaymentForm can be filled`, () => {const { debug } = renderWithStateMgmt(<PaymentForm />);debug();});
payment-form.spec.jsxjsx
...test(`PaymentForm can be filled`, () => {const { debug } = renderWithStateMgmt(<PaymentForm />);debug();});
And we realize, the payment amount is 0.
This is because in actual scenario, customer need to add some product into cart before they able to see the payment form. Therefore, we need some way to simulate the behavior.
Let’s modify renderWithStateMgmt
to supports this use case:
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../modules/root-reducer';// highlight-next-lineexport const renderWithStateMgmt = (ui, { actions = [] } = {}) => {const store = configureStore({reducer: rootReducer,});actions.forEach((action) => store.dispatch(action)); // highlight-lineconst renderResult = render(<Provider store={store}>{ui}</Provider>);return {...renderResult,store,};};
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import * as React from 'react';import { Provider } from 'react-redux';import { rootReducer } from '../modules/root-reducer';// highlight-next-lineexport const renderWithStateMgmt = (ui, { actions = [] } = {}) => {const store = configureStore({reducer: rootReducer,});actions.forEach((action) => store.dispatch(action)); // highlight-lineconst renderResult = render(<Provider store={store}>{ui}</Provider>);return {...renderResult,store,};};
Then in our test:
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice'; // highlight-lineimport { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-startconst { debug } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});// highlight-enddebug();});
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice'; // highlight-lineimport { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-startconst { debug } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});// highlight-enddebug();});
And the amount is displayed! Let’s add an assertion:
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice'; // highlight-lineimport { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-next-lineconst { getByText } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});expect(getByText('RM 200.00')).not.toBeNull(); // highlight-line});
payment-form.spec.jsxjsx
import * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice'; // highlight-lineimport { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-next-lineconst { getByText } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});expect(getByText('RM 200.00')).not.toBeNull(); // highlight-line});
Then simulate user filling up the form:
payment-form.spec.jsxjsx
import { fireEvent } from '@testing-library/react'; // highlight-lineimport * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice';import { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-next-lineconst { getByText, getByLabelText } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});expect(getByText('RM 200.00')).not.toBeNull();// highlight-startexpect(getByText('Pay').disabled).toBe(true);fireEvent.change(getByLabelText('Card Number'), {target: {value: '5572336646354657',},});fireEvent.change(getByLabelText('Name'), {target: {value: 'James Bond',},});fireEvent.change(getByLabelText('Valid Thru'), {target: {value: '12/25',},});fireEvent.change(getByLabelText('CVC'), {target: {value: '123',},});expect(getByText('Pay').disabled).toBe(false);// highlight-end});
payment-form.spec.jsxjsx
import { fireEvent } from '@testing-library/react'; // highlight-lineimport * as React from 'react';import { renderWithStateMgmt } from '../../../lib/test-util';import { cartActions } from '../cart.slice';import { PaymentForm } from './payment-form';...test(`PaymentForm can be filled`, () => {// highlight-next-lineconst { getByText, getByLabelText } = renderWithStateMgmt(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});expect(getByText('RM 200.00')).not.toBeNull();// highlight-startexpect(getByText('Pay').disabled).toBe(true);fireEvent.change(getByLabelText('Card Number'), {target: {value: '5572336646354657',},});fireEvent.change(getByLabelText('Name'), {target: {value: 'James Bond',},});fireEvent.change(getByLabelText('Valid Thru'), {target: {value: '12/25',},});fireEvent.change(getByLabelText('CVC'), {target: {value: '123',},});expect(getByText('Pay').disabled).toBe(false);// highlight-end});
Test Components that Use React Router
Let’s continue the last test by clicking the Pay button:
payment-form.spec.jsxjsx
...test(`PaymentForm can be filled`, () => {...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay')); // highlight-line});
payment-form.spec.jsxjsx
...test(`PaymentForm can be filled`, () => {...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay')); // highlight-line});
And it fails!
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import { createMemoryHistory } from 'history'; // highlight-lineimport * as React from 'react';import { Provider } from 'react-redux';import { Router } from 'react-router-dom'; // highlight-lineimport { rootReducer } from '../modules/root-reducer';// highlight-next-lineexport const renderWithStateMgmtAndRouter = (ui,{ actions = [], route = '/' } = {}) => {const store = configureStore({reducer: rootReducer,});actions.forEach((action) => store.dispatch(action));// highlight-startconst history = createMemoryHistory({initialEntries: [route],});// highlight-endconst renderResult = render(// highlight-next-line<Router history={history}><Provider store={store}>{ui}</Provider>{/* highlight-next-line */}</Router>);return {...renderResult,store,history,};};
src/lib/test-util.jsxjsx
import { configureStore } from '@reduxjs/toolkit';import { render } from '@testing-library/react';import { createMemoryHistory } from 'history'; // highlight-lineimport * as React from 'react';import { Provider } from 'react-redux';import { Router } from 'react-router-dom'; // highlight-lineimport { rootReducer } from '../modules/root-reducer';// highlight-next-lineexport const renderWithStateMgmtAndRouter = (ui,{ actions = [], route = '/' } = {}) => {const store = configureStore({reducer: rootReducer,});actions.forEach((action) => store.dispatch(action));// highlight-startconst history = createMemoryHistory({initialEntries: [route],});// highlight-endconst renderResult = render(// highlight-next-line<Router history={history}><Provider store={store}>{ui}</Provider>{/* highlight-next-line */}</Router>);return {...renderResult,store,history,};};
Then we can test the behavior after click:
payment-form.spec.jsxjsx
...import { renderWithStateMgmtAndRouter } from '../../../lib/test-util'; // highlight-line...// highlight-next-linetest(`PaymentForm can be filled`, async () => {// highlight-next-lineconst { getByText, getByLabelText, findByText } = renderWithStateMgmtAndRouter(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay'));// highlight-next-lineawait findByText('Paid');});
payment-form.spec.jsxjsx
...import { renderWithStateMgmtAndRouter } from '../../../lib/test-util'; // highlight-line...// highlight-next-linetest(`PaymentForm can be filled`, async () => {// highlight-next-lineconst { getByText, getByLabelText, findByText } = renderWithStateMgmtAndRouter(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay'));// highlight-next-lineawait findByText('Paid');});
Snapshot Testing: What and When to Use It
Let’s change the code for PaymentForm
:
payment-form.jsxjsx
...const PaymentFormView = ({ defaultName, totalAmount, pay }) => {...return return paid ? (<Alert color="success">{/* highlight-next-line */}<p className="text-xl text-center" data-testid="success-msg">Paid</p><div className="text-center py-3"><Link to="/" className="text-blue-500">Back to Home</Link></div></Alert>) : (<React.Suspense fallback={<Spinner />}>...</React.Suspense>);}
payment-form.jsxjsx
...const PaymentFormView = ({ defaultName, totalAmount, pay }) => {...return return paid ? (<Alert color="success">{/* highlight-next-line */}<p className="text-xl text-center" data-testid="success-msg">Paid</p><div className="text-center py-3"><Link to="/" className="text-blue-500">Back to Home</Link></div></Alert>) : (<React.Suspense fallback={<Spinner />}>...</React.Suspense>);}
Then in the test:
payment-form.spec.jsxjsx
...import { renderWithStateMgmtAndRouter } from '../../../lib/test-util';...test(`PaymentForm can be filled`, async () => {// highlight-next-lineconst { getByText, getByLabelText, findByTestId } = renderWithStateMgmtAndRouter(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay'));const successMsg = await findByTestId('success-msg'); // highlight-next-lineexpect(successMsg).toMatchInlineSnapshot();});
payment-form.spec.jsxjsx
...import { renderWithStateMgmtAndRouter } from '../../../lib/test-util';...test(`PaymentForm can be filled`, async () => {// highlight-next-lineconst { getByText, getByLabelText, findByTestId } = renderWithStateMgmtAndRouter(<PaymentForm />, {actions: [cartActions.addItem({product: {id: 1,price: 200,},}),],});...expect(getByText('Pay').disabled).toBe(false);fireEvent.click(getByText('Pay'));const successMsg = await findByTestId('success-msg'); // highlight-next-lineexpect(successMsg).toMatchInlineSnapshot();});
Effective uses of snapshot:
- aims to verify the render output, not just to increase test coverage
- the size of snapshot should be small so it will be easily verified
- prefer
toMatchInlineSnapshot
overtoMatchSnapshot