All Articles

Improve Quality in React with Unit Tests

Checkboxes
In testing, seeing red is just as scary as green is satisfying...

In previous posts, we have set up the first couple of components that retrieve account data from a backend server, and then nicely display that in a table or in tiles, depending on the user’s selection. We have manually verified that it looks pretty.

You may think that we could now continue with the next couple of components, or continue with additional features, by adding new accounts for example. All of this will come in due time - however, one final item is missing, and that is unit testing.

In this article, we will cover what unit tests are, why they are crucial in any application, which framework exists for react, and how we can set up the first couple of tests for our currently existing functionality.

Why test at all?

As with any feature or language, it is much more exciting to write new functionality and add logic to the application. Testing sounds boring, and if you’re the only one working on your app, why should you even add it? After all, you are confident that you’re not writing nonsense…

You need to be aware though, that no matter how much experience you have, you will make errors. No matter how well you compartementalize your packages, you will, at one point or another, introduce side effects that break existing functionality. Those errors can be flagrant, which will make finding them easy, but they can also be subtle, in which case you may not realize for quite some time that you’ve introduced bugs!

It’s therefore crucial that you have testing for your application. Sure, you’ve looked at how the code looks in the UI. That’s indeed important (and satisfying!), but having repeatable, automated tests will make you a lot more confident that your code is always working as it should. Essentially, it will give you a much bigger trust in your deployments, and you’ll know that so much less can go wrong when push comes to shove.

Different types of tests

There are many ways of testing, and ideally, none should be ignored. All the facets of testing do have different use cases though, and every option should be used correctly. Also, it is key that tests are meaningful - they should not simply cover a lot of lines and then not have any assertions.

A typical image of testing types, the associated costs, and time it takes to create them can be seen in the testing pyramid.

The testing pyramid
Complexity of types of test can vastly increase the costs for them

Other people may have different groupings of tests, as they can be separated in a more granular way as well. However, I believe that discerning these 4 categories will already give you a good understanding of what they are, what they are used for, and when to use them.

Unit tests

Unit Tests are the cheapest, fastest and easiest to write. They ought to cover a single unit of code, with all the dependencies of that block of code being mocked. They are usually run in build time, but can be quickly run separately as well.

When to use unit tests

As can be seen in the testing pyramid, unit tests should be used by far the most. Essentially, whenever you write new code, there should be a unit test associated with it. Here are some characteristics for unit tests:

  • Independent and isolated
  • Descriptive
  • Repeatable
  • Only test single units
  • Fast
  • Not ignored

While it may be obvious that unit tests should cover units, I have seen often how they do not test only small, simple units of software but are instead mixed with integration tests. This makes the tests brittle, as you may need to refactor certain tests even when working on some code that’s only partially related to it.

Ideally, unit tests only break when you change something that belongs to that actual unit.

There are great tools out there that analyse how much code your tests cover, e.g. SonarQube. The analysis may be about line coverage, condition coverage, etc.

Integration tests

Integration tests are already a little more tricky to define. They are usually still inside a single application, but they already cover some bundled units which are then tested as a group. For example, a Java test that’s annotated with @SpringBootTest will spin up a complete Spring application context, load all the beans, wire them, etc. This does not take single seconds, but can take multiple minutes. Obviously, you do not want to wait all that time for them to run if all you want to test is that some String is transformed into upper case.

When to use integration tests

Integration tests can be extremely useful in certain cases, however. Back to the Java/Spring example, it does make sense to have a very basic test (which is even generated by default in applications created with an initializr), which only has the aforementioned annotation on it. It does not check anything but the fact that the application context can start. You may have all the unit tests in the world - if the application beans are not wired correctly, your application wouldn’t even start!

Another example of integration tests would be fancy database methods, where you want to check that you wrote that little bit of SQL correctly. Or that your REST controllers pick up the requests in the endpoint definitions you expect.

As you can already tell, they cover more complex scenarios, usually require more setup, and should therefore be used more sparseley.

UI tests

These tests are often also called E2E tests, which stands for end-to-end tests.

In these tests, you verify the logic across multiple components. They are ideally still automated, however. While they do provide the greatest coverage, even throughout several applications, they will also break the most often. They can even break from one day to the next, without you deploying or even merging new code into the code base, leaving you scratching your head, and losing a lot of time to investigate the issue.

When to use UI/E2E tests

Oftentimes, these tests enable the following improvements:

  • Checking edge cases
  • That the APIs between the components are implemented correctly
  • Reducing time to market
  • Reducing application integration errors

These require the most setup, which is why they take much more time to get them right. As long as some functionality is missing, you will not be able to run all of them successfully. They are definitely useful to calm your nerves whenever you are moving from the staging area to production, but you will need to take into account that they will be blocked for unknown reasons.

Manual tests

Finally, there are manual tests. These should really be avoided, if possible. Business will love these tests, as they seem to not fully trust automated tests. Or they might trust them, but they trust themselves more. No one has ever heard of human error, right?…

As the name suggests, these are tests that are performed by a person. The time invested in them is linear, which is very expensive. Imagine doing 50 regression tests manually after every single merge. However, they can be useful, since they do underline the strange things your user may be making, and therefore uncover potential improvements for your application.

When to use manual tests

Essentially, as little as possible. That’s pretty much it.

The best reason to perform manual tests is really just verifying that new functionality works, and to verify what that new functionality looks like. These tests are of course much more pleasing to do (the first time), as you can feel the pride of what you’ve been working on.

Testing with Jest and Enzyme

What is Jest?

Jest is one of the most commonly used tools for unit testing of Javascript and Typescript code. It allows you to assert results for pure functions, asynchronous code, as you might expect. However, you can also create snapshots, which are created the first time code that generates HTML/TSX is run. Subsequent runs of the tests are then compared to these snapshots to ensure that the UI has not changed.

Get started with Jest

To install Jest, simply execute npm install --save-dev jest. If you want to verify snapshots, you will also need to run npm i react-test-renderer --save-dev.

Next, I’ve created a directory called src/__tests__/, under which all the tests will lie. Often the tests are put right next to the components themselves, but I’m not a fan of cluttering the directories of actual code with tests, hence my separation.

Creating the first test

Let’s start with a simple component, assets/accounts/AccountItem.test.tsx. This component does not have any children, so it’s a good place to start.

To begin, let’s create a test that fails.

describe("AccountItem rendering", () => { 
  test("AccountItem is rendered correctly", () => {
    expect(1+1).toEqual(3)
  });
});

First, some comments about above code. describe is used to wrap a single, or a group of tests in a logical unit. Usually, your IDE will group them together, so it will be easier to find the errors that occur.

test is the actual test. This is again displayed in the IDE. If you’ve used create-react-app to instantiate your application, you need only run npm test to run the test. If this does not work, you may need to add the following in your package.json:

  "scripts": {
    "test": "jest"
  },

After running the test, you will receive the following result.

Error message on React
Error message on React

Wonderful! Now, let’s have our test verify actual React code, and not simply some dummy Typescript.

React testing with testing-library

For our first snapshot test we need to import React, react-test-renderer and the component to test. If you look for tutorials online, you will often find guides on how to use Jest together with Enzyme. This may be advantageous sometimes, but it is not really required. Therefore I will focus on using only Jest for now.

Changing the test to create a snapshot will give the following:

import React from "react";
import {AccountItem} from "../../../assets/accounts/AccountItem";
import {cleanup, render} from "@testing-library/react";

const account = {
  "id": 1,
  "name": "myAccount",
  "description": "This is a description.",
  "currency": "CHF",
  "amount": 100
};

afterEach(cleanup);

describe("AccountItem rendering", () => {
  test("AccountItem is rendered correctly", () => {
    const accountItem = render(<AccountItem account={account} />);
    expect(accountItem).toMatchSnapshot();
  });
});

Running this the first time, the test will actually fail. This makes sense, since there is no snapshot just yet. However, you can choose to generate a snapshot now and run it again, to see that lovely green of a test result.

If we now change the value of the name, and run the test again, it will of course fail. Often, this is a sign that you have made changes in the component, and it was to be expected. However, if your latest changes have not impacted the component at all, you now know that you have introduced an error.

Error message on React with snapshots
Error message on React with snapshot testing.

Now, if you have a pipeline, you should create the snapshots locally and commit them along with the code. Pipelines generally don’t create this kind of files, and especially they wouldn’t have a place to store them. Hence, you need to provide the results to them.

Additionally, keep in mind that you’d better verify that the component does indeed look as you intended before you update a snapshot. The test will pretty much always pass after updating a snapshot, but a changing snapshot does mean that something has changed, so it’s better to be safe than sorry.

Testing behaviour with useState and useEffect

The AccountItem may not be the best example for this, as there is no real behavioral logic in there. It’s purely a representational component. We do, however, already have the AccountDisplay component, that has a little logic in it. Remember the switch between tiles and a table? Well, we can also verify that this works correctly. If you want to check the code for the AccountDisplay, you can do so here.

Right, the test for the AccountDisplay, which is called AccountDisplay.test.tsx offers two new challenges.

  • Initially, it has a state for the preferred view of tiled
  • There are no accounts, as they get loaded in a useEffect

First, we need to mock the AccountService. This is a unit test for AccountDisplay, so we do not want to worry about its dependencies. The mock will simply return an expected value, which we can then assert that the component uses this new data correctly.

So, in theory, the mock is supposed to look like so:

import AccountService from "../../../assets/accounts/AccountService";

jest.mock('AccountService');

describe("AccountDisplay rendering", () => {
    test("AccountDisplay makes service request and displays result correctly", () => {
        jest.mock('../../../assets/accounts/AccountService');
        AccountService.getAccounts.mockResolvedValue(accounts);
        ...

However, if you write this in Typescript, your IDE will start complaining. JavaScript would have worked fine, but for Typescript, you need just a little more help.

Fortunately, there have been helpful libraries that were developed for precisely this. One such library is ts-jest. You can add it to your project by running npm install ts-jest. This will allow you to elegantly wrap some functionality to remove the TypeScript warnings that your IDE will throw.

After having installed this, you can wrap the stub of the mock declaration with mocked: mocked(AccountService.getAccounts).mockResolvedValue(accounts);.

The next issue is that our useEffect is actually doing stuff asynchronously. This is a problem, since the assertions are being performed so quickly, that whatever is in the useEffect won’t have time to actually run. This problem is solved by

  • wrapping the test in an async
  • waiting using await wait();

Now, the tests looks as follows (with a minimal assertion), just to check that the accounts actually are added to the DOM.

import React from "react";
import {act, cleanup, render, screen, wait, waitFor} from "@testing-library/react";
import {mocked} from "ts-jest/utils";
import AccountService from "../../../assets/accounts/AccountService";
import AccountDisplay from "../../../assets/accounts/AccountDisplay";

const accounts = [{
  "id": 1,
  "name": "myAccount",
  "description": "This is a description.",
  "currency": "CHF",
  "amount": 100
},
  {
    "id": 2,
    "name": "myAccount2",
    "currency": "USD",
    "amount": 200
  }];

jest.mock('../../../assets/accounts/AccountService');

afterEach(cleanup);

describe("AccountDisplay rendering", () => {
  test("AccountDisplay makes service request and displays result correctly", async () => {
    mocked(AccountService.getAccounts).mockResolvedValue(accounts);
    render(<AccountDisplay/>);

    expect(screen.queryByText("myAccount")).toBeNull();
    await wait();

    expect(screen.queryByText("myAccount")).toBeDefined();
  });
});

Now, another very popular testing library is Enzyme. As we have seen, Jest can be used without Enzyme, however, it is a lot more tedious. Enzyme adds a lot of additional functionality.

Enzyme can be installed by running npm i @wojtekmaj/enzyme-adapter-react-17 (this is the unofficial adapter for react 17). A little additional configuration is needed, so add the following code in the src/setupTests.ts (exactly that directory, otherwise it won’t work…)

import Enzyme from 'enzyme';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';

Enzyme.configure({ adapter: new Adapter() });

The main reason I mention Enzyme is because of the shallow method, and quite a few new assertions that can be done on those rendered components. Also, one can check for child components fairly easily, without requiring the data-testid attribute on a component. Whenever I can avoid adding attributes or code that is used only in testing, I tend to do so.

So, the only thing left to test is the correct changing of display. Since we are only checking the AccountDisplay, we should only check for its children - at first there should be the tiles, and after clicking the ToggleButtonGroup, the table should appear. The following test checks for precisely this:

  test("AccountDisplay can be viewed as tiled and tabled list", async () => {
    const wrapper = shallow(<AccountDisplay/>);

    expect(wrapper.find(AccountTiles)).toHaveLength(1);
    expect(wrapper.find(AccountList)).toHaveLength(0);

    const toggleGroup = wrapper.find(ToggleButtonGroup);

    toggleGroup.simulate('change');

    expect(wrapper.find(AccountTiles)).toHaveLength(0);
    expect(wrapper.find(AccountList)).toHaveLength(1);
  });

Keep in mind that there do not need to be any actual accounts. As long as the child component is there, albeit empty, the useState works perfectly well.

There we go. We have covered the need for testing, and delved deeper into unit tests. We have covered Jest, Enzyme, and react-test-renderer. We have gone over mocking of services, snapshot testing, as well as the cheaper functional testing. It may sometimes be annoying to write tests, as opposed to working on new functionality, but trust me - the peace of mind you will have when the component becomes large is absolutely worth it!

Happy testing!!