DEV Community

Cover image for React Testing Crash Course
Gábor Soós for Emarsys Craftlab

Posted on • Originally published at blog.craftlab.hu

React Testing Crash Course

You have nearly finished your project, and only one feature is left. You implement the last one, but bugs appear in different parts of the system. You fix them, but another one pops up. You start playing a whack-a-mole game, and after multiple turns, you feel messed up. But there is a solution, a life-saver that can make the project shine again: write tests for the future and already existing features. This guarantees that working features stay bug-free.

In this tutorial, I’ll show you how to write unit, integration and end-to-end tests for React applications.

For more test examples, you can take a look at my React TodoMVC or React Hooks TodoMVC implementation.

1. Types

Tests have three types: unit, integration and end-to-end. These test types are often visualized as a pyramid.

Testing Pyramid

The pyramid indicates that tests on the lower levels are cheaper to write, faster to run and easier to maintain. Why don’t we write only unit tests then? Because tests on the upper end give us more confidence about the system and they check if the components play well together.

To summarize the difference between the types of tests: unit tests only work with a single unit (class, function) of code in isolation, integration tests check if multiple units work together as expected (component hierarchy, component + store), while end-to-end tests observe the application from the outside world (browser).

2. Test runner

For new projects, the easiest way to add testing to your project is through the Create React App tool. When generating the project (npx create-react-app myapp), you don't need to enable testing. Unit/integration tests can be written in the src directory with *.spec.js or *.test.js suffix. Create React App uses the Jest testing framework to run these files. Jest isn't just a test runner, it also includes an assertion library in contrary to Mocha.

3. Single unit

So far, so good, but we haven’t written any tests yet. Let’s write our first unit test!

describe('toUpperCase', () => {
  it('should convert string to upper case', () => {
    // Arrange
    const toUpperCase = info => info.toUpperCase();

    // Act
    const result = toUpperCase('Click to modify');

    // Assert
    expect(result).toEqual('CLICK TO MODIFY');
  });
});
Enter fullscreen mode Exit fullscreen mode

The above is an example verifies if the toUpperCase function converts the given string to upper case.

The first task (arrange) is to get the target (here a function) into a testable state. It can mean importing the function, instantiating an object, and setting its parameters. The second task is to execute that function/method (act). After the function has returned the result, we make assertions for the outcome.

Jest gives us two functions: describe and it. With the describe function we can organize our test cases around units: a unit can be a class, a function, component, etc. The it function stands for writing the actual test-case.

Jest has a built-in assertion library and with it, we can set expectations on the outcome. Jest has many different built-in assertions. These assertions, however, do not cover all use-cases. Those missing assertions can be imported with Jest's plugin system, adding new types of assertions to the library (like Jest Extended and Jest DOM).

Most of the time, you will be writing unit tests for the business logic that resides outside of the component hierarchy, for example, state management or backend API handling.

4. Component display

The next step is to write an integration test for a component. Why is it an integration test? Because we no longer test only the Javascript code, but rather the interaction between the DOM as well as the corresponding component logic.

In the component examples, I'll use Hooks, but if you write components with the old syntax it won't affect the tests, they're the same.

import React, { useState } from 'react';

export function Footer() {
  const [info, setInfo] = useState('Click to modify');
  const modify = () => setInfo('Modified by click');

  return (
    <div>
      <p className="info" data-testid="info">{info}</p>
      <button onClick={modify} data-testid="button">Modify</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The first component we test is one that displays its state and modifies the state if we click the button.

import React from 'react';
import { render } from '@testing-library/react';
import { Footer } from './Footer.js';

describe('Footer', () => {
  it('should render component', () => {
    const { getByTestId } = render(<Footer />);

    const element = getByTestId('info');

    expect(element).toHaveTextContent('Click to modify');
    expect(element).toContainHTML('<p class="info" data-testid="info">Click to modify</p>');
    expect(element).toHaveClass('info');
    expect(element).toBeInstanceOf(HTMLParagraphElement);
  });
});
Enter fullscreen mode Exit fullscreen mode

To render a component in a test, we can use the recommended React Testing Library's render method. The render function needs a valid JSX element to render. The return argument is an object containing selectors for the rendered HTML. In the example, we use the getByTestId method that retrieves an HTML element by its data-testid attribute. It has many more getter and query methods, you can find them in the documentation.

In the assertions, we can use the methods from the Jest Dom plugin, which extends Jests default assertion collection making HTML testing easier. The HTML assertion methods all expect an HTML node as input and access its native properties.

5. Component interactions

We have tested what can we see in the DOM, but we haven’t made any interactions with the component yet. We can interact with a component through the DOM and observe the changes through its content. We can trigger a click event on the button and observe the displayed text.

import { render, fireEvent } from '@testing-library/react';

it('should modify the text after clicking the button', () => {
  const { getByTestId } = render(<Footer />);

  const button = getByTestId('button');
  fireEvent.click(button);
  const info = getByTestId('info');

  expect(info).toHaveTextContent('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

We need a DOM element where the event can be triggered. The getters returned from the render method returns that element. The fireEvent object can trigger the desired events trough its methods on the element. We can check the result of the event by observing the text content as before.

6. Parent-child interactions

We have examined a component separately, but a real-world application consists of multiple parts. Parent components talk to their children through props, and children talk to their parents through function props.

Let’s modify the component that it receives the display text through props and notifies the parent component about the modification through a function prop.

import React from 'react';

export function Footer({ info, onModify }) {
  const modify = () => onModify('Modified by click');

  return (
    <div>
      <p className="info" data-testid="info">{info}</p>
      <button onClick={modify} data-testid="button">Modify</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the test, we have to provide the props as input and check if the component calls the onModify function prop.

it('should handle interactions', () => {
  const info = 'Click to modify';
  let callArgument = null;
  const onModify = arg => callArgument = arg;
  const { getByTestId } = render(<Footer info={info} onModify={onModify} />);

  const button = getByTestId('button');
  fireEvent.click(button);

  expect(callArgument).toEqual('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

We pass down the info prop and the onModify function prop through JSX to the component. When we trigger the click event on the button, the onModify method is called and it modifies the callArgument variable with its argument. The assertion at the end checks the callArgument whether it was modified by the child components function prop.

7. Store integration

In the previous examples, the state was always inside the component. In complex applications, we need to access and mutate the same state in different locations. Redux, a state management library that can be easily connected to React, can help you organize state management in one place and ensure it mutates predictably.

import { createStore } from 'redux';

function info(state, action) {
  switch (action.type) {
    case 'MODIFY':
      return action.payload;
    default:
      return state;
  }
}

const onModify = info => ({ type: 'MODIFY', payload: info });
const store = createStore(info, 'Click to modify');
Enter fullscreen mode Exit fullscreen mode

The store has a single state, which is the same as what we have seen on the component. We can modify the state with the onModify action that passes the input parameter to the reducer and mutates the state.

Let's construct the store and write an integration test. This way, we can check if the methods play together instead of throwing errors.

it('should modify state', () => {
  store.dispatch(onModify('Modified by click'));

  expect(store.getState()).toEqual('Modified by click');
});
Enter fullscreen mode Exit fullscreen mode

We can alter the store through the dispatch method. The parameter to the method should be an action with the type property and payload. We can always check the current state through the getState method.

When using the store with a component, we have to pass the store instance as a provider to the render function.

const { getByTestId } = render(
  <Provider store={store}>
    <Header />
  </Provider>
);
Enter fullscreen mode Exit fullscreen mode

8. Routing

The simplest way of showing how to test routing inside a React app is to create a component that displays the current route.

import React from 'react';
import { withRouter } from 'react-router';
import { Route, Switch } from 'react-router-dom';

const Footer = withRouter(({ location }) => (
  <div data-testid="location-display">{location.pathname}</div>
));

const App = () => {
  return (
    <div>
      <Switch>
        <Route component={Footer} />
      </Switch>
    </div>
  )
};
Enter fullscreen mode Exit fullscreen mode

The Footer component is wrapped with the withRouter method, which adds additional props to the component. We need another component (App) that wraps the Footer and defines the routes. In the test, we can assert the content of the Footer element.

import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { render } from '@testing-library/react';

describe('Routing', () => {
  it('should display route', () => {
    const history = createMemoryHistory();
    history.push('/modify');

    const { getByTestId } = render(
      <Router history={history}>
        <App/>
      </Router>
    );

    expect(getByTestId('location-display')).toHaveTextContent('/modify');
  });
});
Enter fullscreen mode Exit fullscreen mode

We have added our component as a catch-them-all route by not defining a path on the Route element. Inside the test it is not advised to modify the browsers History API, instead, we can create an in-memory implementation and pass it with the history prop at the Router component.

9. HTTP requests

Initial state mutation often comes after an HTTP request. While it is tempting to let that request reach its destination in a test, it would also make the test brittle and dependant on the outside world. To avoid this, we can change the request’s implementation at runtime, which is called mocking. We will use Jest's built-in mocking capabilities for it.

const onModify = async ({ commit }, info) => {
  const response = await axios.post('https://example.com/api', { info });
  commit('modify', { info: response.body });
};
Enter fullscreen mode Exit fullscreen mode

We have a function: the input parameter is first sent through a POST request, and then the result is passed to the commit method. The code becomes asynchronous and gets Axios as an external dependency. The external dependency will be the one we have to change (mock) before running the test.

it('should set info coming from endpoint', async () => {
  const commit = jest.fn();
  jest.spyOn(axios, 'post').mockImplementation(() => ({
    body: 'Modified by post'
  }));

  await onModify({ commit }, 'Modified by click');

  expect(commit).toHaveBeenCalledWith('modify', { info: 'Modified by post' });
});
Enter fullscreen mode Exit fullscreen mode

We are creating a fake implementation for the commit method with jest.fn and change the original implementation of axios.post. These fake implementations capture the arguments passed to them and can respond with whatever we tell them to return (mockImplementation). The commit method returns with an empty value because we haven't specified one. axios.post will return with a Promise that resolves to an object with the body property.

The test function becomes asynchronous by adding the async modifier in front of it: Jest can detect and wait for the asynchronous function to complete. Inside the function, we wait for the onModify method to complete with await and then make an assertion wether the fake commit method was called with the parameter returned from the post call.

10. The browser

From a code perspective, we have touched every aspect of the application. There is a question we still can’t answer: can the application run in the browser? End-to-end tests written with Cypress can answer this question.

Create React App doesn't have a built-in E2E testing solution, we have to orchestrate it manually: start the application and run the Cypress tests in the browser, and then shut down the application. It means installing Cypress for running the tests and start-server-and-test library to start the server. If you want to run the Cypress tests in headless mode, you have to add the --headless flag to the command.

describe('New todo', () => {
  it('it should change info', () => {
    cy.visit('/');

    cy.contains('.info', 'Click to modify');

    cy.get('button').click();

    cy.contains('.info', 'Modified by click');
  });
});
Enter fullscreen mode Exit fullscreen mode

The organization of the tests is the same as with unit tests: describe stands for grouping, it stands for running the tests. We have a global variable, cy, which represents the Cypress runner. We can command the runner synchronously about what to do in the browser.

After visiting the main page (visit), we can access the displayed HTML through CSS selectors. We can assert the contents of an element with contains. Interactions work the same way: first, select the element (get) and then make the interaction (click). At the end of the test, we check if the content has changed or not.

Summary

We have reached the end of testing use-cases. I hope you enjoyed the examples and they clarified many things around testing. I wanted to lower the barrier of starting to write tests for a React application. We have gone from a basic unit test for a function to an end-to-end test running in a real browser.

Through our journey, we have created integration tests for the building blocks of a React application (components, store, router) and scratched the surface of implementation mocking. With these techniques, your existing and future projects can stay bug-free.

Top comments (8)

Collapse
 
eastonaltman profile image
Easton Altman

So, you're suggesting to use Cypress to execute tests in the browser.
But Cypress doesn't run on Edge, Safari, Internet Explorer and mobile browsers.
And performing some basic operations (iframes, file uploads, multiple tabs) is a nightmare in Cypress.
What do you suggest we use instead?

Collapse
 
sonicoder profile image
Gábor Soós

Puppeteer, Playwright and Testcafe can be alternatives for cross-browser testing. If really not necessary I would drop IE in favor of Edge.

Collapse
 
eastonaltman profile image
Easton Altman

What do you mean drop IE in favor of Edge?

We can ask users to change their default browser, that's not how things work.

Puppeteer works only with Chrome.

Playwright does not work with internet Explorer, Safari or mobile browsers.

TestCafe is pretty awful.

Why didn't you mention Selenium?

Collapse
 
bahmutov profile image
Gleb Bahmutov

Hi Gábor, excellent blog post as usual.

I took liberty forking your TodoMVC repo into github.com/bahmutov/todomvc-react and adding a Cypress component test. Rather than using synthetic JSDom, Cypress can mount a React component using github.com/bahmutov/cypress-react-... and run it. Then it becomes a "normal" realistic test. For example, the Footer component - let's mount it with a few props and click on the "clear completed" button to make sure it calls the passed function.

import React from 'react';
import { mount } from 'cypress-react-unit-test';
import { Footer } from './footer';
// import app's style so the footer looks real
import 'todomvc-app-css/index.css';

describe('footer component', () => {
  it('calls onClearCompleted if there are completed items', () => {
    mount(
      <section className="todoapp">
        <Footer itemsLeft={10} completedCount={2} 
             onClearCompleted={cy.stub().as('clear')} />
      </section>
    );
    // component is running like a mini web app
    // we can interact with it using normal Cypress commands
    // https://on.cypress.io/api
    cy.contains('Clear completed').click();
    cy.get('@clear').should('have.been.calledOnce');
  });
});

When you run the test you see the footer, just like you would in a real application. You can debug each command, for example seeing where the click happened.

For more details, see github.com/bahmutov/cypress-react-... and happy testing!

Collapse
 
sonicoder profile image
Gábor Soós

This component testing with Cypress deserves a separate post :)

Collapse
 
asherirving profile image
Asher Irving

Uh, I hate Cypress.
Our team tried to use it, such a bad experience.

seleniumtests.com/2019/11/cypress-...

If you actually want to create solid tests, use Selenium or Endtest.

Collapse
 
benyou1324 profile image
Mansour Benyoucef ☕

Thanks for Sharing. <3

Some comments may only be visible to logged-in visitors. Sign in to view all comments.