Unit testing
It’s good to understand why we do something before doing it. So, why test and what is its purpose?
- The first purpose of testing is to prevent regression. Regression is the reappearance of a bug that had previously been fixed. It makes a feature stop functioning as intended after a certain event occurs.
- Testing ensures the functionality of complex components and modular applications.
- Testing is required for the effective performance of a software application or product.
Testing makes an app more robust and less prone to error. It’s a way to verify that your code does what you want it to do and that your app works as intended for your users.
Unit testing is the form of testing that focuses on isolated pieces of code, hence "unit testing". It's basically the opposite of end-to-end testing, where we'd test an entire chain.
Jest & React Testing Library
Jest is a seasoned testing framework for JavaScript which is mostly easy to work with and has a good reputation within the industry. Let's start by installing Jest and React Testing Library.
npm i --save-dev jest ts-jest babel-jest @types/jest && npm i @testing-library/react @testing-library/jest-dom
Second, we need to add some configuration. For this we will create the following file in the repository root.
module.exports = {
collectCoverageFrom: ["**/*.{ts,tsx}", "!**/node_modules/**", "!jest.config.js"],
preset: "ts-jest",
testPathIgnorePatterns: [
"/.next/",
"/node_modules/",
"/lib/",
"/tests/",
"/coverage/",
"/.storybook/",
],
testRegex: "(/__test__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
testURL: "http://localhost",
testEnvironment: "jsdom",
moduleFileExtensions: ["ts", "tsx", "js", "json"],
moduleNameMapper: {
"\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/__mocks__/fileMock.js",
},
transform: {
".(ts|tsx)": "babel-jest",
},
transformIgnorePatterns: ["<rootDir>/node_modules/"],
};
Testing your application can be done by running Jest. Rather than doing this directly in the terminal, we're going to add a command
in your package.json
:
"test": "jest --watch",
It’s a bit frustrating to make changes to a test file and then have to manually run npm test again. Jest has a nice feature called watch mode, which watches for file changes and runs tests accordingly. To run Jest in watch mode, we've added the --watch
flag to the command.
Now when you run npm run test
Node will run all tests it can find within your project. When you do this now, you'll run into a displeasing message notifying you no tests have been added yet.
ESLint and TypeScript might need some final configuration since Jest is introducing a few oddities to our application which we will gladly accept. In your .eslintrc.js
, add the following environment:
env: {
jest: true,
},
Creating the actual tests
When writing a test, we have several methods we regularly use:
test
(or its aliasit
) You pass a function to this method, and the test runner will execute that function as a block of tests.describe
This optional method is for grouping any number of it or test statements.expect
This is the condition that the test needs to pass. It compares the received parameter to the matcher. It also gives you access to a number of matchers that let you validate different things. You can read more about it in the documentation.
A best practice for our test file naming and architecture is placing them in the component folder directory like so:
components/
└─ shared/
├─ Select.tsx
└─ __tests__/
└─ Select.test.tsx
Finally, to understand the shape of a simple unit test, look at the following example:
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import { Select } from "../Select";
test("if the select updates its options", () => {
const clients = [
{
name: "Port of Rotterdam",
},
];
const utils = render(<Select clients={clients} />);
expect(
(utils.getByRole("option", { name: /Show all/i }) as HTMLOptionElement)
.selected
).toBe(true);
fireEvent.change(utils.getByRole("combobox"), {
target: { value: clients[0].name },
});
expect(
(
utils.getByRole("option", {
name: "Port of Rotterdam",
}) as HTMLOptionElement
).selected
).toBe(true);
});
We're testing whether the Select
component properly changes value. First off we mock the clients
data. With that we can render our custom select. Then we expect the default option to be selected. We change the value of our select and now check whether our change was successful. The /Show all/i
search query is called a regex and is much like a regular string to which you can add extra alterations. In this case the /i
adds an alteration so that the string is transformed to lowercase.
You can also make a test
function async
if you want to test code containing asynchronous operations. Because you don't want to rely on an actual API connection in your unit tests, it's recommended to use a Mock Server Worker library.
'before' & 'after' methods
Sometimes you need to define setup and cleanup actions to be executed before and after a test. These actions can be used to prepare the environment, set up resources, or clean up after tests have been executed. To do so, you can use beforeEach
, beforeAll
, afterEach
or afterAll
methods. Here's an example when using the Mock Server Worker:
// Establish API mocking before all tests
beforeAll(() => server.listen())
// Reset handlers so that each test could alter them
// without affecting other, unrelated tests.
afterEach(() => server.resetHandlers())
// Clean up once the tests are done
afterAll(() => server.close())
test('displays recent time entries', async () => {
render(<TimeEntries />)
// Wait for the request to be finished
await waitFor(() => {
expect(
screen.getByLabelText('Fetching time entries...'),
).not.toBeInTheDocument()
})
})
Now let's move on to an example of a unit test that verifies whether code changes have lead to unintended changes in the UI: snapshot testing!