Testing with Redux or React Router

What you'll learn

  • write tests to verify behavior of React component that use Redux
  • write tests to verify behavior of React component that use React Router
  • the right way of using snapshot testing

Test Redux Connected Components

Let's try to test PaymentForm in src/modules/cart/components/payment-form.jsx:

payment-form.spec.jsx
jsx
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 rendered
Could 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.jsx
jsx
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.jsx
jsx
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 like render 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.jsx
jsx
import * as React from 'react';
import { renderWithStateMgmt } from '../../../lib/test-util';

import { PaymentForm } from './payment-form';
test(`PaymentForm can be rendered`, () => {
renderWithStateMgmt(<PaymentForm />);

});

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.jsx
jsx
...
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.jsx
jsx
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, { actions = [] } = {}) => {

const store = configureStore({
reducer: rootReducer,
});
actions.forEach((action) => store.dispatch(action));

const renderResult = render(<Provider store={store}>{ui}</Provider>);
return {
...renderResult,
store,
};
};

Then in our test:

payment-form.spec.jsx
jsx
import * 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`, () => {
const { debug } = renderWithStateMgmt(<PaymentForm />, {

actions: [

cartActions.addItem({

product: {

id: 1,

price: 200,

},

}),

],

});

debug();
});

And the amount is displayed! Let's add an assertion:

payment-form.spec.jsx
jsx
import * 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`, () => {
const { getByText } = renderWithStateMgmt(<PaymentForm />, {

actions: [
cartActions.addItem({
product: {
id: 1,
price: 200,
},
}),
],
});
expect(getByText('RM 200.00')).not.toBeNull();

});

Then simulate user filling up the form:

payment-form.spec.jsx
jsx
import { fireEvent } from '@testing-library/react';

import * 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`, () => {
const { getByText, getByLabelText } = renderWithStateMgmt(<PaymentForm />, {

actions: [
cartActions.addItem({
product: {
id: 1,
price: 200,
},
}),
],
});
expect(getByText('RM 200.00')).not.toBeNull();
expect(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);

});

Test Components that Use React Router

Let's continue the last test by clicking the Pay button:

payment-form.spec.jsx
jsx
...
test(`PaymentForm can be filled`, () => {
...
expect(getByText('Pay').disabled).toBe(false);
fireEvent.click(getByText('Pay'));

});

And it fails!

src/lib/test-util.jsx
jsx
import { configureStore } from '@reduxjs/toolkit';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';

import * as React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';

import { rootReducer } from '../modules/root-reducer';
export const renderWithStateMgmtAndRouter = (

ui,
{ actions = [], route = '/' } = {}
) => {
const store = configureStore({
reducer: rootReducer,
});
actions.forEach((action) => store.dispatch(action));
const history = createMemoryHistory({

initialEntries: [route],

});

const renderResult = render(
<Router history={history}>

<Provider store={store}>{ui}</Provider>
</Router>

);
return {
...renderResult,
store,
history,
};
};

Then we can test the behavior after click:

payment-form.spec.jsx
jsx
...
import { renderWithStateMgmtAndRouter } from '../../../lib/test-util';

...
test(`PaymentForm can be filled`, async () => {

const { getByText, getByLabelText, findByText } = renderWithStateMgmtAndRouter(<PaymentForm />, {

actions: [
cartActions.addItem({
product: {
id: 1,
price: 200,
},
}),
],
});
...
expect(getByText('Pay').disabled).toBe(false);
fireEvent.click(getByText('Pay'));
await findByText('Paid');

});

Snapshot Testing: What and When to Use It

Let's change the code for PaymentForm:

payment-form.jsx
jsx
...
const PaymentFormView = ({ defaultName, totalAmount, pay }) => {
...
return return paid ? (
<Alert color="success">
<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.jsx
jsx
...
import { renderWithStateMgmtAndRouter } from '../../../lib/test-util';
...
test(`PaymentForm can be filled`, async () => {
const { getByText, getByLabelText, findByTestId } = renderWithStateMgmtAndRouter(<PaymentForm />, {

actions: [
cartActions.addItem({
product: {
id: 1,
price: 200,
},
}),
],
});
...
expect(getByText('Pay').disabled).toBe(false);
fireEvent.click(getByText('Pay'));
expect(successMsg).toMatchInlineSnapshot();

});

Effective uses of snapshot:

  1. aims to verify the render output, not just to increase test coverage
  2. the size of snapshot should be small so it will be easily verified
  3. prefer toMatchInlineSnapshot over toMatchSnapshot

Issue on this page? Report here