DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Unit testing in React
Serhat Genç
Serhat Genç

Posted on

Unit testing in React

Introduction

Unit tests are tests we write cheaply and with little effort to ensure that components or functions are working as expected. Dividing the code into small pieces increases the maintainability and readability of the code. Such an approach will make it easier to write tests, especially in large-scale projects. We can also use unit testing for things like edge cases and testing APIs.

As for what happens if we don't write unit tests, it might not be a problem if our project is smaller scale. However, as the project grows and the business logic becomes more complex, you may find it difficult to track down bugs.

I created a small-scale weather application to help with this article. I will try to achieve 100% test coverage for this application.

First, let's take a look at how the application looks.

Weather App

The general flow is as follows, we get the latitude and longitude of the user's location using the web API. I access the weather data with OpenWeatherMap and reflect it to the weather card component. The button on the lower left is for re-fetching the weather data. The button on the lower right allows us to expand the weather card and see extra information.


Setup and Mocking

Before continuing, I want to briefly show the final version of my API folder.

Api Folder Structure

Keeping the APIs in different files will be useful in terms of order. For example, here I create weather-related APIs in api/weather.ts.

If we create a mock file with the same API file name in the API folder's __mocks__ folder, the mock function we created here will work in unit tests. This way, we will be running the tests without sending requests to the real API. But keep in mind that the function names need to be the same as well.

In cases where we want the API we use in the test to behave differently from the normal flow, we can do different implementations, such as throwing an error to the API, as you will learn later in the article, by using jest.spyOn. So the usage in jest.spyOn is no different from defining the mocked version of the APIs in the __mocks__ folder. It makes the most sense to define how they behave in the general flow to __mocks__ because only when I need to change the behavior of the API within a test, I can change it with jest.spyOn within the scope of that test.

As seen in the test script that comes ready with the create react app, we run our tests using react-scripts. By giving the file path of the API to Jest, we indicate that the relevant API is mocked. Since we have to specify this at the beginning of the tests, it makes more sense to put it in a file that is run before the tests. This file is setupTests and we need to create this file under the src file. In addition, if our application uses one of the web APIs, we can mock it here; otherwise, it will cause problems in our tests.

// src/setupTests.ts
jest.mock("api/weather");
Enter fullscreen mode Exit fullscreen mode

Here I am telling Jest that the weather API will be mocked. Jest looks for this API file, and its functions in the __mocks__ folder.

React-script uses jest when running tests. Jest is a test runner that finds tests, runs the tests, and determines whether the tests passed or failed. Jest also uses a tool called Istanbul. This tool helps us get the test coverage report. Whereas react testing library provides virtual DOMs for testing React components.

The content of the weather card component rendered in App.tsx is populated with the request of the getWeatherData function. I defined getWeatherData as a mock function that returns fake data in the api/__mocks__/weather.ts file.

// api/__mocks__/weather.ts
import { mockedGetWeatherDataResponse } from "api/mockData/weather";

// mockedGetWeatherDataResponse is a suitable response that can be returned to this api function.

export const getWeatherData = async (
  params: Api.Weather.GetWeatherDataParams
) => Promise.resolve(mockedGetWeatherDataResponse);
Enter fullscreen mode Exit fullscreen mode

Now that we have set the mock function, we can test the weather card component. Since App.tsx includes the weather card component, I will write the test in it.


Writing tests

import { render, screen, waitFor } from "@testing-library/react";
import { mockedGetWeatherDataResponse } from "api/mockData/weather";
import App from "App";

describe("App tests", () => {
  const setup = () => render(<App />);

  it("loads data successfully", async () => {
    setup();

    await waitFor(() => {
      expect(screen.getByTestId("city-name")).toHaveTextContent(
        mockedGetWeatherDataResponse.name
      );
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Here, I wrote my tests in the describe block. This is not required, but you can use it if you want to group your related tests. We define the first parameter as the name of the block, and the second parameter as the callback containing our tests.

We write the tests in it or test blocks, just like in the describe block. The first parameter is the name of the test, and the second parameter is the callback to which the test will be written. If we have async actions in the code, it is useful to define this callback as async.

As mentioned above, we get the necessary dom elements from the react testing library. Here we assign the app component rendered to the virtual dom to a variable called setup. Calling the setup function at the beginning of the test will render the app component to the virtual dom.

As a first test, we will test whether the given data is rendered in the appropriate place. From the results returned from the mock data we defined, Izmir should appear as the city on the screen. There are many ways to find elements, such as by role, by text, or by test id. Searching with a test id is the easiest and most commonly used method. All you have to do is give the data-testid prop to the element you want to find.

We expect the text content of the element with this test id to be the same as the name in the mock data, which is Δ°zmir. Here we must put the expect line in the waitFor as a callback because the data is accessed as a result of 2 async actions, which are getting the location and weather data.

npm run src/App.test.tsx
Enter fullscreen mode Exit fullscreen mode

After coming to the terminal and running the test script with the path of the test file as above,

Failed test because of un-mocked navigator api

We are getting such an error. That is because I used a web API called navigator to find the user's location. To pass the test, the navigator needs to be mocked. We can handle the mocking process in the setupTests file.

// src/setupTests.ts
// When using getCurrentPosition in the flow, this is how the response is returned.
const mockGeolocation = {
  getCurrentPosition: (cb: any) => {
    return cb({
      coords: {
        latitude: 27.0881,
        longitude: 38.4942,
      },
    });
  }
};

global.navigator = Object.defineProperty(global.navigator, "geolocation", {
  value: mockGeolocation,
});
Enter fullscreen mode Exit fullscreen mode

The getCurrentPosition function of the navigator's geolocation property is used in the flow. The structure where this function returns a suitable result is as above. When we run the test again, we will see that it passed.


Test coverage

Before we move on to the next test, I'll show you how to get the test coverage report to guide us on what to write the next test for. There is a script in package.json to run tests using react-scripts. Let's write a script here to get the test coverage report.

// package.json
//...
 "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "test:coverage": "react-scripts test --coverage --watchAll",
    "eject": "react-scripts eject"
  },
//...
Enter fullscreen mode Exit fullscreen mode

To get coverage, we need to add the --coverage flag to the end of the test script.

If a file that reaches 100% test coverage does not appear in the coverage report, you can solve this problem by adding the --watchAll flag at the end of the coverage script. This flag watches files for changes and reruns all tests when something changes.

npm run test:coverage
Enter fullscreen mode Exit fullscreen mode

After running the test coverage report script, you can see the report in the terminal.

Terminal test coverage report

If you want to look at the more readable, detailed, and eye-catching version, you may have noticed that a folder called coverage has been created inside your project. Open the Icov-report folder in the coverage folder and open the index.html in it with the live server. And you will see something like this.

Test coverage with eye-catching version

As you can see, the WeatherCard component is 71.42% covered. Let's see which conditions are not covered.

Weather card uncovered condition

The case where we extend the WeatherCard is not covered. This forms our next test case.


it("should expand the weather card", async () => {
    setup();

    userEvent.click(await screen.findByTestId("expand-card-button"));

    expect(await screen.findByTestId("text-humidity")).toHaveTextContent(
      mockedGetWeatherDataResponse.main.humidity.toString()
    );
  });
Enter fullscreen mode Exit fullscreen mode

What we need to do here is a user action. The user needs to expand the card. You can use userEvent or fireEvent for such user actions. But keep in mind that userEvent can better reflect the actual behavior of the user.

After expanding the card, the humidity level should appear on the screen. I expect this value to be on the screen and equal to the value in the mock data.

Due to the UI library I use here, I get an error that window.scrollTo is not implemented when running the test, but the test passes successfully. If we mock this in setupTests, we will not receive such a warning.

window.scrollTo = jest.fn();
Enter fullscreen mode Exit fullscreen mode

This is how I mocked the window.scrollTo in setupTests file.

Moving on to the next test, the catch part of the function from which we pull the weather is not covered. Here we need to drop the weather function into the catch state.

it("should throw error while fetching weather data", async () => {
    jest.spyOn(weather, "getWeatherData").mockImplementation(() => {
      throw new Error();
    });

    setup();

    await waitFor(async () => {
      expect(weather.getWeatherData).toThrowError();
    });
  });
Enter fullscreen mode Exit fullscreen mode

Here, before the test starts, we set the getWeatherData function of the weather API to throw an error. We use Jest's spyOn function for this. The first parameter is the weather, which is the structure containing the weather-related functions, and the second parameter is the name of the function as a string. Then we make a mock implementation and throw an error.

Everything in the src folder is 100% tested. However, there is no point in testing the src/index.tsx file, so we can exclude it from the test coverage. Let's add this to the package.json and which file extensions to test.

// package.json
//...
"jest": {
    "collectCoverageFrom": [
      "src/**/*.{ts,tsx}",
      "!src/index.tsx"
    ]
  }
//...
Enter fullscreen mode Exit fullscreen mode

All that's left is to test the API folder. I will create the API tests in the tests folder under the API folder. I created a test file named weather.test.tsx in it.

import { weather } from "api";
import { mockedGetWeatherDataResponse } from "api/mockData/weather";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";

const mock = new MockAdapter(axios);
jest.unmock("api/weather");

describe("Weather api tests", () => {
  it("requests and gets a successful response from getWeatherData", async () => {
    const url = "https://api.openweathermap.org/data/2.5/weather";
    const params: Api.Weather.GetWeatherDataParams = {
      lat: 100,
      lon: 200,
    };

    mock.onGet(url).reply(200, mockedGetWeatherDataResponse);

    const response = await weather.getWeatherData(params);

    expect(response).toEqual(mockedGetWeatherDataResponse);
  });
});
Enter fullscreen mode Exit fullscreen mode

Since there is no business-related code in the API function, we will only test the request. We will use axios-mock-adapter for this. And we don't want the requests to go to their counterparts in the __mocks__ folder as before. For this, we remove the mocking process we performed in setupTests here with jest.unmock.

MockAdapter needs the instance you use while making HTTP requests as a parameter. Since I make HTTP requests using Axios, I pass the Axios instance to the MockAdapter. I pass the URL to mock.onGet as a parameter, then the reply to this URL should be equal to the mock data we defined earlier and 200 status code. Then we request the relevant API and expect the return result to be the same as the mock data.


There you have it, 100% test coverage πŸŽ‰.

In a real-life business project, 100% test coverage is not an unattainable percentage, but a percentage that will take time to reach. In reality, the workflow may not be this simple, and things will get complicated. For this, it is best to start by choosing a percentage like 50% as a target, and it will be possible to reach 100% over time.

Github repository if you want to take a look at the final version of the project.

Top comments (3)

Collapse
 
larsejaas profile image
Lars Ejaas

Really enjoyed your article!

I am still fairly new to testing so it really helps to see different React projects and how the different authors use different patterns on how to structure things regarding tests.
Keep up the good work, your writing is πŸ”₯

Collapse
 
serhatgenc profile image
Serhat Genç

Thank you so much for your kind comment. πŸ™

Collapse
 
larsejaas profile image
Lars Ejaas

The pleasure is all mine πŸ™‚

We want your help! Become a Tag Moderator.
Fill out this survey and help us moderate our community by becoming a tag moderator here at DEV.