2022-11-02T12:03:09Z

The React Mega-Tutorial, Chapter 11: Automated Testing

Up to now, all the testing done on the application you've built was manual. Manual testing as you develop your application is useful, but as the application grows the required testing effort grows as well, until it becomes so time-consuming that the only way to keep up is to test less, or to find a way to automate some testing work. In this chapter you are going to learn some techniques to create automated tests for your React application.

The complete course, including videos for every chapter is available to order from my courses site. Ebook and paperback versions of this course are also available from Amazon. Thank you for your support!

For your reference, here is the complete list of articles in this series:

The Purpose of Automated Testing

When asked about the purpose of automated testing, most people say that it is the same as testing manually, which is to find bugs. This is, in general terms, correct, but what many don't know is that automated testing is far superior to manual testing at finding a specific type of bugs called regressions.

Regressions are bugs that occur in code that previously worked correctly. They are usually caused by additions or modifications that affect existing code in ways that the developer fails to recognize. The larger the codebase, the easier it is for regressions to be introduced, as the relationships and dependencies between different parts of the application become more complex difficult to track.

In short, when you develop a new feature of your application, you are going to test it manually and ensure that it works correctly. This is the best time for you to spend a little more time creating automated tests that can ensure that this feature continues to work as designed in the future, as more changes and features are added.

Testing React Applications with Jest

Applications created with Create React App come with testing support built it, using the Jest testing framework.

A common naming convention for test files is to use a .test.js suffix. For example, tests that apply to code in src/App.js are written in a src/App.test.js file. In fact, the project already has a src/App.test.js file with an example test that was added by Create React App.

The example test in src/App.test.js was designed to work with the starter application. With all the changes that went into the application this test is now failing.

To start the test suite, you can run the following command from a terminal:

npm test

As with npm start, the test command runs in the background and watches your code changes. As files change, related tests execute.

When the test suite is started, Jest is going to try to determine what were the most recent changes that were made to the application, and then run the tests for the affected files. Jest may decide that App.js needs to be tested, and then you will see a long description of this test's failure, followed by a summary such as the following:

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.996 s, estimated 2 s
Ran all test suites.

Watch Usage: Press w to show more.

Jest may also decide that none of the changes you made recently to the project affect App.js, and in that case it will not run any tests and just start to watch for changes with the following message:

No tests found related to files changed since last commit.
Press `a` to run all tests, or run Jest with `--watchAll`.

Watch Usage
 › Press a to run all tests.
 › Press f to run only failed tests.
 › Press q to quit watch mode.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press Enter to trigger a test run.

If you get this output, you press "a" to instruct Jest to run the test suite. I recommend that you do this, so that you become familiar with how Jest shows test failures.

Renders, Queries and Assertions

The first task is to make adjustments to the test in src/App.test.js so that it is correct for the application in its current state. Open this file to have a look at the test code:

import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

To write a test, you called the test() function, which is globally installed by the Jest framework. The first argument to this function is a written description of the purpose of the test, and the second argument is a function that contains the test logic.

There are several libraries and tools that make it possible to write tests. The test above shows three of them:

  • The render() function from the React Testing Library allows the test to render a component. Rendering in this case does not happen in the browser, but in an emulated environment that is similar to it.
  • The screen object, also exposed by the same library, allows the test to query the contents of the page that was rendered by render().
  • The expect() function, another global from Jest, allows the test to create assertions, which are the checks that determine if the test passed of failed.

In the test, the render() function is used to render the App component. Then screen.getByText() is used to find an element in the render page that has the given text. Finally, expect() is used to ensure that this element exists in the page.

Let's rewrite this test so that it checks that the "Microblog" heading in the top-left corner of the page is rendered. Here is the updated version of src/App.test.js with this test:

src/App.test.js: First application test

import { render, screen } from '@testing-library/react';
import App from './App';

test('renders brand element', () => {
  render(<App />);

  const element = screen.getByText(/Microblog/);

  expect(element).toBeInTheDocument();
  expect(element).toHaveClass('navbar-brand');
});

If you have the npm test command running, as soon as you save the new version of src/App.test.js the test will run again, and this time it will pass.

The new version of the test still renders App, but then it runs a query to locate an element in the page with the text "Microblog", which should return the title element in the heading.

In this test there are two assertions. First the test ensures that the element was found in the page. As an additional check, it makes sure that the element has the class navbar-brand, which comes from Bootstrap.

The majority of tests in a React application are going to have this same structure. First, a component will be rendered, then one or more queries will retrieve elements of interest from the rendered contents, and finally, one or more assertions will ensure that these elements have the expected attributes.

Using Queries

The React Testing Library provides the screen object to perform queries on content previously rendered with the render() function.

When looking for an element, there are three groups of queries:

  • getBy...() returns the matching element, or raises an error if there are no matching elements, or more than one matching element.
  • queryBy...() returns the matching element, returns null if there are no matching elements, or raises an error if there are more than one matching elements.
  • findBy...() waits for up to one second (by default) for a matching element to appear in the rendered page. An error is raised if no matching element or multiple elements appear during the waiting period. This method is asynchronous and returns a promise.

Sometimes it is useful to look for multiple matching elements. There is a similar set of query functions that support this:

  • getAllBy...() returns an array with the matching elements, or raises an error if there are no matches.
  • queryAllBy...() returns an array with the matching elements, or an empty array if there are no matches.
  • FindAllBy...() waits for one or more matching elements to appear and returns them as an array. An error is raised if no matching elements appear. This method is asynchronous and returns a promise.

For each of these six query options, you can choose from a selection of query options. Above you've seen how to locate an element by its text with getByText(). Other options are getByTitle(), getByAltText(), getByRole() and more that you can see in the documentation.

Using Assertions

Once you have an element of interest, you have to create assertions that ensure that this element has the expected structure. This is done with the expect() function from Jest, which is combined with a matcher function. Below is a straightforward assertion that checks that a variable result is null:

expect(result).toBeNull();

The structure of an assertion is always the same. The value or expression that is the target of the check is passed as an argument to the expect() function. Then a matcher method such as toBeNull() above is called on the result.

Jest provides an extensive list of matcher methods, but none of this are specific to React or web development. The jest-dom package, which is also installed by Create React App, extends Jest with a range of custom matcher methods that apply to DOM elements, such as the toBeInTheDocument() and toHaveClass() methods used in the test above.

Testing Individual Components

The test above rendered the complete application, starting from the App component. In many cases it is preferable to render a single component or a small subset.

Below you can see a test for the Post component.

src/components/Post.test.js: Test Post component

import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Post from './Post';

test('it renders all the components of the post', () => {
  const timestampUTC = '2020-01-01T00:00:00.000Z';
  const post = {
    text: 'hello',
    author: {username: 'susan', avatar_url: 'https://example.com/avatar/susan'},
    timestamp: timestampUTC,
  };

  render(
    <BrowserRouter>
      <Post post={post} />
    </BrowserRouter>
  );

  const message = screen.getByText('hello');
  const authorLink = screen.getByText('susan');
  const avatar = screen.getByAltText('susan');
  const timestamp = screen.getByText(/.* ago$/);

  expect(message).toBeInTheDocument();
  expect(authorLink).toBeInTheDocument();
  expect(authorLink).toHaveAttribute('href', '/user/susan');
  expect(avatar).toBeInTheDocument();
  expect(avatar).toHaveAttribute('src', 'https://example.com/avatar/susan&s=48');
  expect(timestamp).toBeInTheDocument();
  expect(timestamp).toHaveAttribute(
    'title', new Date(Date.parse(timestampUTC)).toString());
});

This test starts by creating a fake blog post, with a similar structure as the posts that are returned by Microblog API.

In the render section, an instance of the Post component receiving the fake blog post is rendered. Because the Post component renders a link that navigates to the author's page, it is necessary to have the support of React Router, so instead of rendering the Post component alone, it is wrapped in a BrowserRouter component.

When rendering isolated components, it is often necessary to create a minimal environment that is similar to that of the real application. In this case, the component needed routing support, so a router component was added. In other cases a component might need to have access to a specific context, to the corresponding provider component must be included in the render. A good approach to render the smallest possible tree is to start by rendering the target component and look at the errors the render produces to determine what wrapper components are needed.

In the query section of the test, several elements of the rendered post are retrieved. Sometimes it may not be clear what is the best method to query for an element. A good approach is to look at the HTML code that the component renders in the real application from your browser's developer console to find if there is a particular text, alt text or maybe title that can be used to query for the element. If you can't find anything that you can use, you can add a data-testid attribute to query for in the test.

The assertions of this test ensure that the four elements queried appear in the document. For the author link, the href attribute is also checked to be correct. Likewise, for the avatar image, the src attribute is checked. The timestamps rendered by the TimeAgo component have a title attribute that can be included in assertions as well. When trying to decide what assertions to write for a component, it is a good idea to look at the rendered HTML code of the target component in your browser's developer console.

Using Fake Timers

Are you ready for something more advanced? In the next test, a small Test component is created, with the only purpose of flashing a message. The test then verifies that the alert component appears on the rendered page.

src/contexts/FlashProvider.test.js: Test message flashing

import { render, screen } from '@testing-library/react';
import { useEffect } from 'react';
import FlashProvider from './FlashProvider';
import { useFlash } from './FlashProvider';
import FlashMessage from '../components/FlashMessage';

beforeEach(() => {
  jest.useFakeTimers();
});

afterEach(() => {
  jest.useRealTimers()
});

test('flashes a message', async () => {
  const Test = () => {
    const flash = useFlash();
    useEffect(() => {
      flash('foo', 'danger');
    }, []);
    return null;
  };

  render(
    <FlashProvider>
      <FlashMessage />
      <Test />
    </FlashProvider>
  );

  const alert = screen.getByRole('alert');

  expect(alert).toHaveTextContent('foo');
  expect(alert).toHaveClass('alert-danger');
});

There are a number of new concepts in this test. First, since the flashing mechanisms in the application use timers, it is a good idea to use mocked timers that can run time faster. The beforeEach() and afterEach() functions from Jest take functions that are executed before and after each test in the file. The before function calls jest.useFakeTimers() to switch to simulated timers, and the after function resets the default timers from JavaScript.

The test begins by creating the Test component, which launches a side effect function and then returns null to render itself as an empty component. The side effect function calls the flash() function, returned by the useFlash() hook.

The render() call in this test creates a small application with the FlashProvider component at the root. This is necessary so that the FlashContext is available. This component has two children: the FlashMessage component, which renders the alerts, and the Test component create specifically for this test.

An important implementation detail of the render() function is that it not only renders the component tree, but also waits for side effects functions to run and update the application state until the application settles and there is nothing else to update. When render() returns, the alert should already be visible.

The Alert component from React-Bootstrap can be obtained by its role. The assertions ensure that the text and the alert class are correct.

Testing for Follow-Up State Changes

The test above ensures that alerts are displayed, but the flash message system in this application has some complexity that the test isn't reaching. In particular, alerts are supposed to close on their own after 10 seconds. How can that be tested, ideally without having to wait those 10 seconds?

The complication with testing the automatic closing of the alert is that the alert isn't ever removed or hidden. The FlashMessage component wraps the alert with a Collapse component from React-Bootstrap. This component uses CSS transitions to shrink the alert until it reaches a height of 0 pixels instead of hiding it.

There are several options to work around this problem, but most would require the test to have knowledge of the inner workings of the Collapse component, which is not ideal because the test could break if React-Bootstrap is upgraded to a new version. A surprisingly simple solution is to add a custom data attribute to the Alert component indicating the visible state of the alert. This adds a tiny amount of overhead, but gives the test something to assert on.

In the listing below, the alert rendered by the FlashMessage component includes a data-visible attribute that exposes the visible state variable from the FlashContext.

src/components/FlashMessage.js: Add testing helper

... // <-- no changes to imports

export default function FlashMessage() {
  const { flashMessage, visible, hideFlash } = useContext(FlashContext);

  return (
    <Collapse in={visible}>
      <div>
        <Alert variant={flashMessage.type || 'info'} dismissible
          onClose={hideFlash} data-visible={visible}>
          {flashMessage.message}
        </Alert>
      </div>
    </Collapse>
  );
}

With the data-visible element in place, the flash message test can be expanded to check the message visibility.

src/contexts/FlashProvider.test.js: Test alert visibility

import { render, screen, act } from '@testing-library/react';
... // <-- no other changes to imports

... // <-- no changes to beforeEach() and afterEach()

test('flashes a message', () => {
  ... // <-- no changes to Test component or render() call

  const alert = screen.getByRole('alert');

  expect(alert).toHaveTextContent('foo');
  expect(alert).toHaveClass('alert-danger');
  expect(alert).toHaveAttribute('data-visible', 'true');

  act(() => jest.runAllTimers());
  expect(alert).toHaveAttribute('data-visible', 'false');
});

The data-visible attribute is checked to be true when the alert is rendered. After that, the test needs to wait 10 seconds for the timer to go off and collapse the alert, but having the test really wait that long is impractical. When working with Jest's fake timers, the jest.runAllTimers() function advances the time until the next timer, without having to wait for the actual time to pass.

As a result of the timer firing, some state variables in React will change, and that will require some re-renders, which in turn might launch new side effect functions that might require even more renders. The render() function is designed to wait for this asynchronous activity until all state variables, side effects and renders settle, but calling jest.runAllTimers() on its own would not provide the same kind of safety wait.

The React Testing Library provides the act() function to perform this type of waiting. Instead of calling jest.runAllTimers() directly, act() is called with a function that performs this action. The act() function will call the function passed as an argument, and then wait for the React application to settle down.

Once the timer has been advanced, a final assertion ensures that the data-visible attribute is now false, which confirms that the alert has been collapsed.

Mocking API Calls

Your tests should run in complete isolation, without requiring the availability of external services such as databases or API back ends. When a component that makes calls to a back end needs to be tested, any API calls that it makes should be mocked.

You have seen an example of mocking in the previous section, where a JavaScript timer was replaced with a fake version that runs much faster. In addition to providing fake timers, Jest provides an extensive set of functions for mocking functions or entire modules.

Ready to learn how to mock? The authentication subsystem is a core piece of the application that can benefit from automated testing. This is an excellent use case to learn how to work with mocks.

Since the authentication logic is mostly located in the UserProvider component, the tests will be in src/contexts/UserProvider.test.js. Below you can see the includes that will be needed, and a beforeEach() and afterEach() pair of functions that initialize and restore the test environment.

src/contexts/UserProvider.test.js: Create a mock for fetch()

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'
import { useState, useEffect } from 'react';
import FlashProvider from './FlashProvider';
import ApiProvider from './ApiProvider';
import UserProvider from './UserProvider';
import { useUser } from './UserProvider';

const realFetch = global.fetch;

beforeEach(() => {
  global.fetch = jest.fn();
});

afterEach(() => {
  global.fetch = realFetch;
  localStorage.clear();
});

The mocking in this case is done in the beforeEach() handler function, so that it applies to all the test in the file. Since the purpose of mocking is to prevent network access from actually reaching a remote server, the fetch() function is the target to mock. Before this function is modified, it is a good idea to save the original in a global variable, so that it can be restored after the tests are done. Accessing the fetch() function as global.fetch is done to make the intention of mocking a global function more clear.

The jest.fn() function creates a general purpose fake function that can be used to replace any function in the application. Functions created with jest.fn() have the interesting property that they record calls made to them, so these calls can then be checked in the test assertions. Jest mocks can be programmed to return specific values, as needed by each test, as you will see next.

To keep things tidy, the afterEach() handler reverts the mock back to the original function. Since the MicroblogApiClient class stores access tokens in local storage, it is also a good practice to clear any data the might have been stored during a test when the test is done.

Testing a Valid Login

The next listing shows the first test, which logs a user in. This code goes right after the afterEach() handler.

src/contexts/UserProvider.test.js: Login test

test('logs user in', async () => {
  const urls = [];

  global.fetch
    .mockImplementationOnce(url => {
      urls.push(url);
      return {
        status: 200,
        ok: true,
        json: () => Promise.resolve({access_token: '123'}),
      };
    })
    .mockImplementationOnce(url => {
      urls.push(url);
      return {
        status: 200,
        ok: true,
        json: () => Promise.resolve({username: 'susan'}),
      };
    });

  const Test = () => {
    const { login, user } = useUser();
    useEffect(() => {
      (async () => await login('username', 'password'))();
    }, []);
    return user ? <p>{user.username}</p> : null;
  };

  render(
    <FlashProvider>
      <ApiProvider>
        <UserProvider>
          <Test />
        </UserProvider>
      </ApiProvider>
    </FlashProvider>
  );

  const element = await screen.findByText('susan');
  expect(element).toBeInTheDocument();
  expect(global.fetch).toHaveBeenCalledTimes(2);
  expect(urls).toHaveLength(2);
  expect(urls[0]).toMatch(/^http.*\/api\/tokens$/);
  expect(urls[1]).toMatch(/^http.*\/api\/me$/);
});

The new and most interesting part of this test is at the start of the function. The urls array is going to be used to collect all the URLs that the application calls fetch() on. These calls are now going to be redirected to the mock function, so the test has full control of what happens in these calls.

The two mockImplementationOnce() calls that are made on the global.fetch function, which is now a mock function, provide alternative functions that are going to execute each time the application makes a call. The two implementations capture the url parameter that was sent by the application and add it to the urls array, so that they can be asserted later. Then they return a response that is similar to what the real fetch() function returns.

The first API call that the application makes goes to the /api/tokens endpoint to request an access token. The first fake function registered with the mock returns a success response with status code 200 and a made up 123 token.

Once the API client receives an API token, the UserProvider component is going to make a request to the /api/me endpoint to retrieve the information about the user. This call is handled by the second mocked response, which returns a fictitious user "susan".

Note that mockImplementationOnce() is not the only way to configure mocks, and in fact there is an extensive list of methods that can be used to model how the mocked function will behave during the test.

The mock function is now ready to be used. The Test component created by the test calls the login() function obtained from the useUser() hook in a side effect function, and renders the username of the logged-in user.

For the render portion of this test, the FlashProvider, ApiProvider and UserProvider components are added as wrappers to Test, so that all the hooks and contexts required during the test are available.

In the assertions section the screen.findByText() method is used to wait for the username to be rendered after the effect function completes and the fake user is logged in. Note that the findBy...() set of functions are asynchronous, so they need to be awaited. The test function was created as an async function so that await can be used here.

After ensuring that the username was rendered, the number of calls made to the global.fetch() mock is checked. And then, the urls array is also checked, to make sure the correct URLs were requested by the application. A regular expression is used to check the URLs, to avoid discrepancies in the domain and port portions of the URL, which are irrelevant to this test.

Testing an Invalid Login

The following test is similar to the previous one, but it models the case of a user providing invalid credentials.

src/contexts/UserProvider.test.js: Invalid login test

test('logs user in with bad credentials', async () => {
  const urls = [];

  global.fetch
    .mockImplementationOnce(url => {
      urls.push(url);
      return {
        status: 401,
        ok: false,
        json: () => Promise.resolve({}),
      };
    });

  const Test = () => {
    const [result, setResult] = useState();
    const { login, user } = useUser();
    useEffect(() => {
      (async () => {
        setResult(await login('username', 'password'));
      })();
    }, []);
    return <>{result}</>;
  };

  render(
    <FlashProvider>
      <ApiProvider>
        <UserProvider>
          <Test />
        </UserProvider>
      </ApiProvider>
    </FlashProvider>
  );

  const element = await screen.findByText('fail');
  expect(element).toBeInTheDocument();
  expect(global.fetch).toHaveBeenCalledTimes(1);
  expect(urls).toHaveLength(1);
  expect(urls[0]).toMatch(/^http.*\/api\/tokens$/);
});

This test programs the global.fetch() mock to return a 401 response in its first call, which is what would happen if a user entered an invalid username or password in the login form.

The Test component this time renders the return value of the login() function returned by the useUser() hook. If you recall, this function returns the string 'ok' when the login was successful, 'fail' when the login was invalid, and 'error' when an unexpected error occurred during the request.

The assertions ensure that "fail" is rendered to the page, in addition to similar checks on the mock and urls array.

Testing Logouts

The last authentication test ensures that users can log out of the application.

src/contexts/UserProvider.test.js: Invalid login test

test('logs user out', async () => {
  localStorage.setItem('accessToken', '123');

  global.fetch
    .mockImplementationOnce(url => {
      return {
        status: 200,
        ok: true,
        json: () => Promise.resolve({username: 'susan'}),
      };
    })
    .mockImplementationOnce((url) => {
      return {
        status: 204,
        ok: true,
        json: () => Promise.resolve({}),
      };
    });

  const Test = () => {
    const { user, logout } = useUser();
    if (user) {
      return (
        <>
          <p>{user.username}</p>
          <button onClick={logout}>logout</button>
        </>
      );
    }
    else if (user === null) {
      return <p>logged out</p>;
    }
    else {
      return null;
    }
  };

  render(
    <FlashProvider>
      <ApiProvider>
        <UserProvider>
          <Test />
        </UserProvider>
      </ApiProvider>
    </FlashProvider>
  );

  const element = await screen.findByText('susan');
  const button = await screen.findByRole('button');
  expect(element).toBeInTheDocument();
  expect(button).toBeInTheDocument();

  userEvent.click(button);
  const element2 = await screen.findByText('logged out');
  expect(element2).toBeInTheDocument();
  expect(localStorage.getItem('accessToken')).toBeNull();
});

This test presents a new challenge, because to be able to test that a user can log out, the user must be first logged in. Instead of repeating a login procedure as in previous tests, this time the test installs a fake access token in local storage, which will make the application believe that it is being started on a browser on which the user is logged in already.

The mocked fetch() function for this test includes two calls. The first mocks the response to the /api/me request issued by the UserProvider component. A second mocked is response is included for the token revocation request issued during logout.

The Test component used in this test is more complex than before. The component renders a page with the username of the logged-in user and a button to log out. When the button is clicked, the component re-renders with just the text "logged out".

The first group of assertions ensure that both the username and the button are rendered to the page, after the UserProvider component gets a chance to load the fake user from the mocked fetch() call.

The next step is to simulate a user clicking the button to log out. The React Testing Library includes user-event, a companion library that is specialized in generating fake events. In this case a click event is simulated on the button element.

Once the click is triggered, a screen.findByText() call is issued to retrieve the element with the "logged out" text. Note that this is issued with the asynchronous findBy...(), to wait for React to run all asynchronous operations and update the page.

Chapter Summary

  • The main purpose of writing automated tests is to ensure that new code does not fail in the future.
  • Applications bootstrapped with Create React App integrate the Jest testing framework and the React Testing Library.
  • Tests have access to a render() function to render the application, a subset, or an individual component.
  • Correction functioning is confirmed by querying the results of a render for elements of interest, and asserting that these elements have the expected structure.
  • Jest allows the tests to mock timers, remote services and other external entities required by the application, so that the test runs in an isolated, controlled and reproducible environment.

Leave a Comment