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:
- test runner (the code that will extract all the test code from your code and run them, generate and display test results)
- assertion library (the utilities for you to make assertion about your result)
- 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)
- test coverage reports
- 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:
-
install the packages as devDependencies:
npm install -D jest babel-jest babel-core@7.0.0-bridge.0
-
add a
test
npm script inpackage.json
package.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:
// 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.
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:
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
orspec.js
. Therefore by naming the file aslib.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
andexpect
. -
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: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:
...
"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:
- add a new npm script:
"test:watch": "jest --watch"
- run
npm run test:watch
- 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:
-
add a new npm script:
package.json{ "scripts": { ... "test:coverage": "jest --coverage" } }
-
run
npm run test:coverage
-
explore the
coverage/lcov-report
folder that has been generated. Open theindex.html
file with your browser.
Exercise
- install Jest as described and configure your npm scripts.
- copy the utility code as provided and write the tests to test the function.
- run
npm run test
and verify that the tests are passed. - fix ESLint error as described
- run test in watch mode as described
- generate code coverage report as described
- (optional) write unit tests for the function
joinString
. - (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:
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:
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
andit
are two other global helpers injected by Jest in all test files.describe
is used to group tests into logical group, whileit
is equivalent totest
.- 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
andappendChild
. - 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
- setup our DOM by creating a div and append to body, then we use
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:
npm install -D react-testing-library
Let’s change busy-container.test.js
to the following:
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 usegetByTestId
andqueryByTestId
, which is just a wrapper overquerySelector
(the difference between the two isgetByTestId
will throws error if no result returns whilequeryByTestId
will not throw error and returnsnull
). For a full list of supported queries, refer to thereact-testing-library
Queries docs.
Exercise
- install
react-testing-library
as described. - modify
BusyContainer
and write the test for it. - 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.
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 fromreact-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 theloadMovies
function and mock a implementation that will return a Promise that resolve with ourmockMovieData
. - we use
wait
helper fromreact-testing-library
to introduce some delay. This is because theloadMovies
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