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.

Β·

18 min read

Table of Content

A poster mentioning stats for test cases and efforts invested. The main point of poster is to learn by public contribution (Click on poster to zoom)

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.

--
Repositoryexercise-arab
DescriptionA small side project on loan management
Original repository ownerVipul Bhardwaj
FrameworkReact

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 the react-scripts project) file and keep it in the src folder to setup a custom testing environment. This is equivalent to creating a setupFilesAfterEnv array in jest.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 -

ScriptNODE_ENV value
npm startdevelopment
npm testtest
npm buildproduction

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.

TypePurposeExample
jest.fnmocks a standalone functioncallback
jest.mockmocks entire moduletoast notification library
jest.spyOnSpies on a function either without changing implementation or restore implementation if changedlocalStorage

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 import jest.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.

A Editor debugging tool showing warning but passing tests (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.

MethodDescriptionExample
afterAllIt runs after all the tests in the file have completed.reset database or run clean up function
beforeAllIt runs before all the tests in the file have completed.setup database
afterEachIt runs after each one of the tests in the file completes.clear all mocks
beforeEachIt 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 -

  1. Children components
  2. Local state or function of the component
  3. 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

A test coverage report showing around 50% codebase coverage. (Click on screenshot to zoom testing coverage report)

Resources

Articles

  1. Testing React components with Jest and Enzyme
  2. Mocking and testing fetch with Jest
  3. Jest set, clear and reset mock/spy/stub implementation
  4. Understanding Jest Mocks
  5. Why Do JavaScript Test Frameworks Use describe() and beforeEach()?
  6. What is the difference between a test runner, testing framework, assertion library, and a testing plugin?
  7. Create React App Configuration Override library

Stackoverflow questions

  1. App not re-rendering on history.push when run with jest
  2. How to test a component with the tag inside of it?
  3. 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.

Β