React Testing

Testing used to be something that requires a lot efforts to setup, because there are a few moving parts for you to write tests:

  1. test runner (the code that will extract all the test code from your code and run them, generate and display test results)
  2. assertion library (the utilities for you to make assertion about your result)
  3. mocking and spying (in a real-world application, you would definitely need mocking whenever there are indeterminate functionlities or it’s expensive to setup end-to-end application)
  4. test coverage reports
  5. provide a browser or browser-like environment

Thanks to Jest, all of these are included in single library with minimal configuration.

Setup Jest

To setup Jest for our application:

  1. install the packages as devDependencies:

    bash
    npm install -D jest babel-jest babel-core@7.0.0-bridge.0
    bash
    npm install -D jest babel-jest babel-core@7.0.0-bridge.0
  2. add a test npm script in package.json

    package.json
    json
    {
    "scripts": {
    ...
    "test": "jest"
    }
    }
    package.json
    json
    {
    "scripts": {
    ...
    "test": "jest"
    }
    }

Now you’re good to go to write your test and run them.

Testing Javascript (not React specific)

Let’s start with writing test for javascript function.

Once you get an idea of how to test javascript, then we will go through how to test React component.

Creating utility function to generate className

A common utility that you would need when writing React is to generate className to be attached to DOM element for styling based on props.

The offical packages to do that is a package known as classnames, however, for the sake of learning let’s write it as our own code.

Create a file lib.js with the content from this gist.

  • classNames is a function that take any number of arguments, and join them together as a string. Only string and number will be included in final results and falsy value will be excluded. Array will be flattened

  • Example usage:

    js
    // simple usage
    classNames('btn', 'btn--default'); // 'btn btn--default'
    // use ternary expression as falsy value will be ignored
    classNames('btn', true && 'btn--default', false && 'btn--raised', null); // 'btn btn--default'
    // you may pass down array if you wish, and it will be flattened
    classNames(['btn', null, 'btn--default']); // 'btn btn--default'
    js
    // simple usage
    classNames('btn', 'btn--default'); // 'btn btn--default'
    // use ternary expression as falsy value will be ignored
    classNames('btn', true && 'btn--default', false && 'btn--raised', null); // 'btn btn--default'
    // you may pass down array if you wish, and it will be flattened
    classNames(['btn', null, 'btn--default']); // 'btn btn--default'

Create test for the utility function

Let’s write unit test for the utility function.

Create a file lib.test.js next to lib.js. Write the following test case as per usage above.

src/lib.test.js
js
import { classNames } from './lib.js';
test('classNames', () => {
expect(classNames('btn', 'btn--default')).toBe('btn btn--default');
expect(classNames('btn', true && 'btn--default', false && 'btn--raised', null)).toBe(
'btn btn--default'
);
expect(classNames(['btn', null, 'btn--default'])).toBe('btn btn--default');
});
src/lib.test.js
js
import { classNames } from './lib.js';
test('classNames', () => {
expect(classNames('btn', 'btn--default')).toBe('btn btn--default');
expect(classNames('btn', true && 'btn--default', false && 'btn--raised', null)).toBe(
'btn btn--default'
);
expect(classNames(['btn', null, 'btn--default'])).toBe('btn btn--default');
});

Now when you run npm run test, you should be able to see the following output:

bash
PASS src/lib.test.js
√ classNames (5ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.789s, estimated 2s
Ran all test suites.
bash
PASS src/lib.test.js
√ classNames (5ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.789s, estimated 2s
Ran all test suites.

Congratulations! You just written your first test.

  • By default, Jest will look for any files that is inside folder __test__ or file name end with .test.js or spec.js. Therefore by naming the file as lib.test.js, the file will be treated as test file that Jest need to run. I recommend to place the test file next to the code that it’s testing with the naming convention <code-under-test>.test.js, so that it’s clear on the purpose of the test, and what code has test associated with it.

  • When Jest run the test file, it will injects few variables globally, e.g. test and expect.

  • test is used to wrap your unit test and give it a name. When your test fails, the test name will be displayed in the console.

  • expect is used to assert the result of your test. Common usages are:

    js
    expect(result).toBe(expected); // use ==== for equality check
    expect(result).toEqual(expected); // recursively check for value equality, this is useful when you want to verify the value but not the identity
    // you can prefix with .not to invert the assertion
    expect(result).not.toBe(unexpected);
    // some common checking is included for your convenience
    expect(result).toBeDefined(); // equivalent to expect(result).not.toBe(undefined);
    js
    expect(result).toBe(expected); // use ==== for equality check
    expect(result).toEqual(expected); // recursively check for value equality, this is useful when you want to verify the value but not the identity
    // you can prefix with .not to invert the assertion
    expect(result).not.toBe(unexpected);
    // some common checking is included for your convenience
    expect(result).toBeDefined(); // equivalent to expect(result).not.toBe(undefined);

    Read through the Jest expect docs to get an idea of the supported assertions.

Additional Configurations of Jest

Let’s explore some common configurations when using Jest.

Fix ESLint Error

You may realize that ESLint is showing error in your test file that 'test' is not defined and 'expect' is not defined. However, to fix that error is just a line of code. Update your .eslintrc file env properties:

.eslintrc
json
...
"env": {
"es6": true,
"browser": true,
"node": true,
"jest": true
}
...
.eslintrc
json
...
"env": {
"es6": true,
"browser": true,
"node": true,
"jest": true
}
...

Now the error should be gone now.

Watch mode

It’s common that you may want to keep Jest in watch mode while writing tests, so any change of the test will trigger a re-run and ensure the test is passed.

To run Jest in watch mode:

  1. add a new npm script: "test:watch": "jest --watch"
  2. run npm run test:watch
  3. explore the watch mode options (p to filter filename, t to filter test name)

Code Coverage Reports

You may want to explore how many of your code is covered.

To generate code coverage report:

  1. add a new npm script:

    package.json
    json
    {
    "scripts": {
    ...
    "test:coverage": "jest --coverage"
    }
    }
    package.json
    json
    {
    "scripts": {
    ...
    "test:coverage": "jest --coverage"
    }
    }
  2. run npm run test:coverage

  3. explore the coverage/lcov-report folder that has been generated. Open the index.html file with your browser.

Exercise

  1. install Jest as described and configure your npm scripts.
  2. copy the utility code as provided and write the tests to test the function.
  3. run npm run test and verify that the tests are passed.
  4. fix ESLint error as described
  5. run test in watch mode as described
  6. generate code coverage report as described
  7. (optional) write unit tests for the function joinString.
  8. (optional) increase conditional coverage of lib.js to 100%.

Commit: 120-jest-setup-and-test

Testing React Components

Before we start writing tests for React components, let’s take a step back and discuss how we write a test.

When writing tests for a function, it is mostly about asserting the returns of the function given a specific parameters. The convention is When Y, then Z. For instance, when calling classNames with parameters of ‘btn’ and ‘btn—default’, then it will returns the result of 'btn btn--default'.

Same test structure would applies when writing tests for React Components. The difference of React components is we do not call React Component itself directly and get the output of the component, but pass the output to ReactDOM.render, which will decide what to append/update in the DOM.

Let’s explore how to do that.

Write React Component test

We will write test for busy-container.js. But before that, let’s modify BusyContainer slightly:

src/busy-container.js
jsx
import * as React from 'react';
export const BusyContainer = ({ isLoading, children }) => (
<div>
{isLoading && <span data-testid="loading-indicator">loading...</span>}
{children}
</div>
);
src/busy-container.js
jsx
import * as React from 'react';
export const BusyContainer = ({ isLoading, children }) => (
<div>
{isLoading && <span data-testid="loading-indicator">loading...</span>}
{children}
</div>
);

Let’s create a file busy-container.test.js next to busy-container.js with the following contents:

src/busy-container.test.js
jsx
import * as React from 'react';
import ReactDOM from 'react-dom';
import { BusyContainer } from './busy-container';
describe('BusyContainer', () => {
it('is defined', () => {
expect(BusyContainer).toBeDefined();
});
it('renders loading indicator when props is loading', () => {
// Thanks to JSDOM (included part of Jest), we have access to browser object like document
const div = document.createElement('div');
document.body.appendChild(div);
ReactDOM.render(
<BusyContainer isLoading={true}>
<div id="children">Hello Test</div>
</BusyContainer>,
div
);
const loadingIndicator = div.querySelector('[data-testid="loading-indicator"]');
expect(loadingIndicator).toBeDefined();
// cleanup after test is done
ReactDOM.unmountComponentAtNode(div);
document.body.removeChild(div);
});
it('not renders loading indicator when props loading = false', () => {
const div = document.createElement('div');
document.body.appendChild(div);
ReactDOM.render(
<BusyContainer isLoading={false}>
<div id="children">Hello Test</div>
</BusyContainer>,
div
);
const loadingIndicator = div.querySelector('[data-testid="loading-indicator"]');
expect(loadingIndicator).toBe(null);
// cleanup after test is done
ReactDOM.unmountComponentAtNode(div);
document.body.removeChild(div);
});
});
src/busy-container.test.js
jsx
import * as React from 'react';
import ReactDOM from 'react-dom';
import { BusyContainer } from './busy-container';
describe('BusyContainer', () => {
it('is defined', () => {
expect(BusyContainer).toBeDefined();
});
it('renders loading indicator when props is loading', () => {
// Thanks to JSDOM (included part of Jest), we have access to browser object like document
const div = document.createElement('div');
document.body.appendChild(div);
ReactDOM.render(
<BusyContainer isLoading={true}>
<div id="children">Hello Test</div>
</BusyContainer>,
div
);
const loadingIndicator = div.querySelector('[data-testid="loading-indicator"]');
expect(loadingIndicator).toBeDefined();
// cleanup after test is done
ReactDOM.unmountComponentAtNode(div);
document.body.removeChild(div);
});
it('not renders loading indicator when props loading = false', () => {
const div = document.createElement('div');
document.body.appendChild(div);
ReactDOM.render(
<BusyContainer isLoading={false}>
<div id="children">Hello Test</div>
</BusyContainer>,
div
);
const loadingIndicator = div.querySelector('[data-testid="loading-indicator"]');
expect(loadingIndicator).toBe(null);
// cleanup after test is done
ReactDOM.unmountComponentAtNode(div);
document.body.removeChild(div);
});
});
  • describe and it are two other global helpers injected by Jest in all test files. describe is used to group tests into logical group, while it is equivalent to test.
  • As Jest will run the test in jsdom (a environment that runs in NodeJS and supports most browser features listed in web standards), so we have access to DOM API like document.createElement, querySelector and appendChild.
  • For each test, we need to
    • setup our DOM by creating a div and append to body, then we use ReactDOM to render our components
    • use querySelector to check the current state of the DOM and assert it.
    • unmount the component with ReactDOM.unmountComponentAtNode, then remove the container from the body

As the setup and cleanup are required and similar for all tests, there is a library that already implements them with a bunch of helpers. The library is react-testing-library (surprise, surprise!). Let’s install that:

bash
npm install -D react-testing-library
bash
npm install -D react-testing-library

Let’s change busy-container.test.js to the following:

busy-container.test.js
jsx
import * as React from 'react';
import { render, cleanup } from 'react-testing-library';
import { BusyContainer } from './busy-container';
afterEach(cleanup);
describe('BusyContainer', () => {
it('is defined', () => {
expect(BusyContainer).toBeDefined();
});
it('renders loading indicator when props is loading', () => {
const { getByTestId } = render(
<BusyContainer isLoading={true}>
<div id="children">Hello Test</div>
</BusyContainer>
);
const loadingIndicator = getByTestId('loading-indicator');
expect(loadingIndicator).toBeDefined();
});
it('not renders loading indicator when props loading = false', () => {
const { queryByTestId } = render(
<BusyContainer isLoading={false}>
<div id="children">Hello Test</div>
</BusyContainer>
);
const loadingIndicator = queryByTestId('loading-indicator');
expect(loadingIndicator).toBe(null);
});
});
busy-container.test.js
jsx
import * as React from 'react';
import { render, cleanup } from 'react-testing-library';
import { BusyContainer } from './busy-container';
afterEach(cleanup);
describe('BusyContainer', () => {
it('is defined', () => {
expect(BusyContainer).toBeDefined();
});
it('renders loading indicator when props is loading', () => {
const { getByTestId } = render(
<BusyContainer isLoading={true}>
<div id="children">Hello Test</div>
</BusyContainer>
);
const loadingIndicator = getByTestId('loading-indicator');
expect(loadingIndicator).toBeDefined();
});
it('not renders loading indicator when props loading = false', () => {
const { queryByTestId } = render(
<BusyContainer isLoading={false}>
<div id="children">Hello Test</div>
</BusyContainer>
);
const loadingIndicator = queryByTestId('loading-indicator');
expect(loadingIndicator).toBe(null);
});
});
  • cleanup will perform the cleanup step of unmount component and remove container that we did manually previously.
  • render will create a container and mount our component in the container, as we did manually previously.
  • render will also returns a few helpers for us to query the DOM. In our case, we use getByTestId and queryByTestId, which is just a wrapper over querySelector (the difference between the two is getByTestId will throws error if no result returns while queryByTestId will not throw error and returns null). For a full list of supported queries, refer to the react-testing-library Queries docs.

Exercise

  1. install react-testing-library as described.
  2. modify BusyContainer and write the test for it.
  3. ensure all the tests are passed

Commit: 130-react-test

Write React Component test that check stateful behavior

The previous React test is quite straight-forward as the BusyContainer is simple (as it should be!). Following is a sample of more complex React component test that test our App component.

jsx
import * as React from 'react';
import { render, fireEvent, wait } from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import App from './app';
import * as api from './api';
const mockMovieData = [
{
id: 1,
name: 'Aquaman',
releaseDate: '2018-12-07',
description:
'Arthur Curry learns that he is the heir to the underwater kingdom of Atlantis, and must step forward to lead his people and be a hero to the world.',
},
{
id: 2,
name: 'Bumblebee',
releaseDate: '2018-12-15',
description:
'On the run in the year 1987, Bumblebee finds refuge in a junkyard in a small Californian beach town. Charlie, on the cusp of turning 18 and trying to find her place in the world, discovers Bumblebee, battle-scarred and broken. When Charlie revives him, she quickly learns this is no ordinary yellow VW bug.',
},
{
id: 3,
name: 'Fantastic Beasts: The Crimes of Grindelwald',
releaseDate: '2018-11-14',
description:
'Gellert Grindelwald has escaped imprisonment and has begun gathering followers to his cause—elevating wizards above all non-magical beings. The only one capable of putting a stop to him is the wizard he once called his closest friend, Albus Dumbledore. However, Dumbledore will need to seek help from the wizard who had thwarted Grindelwald once before, his former student Newt Scamander, who agrees to help, unaware of the dangers that lie ahead. Lines are drawn as love and loyalty are tested, even among the truest friends and family, in an increasingly divided wizarding world.',
},
];
describe('<App />', () => {
it('is defined', () => {
expect(App).toBeDefined();
});
it('shows movie list when show button is clicked', () => {
const { getByText, getByTestId } = render(<App />);
fireEvent.click(getByText('Show Movies'));
expect(getByTestId('loading-indicator')).toBeDefined();
});
it('displays movies list when show button is clicked and data is loaded', async () => {
jest.spyOn(api, 'loadMovies').mockImplementation(() => Promise.resolve(mockMovieData));
const { getByText, getAllByTestId } = render(<App />);
await wait();
fireEvent.click(getByText('Show Movies'));
// Note: assertion below requires `data-testid` attribute in Movie component
expect(getAllByTestId('movie').length).toBe(mockMovieData.length);
});
});
jsx
import * as React from 'react';
import { render, fireEvent, wait } from 'react-testing-library';
import 'react-testing-library/cleanup-after-each';
import App from './app';
import * as api from './api';
const mockMovieData = [
{
id: 1,
name: 'Aquaman',
releaseDate: '2018-12-07',
description:
'Arthur Curry learns that he is the heir to the underwater kingdom of Atlantis, and must step forward to lead his people and be a hero to the world.',
},
{
id: 2,
name: 'Bumblebee',
releaseDate: '2018-12-15',
description:
'On the run in the year 1987, Bumblebee finds refuge in a junkyard in a small Californian beach town. Charlie, on the cusp of turning 18 and trying to find her place in the world, discovers Bumblebee, battle-scarred and broken. When Charlie revives him, she quickly learns this is no ordinary yellow VW bug.',
},
{
id: 3,
name: 'Fantastic Beasts: The Crimes of Grindelwald',
releaseDate: '2018-11-14',
description:
'Gellert Grindelwald has escaped imprisonment and has begun gathering followers to his cause—elevating wizards above all non-magical beings. The only one capable of putting a stop to him is the wizard he once called his closest friend, Albus Dumbledore. However, Dumbledore will need to seek help from the wizard who had thwarted Grindelwald once before, his former student Newt Scamander, who agrees to help, unaware of the dangers that lie ahead. Lines are drawn as love and loyalty are tested, even among the truest friends and family, in an increasingly divided wizarding world.',
},
];
describe('<App />', () => {
it('is defined', () => {
expect(App).toBeDefined();
});
it('shows movie list when show button is clicked', () => {
const { getByText, getByTestId } = render(<App />);
fireEvent.click(getByText('Show Movies'));
expect(getByTestId('loading-indicator')).toBeDefined();
});
it('displays movies list when show button is clicked and data is loaded', async () => {
jest.spyOn(api, 'loadMovies').mockImplementation(() => Promise.resolve(mockMovieData));
const { getByText, getAllByTestId } = render(<App />);
await wait();
fireEvent.click(getByText('Show Movies'));
// Note: assertion below requires `data-testid` attribute in Movie component
expect(getAllByTestId('movie').length).toBe(mockMovieData.length);
});
});
  • we define mockMovieData which will be used to act as mock response for the api call. Usually you can get his via the data contract that has been agreed with your the API developer or via the sample REST call to the actual API.
  • we use fireEvent helper from react-testing-library to simulate event. In the tests, we use it to simulate click event. You can use it to simulate most of the browser events, e.g. focus, blur, change etc.
  • we use jest.spyOn to spy the calling of the loadMovies function and mock a implementation that will return a Promise that resolve with our mockMovieData.
  • we use wait helper from react-testing-library to introduce some delay. This is because the loadMovies returns a promise, which will only be resolve in next ticks on the JS event cycle.
  • we use getAllByTestId to get the count of the mounted movie components and asserts the count is equal to the number of movies in our mock data.

Commit: 131-react-test-stateful