Learning Unit testing - Jest and React Testing Library
Read about how to begin unit testing in your project, what are the challenges you might face, and my short story around learning unit testing.
Table of Content
- Jest, React Testing library and Enzyme
- Unit testing in Create React App (CRA)
- Mocking - When to use what?
- Async function testing
- Route testing
- Assertions, global methods, and cleanup
- Formik testing
- What you should avoid with React Testing Library
- Testing hygiene
- Visual satisfaction
- Resources
It was long due that I paid. I wanted to learn unit testing but never moved towards it. Finally, I decided and the day came when I started learning from the TestingJavaScript course by KCD. I was blown away π€― when he showed a simple assertion without any library.
// Function - Often referred as test subject
const sum = (a, b) => a - b;
// Function to make assertion
function expect(actual) {
return {
toBe(expected) {
if (actual !== expected) {
throw new Error(`${actual} is not equal to ${expected}`);
}
}
}
};
// Unit testing
const result = sum(3, 7);
const expected = 10;
expect(result).toBe(expected); // Uncaught Error: -4 is not equal to 10
I started gaining interest and finally completed the course. You might be aware that gaining knowledge and understanding of how to apply are two different things. So I decided to announce on Twitter that Iβm ready to contribute to a small public project where I can apply my newly acquired skill.
I received only one response with a small side project and luckily it was a perfect fit for me. I picked up the project and started with the test configuration setup and then writing ever-growing unit test cases.
- | - |
---|---|
Repository | exercise-arab |
Description | A small side project on loan management |
Original repository owner | Vipul Bhardwaj |
Framework | React |
The project is built using Create React App (CRA) and has no specific test configuration. I used Jest and React Testing Library (RTL) to introduce unit testing in the project. I started by writing test cases for utility functions (these are easy to write) to gain initial momentum and confidence.
I declared victory π by providing around 50% testing coverage to the project. I get satisfaction with this visual.
Jest, React Testing library and Enzyme #
πJest is a testing framework that comes up with assertion and mocking capabilities, code coverage report, running tests in parallel, etc. Jest is not limited to React and can be used in other frameworks too.
π§ͺ Enzyme is a testing utility that allows us to access and manipulate React components and requires a test runner like Jest by providing wrappers for it. It allows us to manipulate React lifecycle methods and state values.
πReact Testing library (RTL) is again a testing utility but built on a different philosophy. Its philosophy is to write tests that resemble the way users interact with our application. So it avoids the implementation details and focuses on resembling how users will interact with the application.
In this article, we will exclusively talk about Jest and RTL.
Unit testing in Create React App (CRA) #
The unit testing in CRA is made simple as Jest is an built-in task runner for CRA apps. Though there were 2 conventions I stumbled upon when I was setting up testing configuration:
You need to create a
setupTests.js
(as itβs the default path expected for thereact-scripts
project) file and keep it in thesrc
folder to setup a custom testing environment. This is equivalent to creating asetupFilesAfterEnv
array injest.config.js
when you are either not using CRA or ejected it.Apart from this, I realized that CRA comes with pre-defined value for a built-in environment variable called
NODE_ENV
. The CRA already setup 3 values for it -
Script | NODE_ENV value |
---|---|
npm start | development |
npm test | test |
npm build | production |
CRA doesnβt allow manually overriding these variables, CRA claims that this is to prevent accidental deployment of a slow build in the production. Though there was a case where I wanted to change the variable value.
A quick search on the internet helped me to achieve this, see the example to understand it better.
/** test setup **/
const OLD_ENV = process.env;
/** test clean up **/
afterEach(() => {
process.env = OLD_ENV; // restore old env
});
beforeEach(() => {
process.env = {
...OLD_ENV
}; // make a copy
});
// test
test('should return production url', () => {
// overriding value to 'production' in test mode
process.env.NODE_ENV = 'production';
expect(backendUrl()).toBe(process.env.REACT_APP_PRODUCTION_API);
});
π‘Tip: You may want to create your node script for running test cases with coverage and without watch mode. The CRA app will run the npm test
in watch mode with interactive CLI.
Donβt miss extra --
, this is how we pass these arguments to run the script.
"test:coverage": "npm run test -- --coverage --watchAll=false"
Mocking - When to use what? #
Mocking is a way to manipulate the original function or module with dummy implementations that emulate real behavior. It helps us to isolate implementation details and focus on behavior. Usually, we mock utility functions, node modules, custom modules, or global functions such as fetch
or localStorage
.
In Jest, we have 3 ways to mock - jest.fn
, jest.mock
, and jest.spyOn
.
Type | Purpose | Example |
---|---|---|
jest.fn |
mocks a standalone function | callback |
jest.mock |
mocks entire module | toast notification library |
jest.spyOn |
Spies on a function either without changing implementation or restore implementation if changed | localStorage |
jest.fn
is the simplest way to mock a function. You can mimic the callback, a utility function, etc.
import { login } from '../auth';
test('should login the user into application', async () => {
// arrange
const handleClick = jest.fn(); // π
const values = {
username: 'kalpeshsingh',
password: 123
}
// act
await login(values, handleClick);
// assert
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick.mock.calls[0].length).toBe(1);
expect(handleClick).toHaveBeenCalledWith(false);
});
In the above example, our login
function expects a callback function, we created a mock callback function using jest.fn()
and it now can be asserted.
We have performed various assertions to make sure the callback function is behaving as expected. We are calling it once with false
as a single parameter from our login function implementation.
Letβs say now we import the colors module which has many utility functions exported. Letβs see how jest.mock
can improve it.
import * as colors from '../colors';
import app from '../app';
/** What if we 4-6 methods in `colors.js`? π±
We don't want to write jest.fn() for 4-6 methods.
**/
colors.setRGBColor = jest.fn();
colors.setHexColor = jest.fn();
test('should set RGB color', () => {
app.invokeRGBColors([244, 322, 123]); // assume internal implementation
expect(colors.setRGBColor).toHaveBeenCalledWith([244, 322, 123]);
});
test('should set Hex color', () => {
app.invokeHexColors('BADA55');
expect(colors.setHexColor).toHaveBeenCalledWith('BADA55');
});
In the above code, we needed to mock each exported functions but there can be a case where we have far many utility functions and we want to mock all of them. We can use jest.mock
to make this possible.
import * as colors from '../colors';
import app from '../app';
// far better π
jest.mock('../colors');
test('should set RGB color', () => {
app.invokeRGBColors([244, 322, 123]);
expect(colors.setRGBColor).toHaveBeenCalledWith([244, 322, 123]);
});
test('should set Hex color', () => {
app.invokeHexColors('BADA55');
expect(colors.setHexColor).toHaveBeenCalledWith('BADA55');
});
Jest even provides functionality to auto mock all the imports (except core node modules such as path
, fs
, etc.) You can enable automock by adding automock: true
in your jest.config.js
file.
This way of mocking is super easy and helpful but it is difficult to restore their original implementation. Thatβs why we have jest.spyOn
.
There are two usages of spyOn. The one where you just want to observe how a function behaves and keeps the original implementation intact. Another usage is where you mock implementation details and then again need to assert with original implementation (by restoring it).
jest.spyOn(global, 'fetch');
// it will call `fetch` in implementation as it is
expect(global.fetch).toHaveBeenCalledTimes(1);
Now letβs see examples where we mock the implementation details using all 3 ways.
// EXAMPLE - jest.fn
test("handle click with value", () => {
const handleClick = jest.fn(() => "clicked");
expect(handleClick("foo")).toBe("clicked");
expect(handleClick).toHaveBeenCalledWith("clicked");
});
test("should register button click", () => {
const handleClick = jest.fn().mockImplementation(() => "clicked");
expect(handleClick("click")).toBe("clicked");
expect(handleClick).toHaveBeenCalledWith("click");
});
// EXAMPLE - jest.mock
// winner.js
module.exports = function () {
// implementation details;
};
// winner.test.js
const winner = require('../winner');
jest.mock('../winner'); // you don't need this if `autoMock` is true
// winner is a mock function
winner.mockImplementation(() => 1);
winner(); // 1
// EXAMPLE - jest.spyOn
const mockSuccessResponse = {
success: true
};
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise
});
/** localstorage spying **/
jest.spyOn(global, 'fetch').mockImplementation(() => mockFetchPromise);
jest.spyOn(Storage.prototype, 'getItem');
Storage.prototype.getItem = jest.fn(() => 'abc');
Manual mocks using __mock__
folder #
Apart from importing modules and mocking them using jest.mock('./module')
, Jest provides another way to create manual mocks for user modules and node modules.
For the user module, say you want to create mock for contact.js
residing in the π contact
directory then you can create __mock__
folder inside the contact
directory and create file contact.js
.
contact/ β βββ contact.js β βββ __mock__ β βββ contact.js
Things to keep in mind-
-
__mocks__
folder naming is case-sensitive, it might not work in a few systems. - Even though you have created manual mocks in the
__mocks__
folder, you need to explicitly importjest.mock('./module')
in test files.
Mocking node_modules are fairly simple. Keeping the __mocks__
adjacent to node_modules
in many cases should work.
There are a few more tiny details Jest provides for manual mocking and you may want to read them on Jest document.
Mock functions with once
suffix #
There are a bunch of functions provided by Jest that can be chained so that subsequent function calls produce different values. The below examples (inspired by Jest docs) will make it easy to understand.
mockImplementationOnce
const mockFunction = jest
.fn()
.mockImplementationOnce(() => "first call registered.")
.mockImplementationOnce(() => "second call registered.");
expect(mockFunction()).toBe("first call registered.");
expect(mockFunction()).toBe("second call registered.");
mockReturnValueOnce
const mockFunction = jest
.fn()
.mockReturnValue('fallback value')
.mockReturnValueOnce('first call registered.')
.mockReturnValueOnce('second call registered.');
expect(mockFunction()).toBe('first call registered.');
expect(mockFunction()).toBe('second call registered.');
expect(mockFunction()).toBe('fallback value');
expect(mockFunction()).toBe('fallback value');
mockResolvedValueOnce
test("async test", async () => {
const asyncMock = jest
.fn()
.mockResolvedValue({ success: true })
.mockResolvedValueOnce({ success: true, token: "abc" })
.mockResolvedValueOnce({ success: true, data: "xyz" });
expect(await asyncMock()).toStrictEqual({ success: true, token: "abc" });
expect(await asyncMock()).toStrictEqual({ success: true, data: "xyz" });
expect(await asyncMock()).toStrictEqual({ success: true });
expect(await asyncMock()).toStrictEqual({ success: true });
});
mockRejectedValueOnce
test("async test", async () => {
const asyncMock = jest
.fn()
.mockResolvedValueOnce("first call")
.mockRejectedValueOnce(new Error("Async error"));
expect(await asyncMock()).toBe("first call");
await expect(asyncMock()).rejects.toThrow("Async error");
});
Async function testing #
I have spent a good amount of time understanding why didnβt functions in then
and catch
block work when I use await login()
. Though I didnβt find any solution online but understood that I need to return the promise from the login
function to test it. This was difficult to find.
function login(values, setSubmitting) {
fetch(`/login`, {
body: JSON.stringify(values),
})
.then((res) => res.json())
.then((data) => {
setSubmitting(false);
})
.catch((err) => {
toast("Something went wrong, Internet might be down");
});
}
In the above code, you wonβt be able to test setSubmitting(false)
callback unless you put the return
keyword before fetch
.
Another issue I ran into was when I was testing routes by rendering the corresponding component. When you render a component then it makes API calls in componentDidMount
(if you have one) and you will receive the below message with test passing.
(Click on screenshot to zoom DevTools)
test("should render loan page if authenticated", () => {
/** prepare routing and render page **/
const history = createMemoryHistory({ initialEntries: ["/loan"] });
const { getByText, queryByText } = render(
<Router history={history}>
<Routes />
</Router>
);
expect(history.location.pathname).toBe("/loan");
expect(getByText("Loan Application Form")).toBeInTheDocument();
});
To get rid of this message, you need to mock fetch
.
test("should render loan page if authenticated", () => {
/** mocks **/
jest.spyOn(global, "fetch").mockResolvedValueOnce({}); π
/** prepare routing and render page **/
const history = createMemoryHistory({ initialEntries: ["/loan"] });
const { getByText, queryByText } = render(
<Router history={history}>
<Routes />
</Router>
);
expect(history.location.pathname).toBe("/loan");
expect(getByText("Loan Application Form")).toBeInTheDocument();
/** mocks restore **/
jest.spyOn(global, "fetch").mockRestore();
});
Route testing #
This one was interesting. I spent a couple of hours figuring out why canβt I test my routes and then I came across one StackOverflow answer. This answer helped me in two ways.
One was the original issue I was facing with route tests and another one was that made me realize that sometimes you need to make your code testable too.
Old code (not testable)
class App extends Component {
render() {
return (
<Router> π
<Switch>
<Route exact path="/" component={Homepage} /> π
<PrivateRoute path="/login" component={Login} />
<PrivateRoute path="/dashboard" component={Dashboard} />
<Route component={NoMatch} />
</Switch>
</Router>
);
}
}
Refactored code (testable)
export const Routes = () => {
return (
<Switch>
<Route exact path="/" component={Homepage} />
<PrivateRoute path="/login" component={Login} />
<PrivateRoute path="/dashboard" component={Dashboard} />
<Route component={NoMatch} />
</Switch>
);
};
class App extends Component {
render() {
return (
<Router> π
<Routes /> π
</Router>
);
}
}
Why didnβt it work with an old example? In the old example, we already had <Router>
declared. We need to separate routes and <Router>
and instead use <Router>
with history in test cases to independently test routes.
Corresponding test file
/** 3P imports **/
import React from "react";
import { render } from "@testing-library/react";
import { Router } from "react-router-dom"; π
import { createMemoryHistory } from "history";
/** test function imports **/
import { Routes } from "../App"; π
test("landing on a bad page shows no match component", () => {
/** prepare routing and render page **/
const history = createMemoryHistory({ initialEntries: ["/something"] });
const { getByText } = render(
<Router history={history}> π
<Routes /> π
</Router>
);
expect(getByText("404 - Page Not Found")).toBeInTheDocument();
//more assertions
});
So, if you are writing test cases for your application then it will demand you to make your code testable else either you wonβt be able to test or will write more code in testing than the original implementation.
Assertions, global methods, and cleanup #
Anecdote: Once I use toBeTruthy
assertion in the test case to verify if it returns boolean literal. I was wrong as toBeTruthy
will return true for all truthy values. The correct was to use toBe(true)
. Interesting to know that subtle differences can make or break test cases.
Assertions #
There are tons of methods offered by both Jest and React Testing Library to assert results and DOM manipulations.
You can find a pretty long list of Jest assertions and DOM queries from React Testing Library in their documentation.
Jest assertions
test("should fail the login call", async () => {
/** mocks **/
const mockFailureResponse = Promise.reject();
const setSubmitting = jest.fn();
jest.spyOn(global, "fetch").mockImplementation(() => mockFailureResponse);
await login(setSubmitting);
/** assertion of toast module **/
expect(toast).toHaveBeenCalledWith(
"Something went wrong, Internet might be down"
);
expect(toast).toHaveBeenCalledTimes(1); π
expect(toast.mock.calls[0].length).toBe(1); π
/** assertion of callback **/
expect(setSubmitting).toHaveBeenCalledTimes(1);
expect(setSubmitting.mock.calls[0].length).toBe(1);
expect(setSubmitting).toHaveBeenCalledWith(false); π
});
React Testing Library DOM queries
test("should render home page if authenticated", () => {
/** mocks **/
jest.spyOn(mockAuth, "authenticated").mockImplementation(() => true);
jest.spyOn(global, "fetch").mockResolvedValueOnce({});
/** prepare routing and render page **/
const history = createMemoryHistory({ initialEntries: ["/home"] });
const { getByText, queryByText } = render(
<Router history={history}>
<Routes />
</Router>
);
expect(history.location.pathname).toBe("/home");
expect(getByText("Choose Application Type")).toBeInTheDocument(); π
expect(queryByText("Apply For Loan In 4 Easy Steps")).not.toBeInTheDocument(); π
/** mocks restore **/
jest.spyOn(global, "fetch").mockRestore();
jest.spyOn(mockAuth, "authenticated").mockRestore();
});
Global state cleanup #
We write many test cases in a single file and all they will share the same global state. Jest provides us a handful of methods to reset global states. These are afterEach
, beforeEach
, afterAll
, and beforeAll
.
Method | Description | Example |
---|---|---|
afterAll |
It runs after all the tests in the file have completed. | reset database or run clean up function |
beforeAll |
It runs before all the tests in the file have completed. | setup database |
afterEach |
It runs after each one of the tests in the file completes. | clear all mocks |
beforeEach |
It runs before each one of the tests in this file runs. | reset mock modules |
If you have a synchronous setup (say database call) then you can even avoid beforeAll
method.
Global methods setup #
I extensively used 2 global methods in Project Pandim viz. fetch
and localStorage
.
As Jest runs in the node environment, you donβt have access to window
object but you can access these two easily in the node environment.
π Applaud jsdom for this.
We can use the following snippet to mock fetch
and localStorage
.
Mock localStorage
jest.spyOn(Storage.prototype, "setItem");
jest.spyOn(Storage.prototype, "removeItem");
Storage.prototype.setItem = jest.fn();
Storage.prototype.removeItem = jest.fn();
Mock fetch
const mockSuccessResponse = { success: true, user: "abc" };
const mockJsonPromise = Promise.resolve(mockSuccessResponse);
const mockFetchPromise = Promise.resolve({
json: () => mockJsonPromise,
});
jest.spyOn(global, "fetch").mockImplementation(() => mockFetchPromise);
You can even use without jest.spyOn
, just mock the function as you do normally with other functions. Storage.prototype.setItem = jest.fn();
Formik testing #
The project uses a few forms using the Formik library and I was not sure how to test it because it takes functions and components as props.
Iβm yet to learn how to better cover Formik kinds of components but I have written basic tests around it.
test("should render correct fields and able to change value", () => {
/** prepare routing and render LoginForm **/
const history = createMemoryHistory({ initialEntries: ["/something"] });
const { container } = render(
<Router history={history}>
<LoginForm />
</Router>
);
/** grab form fields **/
const email = container.querySelector('input[name="email"]');
const password = container.querySelector('input[name="password"]');
/** mimic user input **/
fireEvent.change(email, {
target: {
value: "mockemail",
},
});
fireEvent.change(password, {
target: {
value: "mockPassword",
},
});
expect(email).toHaveValue("mockemail");
expect(password).toHaveValue("mockPassword");
});
What you should avoid with React Testing Library #
Testing Library follows its guiding principles where it encourages developers to avoid testing implementation details. Thatβs why you should avoid testing the following -
- Children components
- Local state or function of the component
- Lifecycle methods
Fun fact π - I made a document contribution in Testing Library to include a similar section on their main introduction page. You may want to look at the Pull Request and see the conversations.
Testing hygiene #
As we have a few practices to write better code similarly we have a few things to make our testing code better too. As test code is part of our codebase, it is entitled to all the best practices and quality we look at in our codebase. It can range from test descriptions to organizing test files to having helper functions for test suits.
1. Meaningful test descriptions
Similar to meaningful commit messages, we need to write meaningful test descriptions. Alike variable names it helps us to understand implementation without reading testing code.
// ===Example 1===
test('should render error toast when invalid input is provided', () => {
// test details
});
// ===Example 2===
describe('should render toast', () => {
test('error if input is invalid', () => {
// test details
});
test('success if input is valid', () => {
// test details
});
});
2. Reusable code
There are broadly 2 kinds of code reusability I can think of in unit testing. One you can keep at a global level which applies to all your test cases and one specific in the specific test file.
If you observe you are repeating a good chunk of code in a single test file or you have a pattern that is getting repeated then it is good to mull if this can qualify for reusable function.
Jest allows us to keep a global test setup by configuring setupFilesAfterEnv
in jest.config.js
file. Say, we want to setup localStorage
mocking before running tests.
// jest.config.js
module.exports = {
setupFilesAfterEnv: ["./jest.setup.js"],
};
//jest.setup.js
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
};
global.localStorage = localStorageMock;
Now in another scenario letβs say you have 5 tests in a single file and almost all have repeated code then you can create a function and call them in each to promote reusability and make your life easy for maintenance.
function getLoginMockValues() {
const values = {
email: "kalpesh.singh@foo.com",
password: "1234",
};
const setSubmitting = jest.fn((val) => val);
const nav = {
push: jest.fn(),
};
return {
values,
setSubmitting,
nav,
};
}
test("should successfully make the login call", async () => {
/** mocks **/
const { values, setSubmitting, nav } = getLoginMockValues();
// test details
expect(setSubmitting).toHaveBeenCalledTimes(1);
});
3. Organizing test files
When I was writing test files in a small project I didnβt mind keeping all the files in the __test__
folder. One thing I notice and ignore is that mimicking the src
folder structure in the __test__
folder can be daunting in large projects.
project/ βββ src/ β βββ auth.js β βββ Components β βββ Page β βββ app.js __test__/ β βββ auth.js β βββ Components β βββ NavBar.js β βββ LoginForm.js β βββ Page β βββ HomePage.js β βββ app.js
If we follow the recommended practice of keeping test files along with code we are testing from the Create React App project then we can take home a few benefits.
- We can have shorter relative paths
- Colocation eliminates mimicking same folder structure
- We are confident to find test cases easily as it resides in
__test__
folder next to our file we are testing - This practice encourages us to write tests for files where we donβt see
__test__
folder colocated with them without worrying if somebody else has already written tests if we go by the traditional way
Visual satisfaction #
(Click on screenshot to zoom testing coverage report)
Resources #
Articles
- Testing React components with Jest and Enzyme
- Mocking and testing fetch with Jest
- Jest set, clear and reset mock/spy/stub implementation
- Understanding Jest Mocks
- Why Do JavaScript Test Frameworks Use describe() and beforeEach()?
- What is the difference between a test runner, testing framework, assertion library, and a testing plugin?
- Create React App Configuration Override library
Stackoverflow questions
- App not re-rendering on history.push when run with jest
- How to test a component with the tag inside of it?
- Canβt get MemoryRouter to work with @testing-library/react
Spotted a typo or technical mistake? Tweet your feedback on Twitter.
Your feedback will help me to revise this article and encourage me to learn and share more such concepts with you.
Thank you Luigi for proofreading it.