Testing React components

Magne Skutle
by Magne SkutleFeb 26, 2021

Automated tests help us ensure that code changes don't cause unintended problems elsewhere in the code base. They should make it easier to refactor code, save you time on manual testing and prevent bugs.

Introduction

There are a couple of libraries that can help you with testing React components, specifically. Two of the most well-known are Enzyme and React Testing Library (RTL). I haven't personally used Enzyme, but I really appreciate the design philosophy of RTL; Don't test the internals of a component, but instead try to mimic the user behavior as closely as possible.

If your tests involve inspecting and asserting on a component's internal state, you will most likely have to refactor your test if you ever want to change the component's implementation — which isn't ideal. It would be better if we could emulate how the user (which could be an actual end user or another developer consuming that component) interacts with it.

Let's take a look at an example.

Example - Forms

Here's a pretty simple form for ordering face masks.

ds

We have a few requirements:

  • All fields are required
  • The e-mail address needs to be valid
  • There's a limited supply of face masks, so you shouldn't be able to buy more than 5.

Let's implement it. I'm using react-hook-form here because I really like it, but you could replace it with any form library / roll your own.

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Input, Select, Option, Button } from "./FormInputs";

const schema = yup.object().shape({
  firstName: yup.string().required(),
  lastName: yup.string().required(),
  email: yup.string().email().required(),
  color: yup.string().required(),
  quantity: yup.number().min(1).max(5).required(),
});

export default function OrderForm(props) {
  const { register, handleSubmit } = useForm({ resolver: yupResolver(schema) });

  const onSubmit = (data) => {
    props.onSubmit(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col space-y-10"
    >
      <div className="space-y-2">
        <div className="flex space-x-4">
          <label htmlFor="firstName" className="flex flex-col">
            First name
            <Input ref={register} type="text" id="firstName" name="firstName" />
          </label>
          <label htmlFor="lastName" className="flex flex-col">
            Last name
            <Input ref={register} type="text" id="lastName" name="lastName" />
          </label>
        </div>
        <label htmlFor="email" className="flex flex-col">
          Email
          <Input ref={register} type="text" id="email" name="email" />
        </label>
      </div>
      <div className="space-y-2">
        <div className="flex space-x-4">
          <label htmlFor="color" className="flex flex-col">
            Color
            <Select ref={register} name="color" id="color">
              <Option value="null">Select...</Option>
              <Option value="red">Red</Option>
              <Option value="blue">Blue</Option>
              <Option value="green">Green</Option>
            </Select>
          </label>
          <label htmlFor="quantity" className="flex flex-col">
            Quantity
            <Input
              ref={register}
              type="number"
              name="quantity"
              id="quantity"
              min="1"
              max="5"
            />
          </label>
        </div>
        <div className="my-6">
          <Button type="submit">Submit</Button>
        </div>
      </div>
    </form>
  );
}

We've implemented the component by using react-hook-form and yup for validation. If you click the "submit" button with valid data, we'll call the onSubmit-function provided by the caller — who then decides what to do with the data.

Our first test

React Testing Library contains a range of functions for

  • rendering a component
  • querying the DOM
  • interacting with the component

Let's take a look at how we can test our form.

import { screen, render, waitFor } from "@testing-library/react";
import user from "@testing-library/user-event";
import OrderForm from "./OrderForm";

describe("OrderForm", () => {
  test("given valid input, it submits the form with the input", async () => {
    const onSubmit = jest.fn();
    render(<OrderForm onSubmit={onSubmit} />);

    const firstName = screen.getByLabelText(/first name/i);
    const lastName = screen.getByLabelText(/last name/i);
    const email = screen.getByLabelText(/email/i);
    const color = screen.getByLabelText(/color/i);
    const quantity = screen.getByLabelText(/quantity/i);
    const submitButton = screen.getByText(/submit/i);

    user.type(firstName, "Bob");
    user.type(lastName, "Johnson");
    user.type(email, "bob@johnson.com");
    user.selectOptions(color, "blue");
    user.type(quantity, "5");

    user.click(submitButton);

    await waitFor(() =>
      expect(onSubmit).toHaveBeenCalledWith({
        firstName: "Bob",
        lastName: "Johnson",
        email: "bob@johnson.com",
        color: "blue",
        quantity: 5,
      })
    );
  });
});

First, we create a mock function with jest.fn() that we can pass to onSubmit.

const onSubmit = jest.fn();
A mock function is a function that contains information about how and how many times it was called etc. See https://jestjs.io/docs/en/mock-functions

Second, we render the component using RTL's render function.

render(<OrderForm onSubmit={onSubmit} />);

Then we locate all of the form elements based on their label.

const firstName = screen.getByLabelText(/first name/i);
const lastName = screen.getByLabelText(/last name/i);
const email = screen.getByLabelText(/email/i);
const color = screen.getByLabelText(/color/i);
const quantity = screen.getByLabelText(/quantity/i);
const submitButton = screen.getByText(/submit/i);

We then simulate a user filling in all the fields before we hit the submit button.

user.type(firstName, "Bob");
user.type(lastName, "Johnson");
user.type(email, "bob@johnson.com");
user.selectOptions(color, "blue");
user.type(quantity, "5");

user.click(submitButton);

Lastly, we verify that the onSubmit-function was indeed called with the values we entered.

await waitFor(() =>
  expect(onSubmit).toHaveBeenCalledWith({
    firstName: "Bob",
    lastName: "Johnson",
    email: "bob@johnson.com",
    color: "blue",
    quantity: 5,
  })
);

Prevent submission on invalid input

test("given invalid email, the form should not be submitted", async () => {
  const onSubmit = jest.fn();
  render(<OrderForm onSubmit={onSubmit} />);

  const firstName = screen.getByLabelText(/first name/i);
  const lastName = screen.getByLabelText(/last name/i);
  const email = screen.getByLabelText(/email/i);
  const color = screen.getByLabelText(/color/i);
  const quantity = screen.getByLabelText(/quantity/i);
  const submitButton = screen.getByText(/submit/i);

  user.type(firstName, "Bob");
  user.type(lastName, "Johnson");
  user.selectOptions(color, "blue");
  user.type(quantity, "5");
  user.type(email, "some invalid email");

  user.click(submitButton);

  await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
});

Here we make sure that you won't be able to submit the form with an invalid e-mail address, even if all other fields had valid input. We could duplicate this test code for each input field, but instead we'll use jest-in-case to generate different test cases for us.

import cases from "jest-in-case";

const validInput = {
  firstName: "Bob",
  lastName: "Johnson",
  email: "bob@johnson.com",
  color: "red",
  quantity: "4",
};

cases(
  "invalid input will not submit the form",
  async (opts) => {
    const onSubmit = jest.fn();
    render(<OrderForm onSubmit={onSubmit} />);

    const firstName = screen.getByLabelText(/first name/i);
    const lastName = screen.getByLabelText(/last name/i);
    const email = screen.getByLabelText(/email/i);
    const color = screen.getByLabelText(/color/i);
    const quantity = screen.getByLabelText(/quantity/i);
    const submitButton = screen.getByText(/submit/i);

    user.type(firstName, opts.firstName);
    user.type(lastName, opts.lastName);
    user.type(email, opts.email);
    user.selectOptions(color, opts.color);
    user.type(quantity, opts.quantity);
    user.type(email, "some invalid email");

    user.click(submitButton);

    await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
  },
  [
    { name: "missing email", ...validInput, email: "" },
    { name: "invalid email", ...validInput, email: "bob.com" },
    { name: "missing firstname", ...validInput, firstName: "" },
    { name: "missing lastname", ...validInput, lastName: "" },
    { name: "missing color", ...validInput, color: "" },
    { name: "missing quantity", ...validInput, quantity: "" },
    { name: "invalid quantity", ...validInput, quantity: "50" },
  ]
);

Now the test code will run for all of the test cases specified by the 3rd parameter to the cases function call.

We can see our tests passing

ds

Refactoring

Since our tests only concern themselves with what the user sees and not with how the component is implemented, we can actually replace the implementation of our form without making any changes to our test.

This means we don't care about whether the form internally uses react-hook-form, useState, Redux, yup or whatever else to implement the functionality.

Let's try to rewrite the form and implement the logic ourselves instead of relying on react-hook-form and yup.

import { useState } from "react";
import { Input, Select, Option, Button } from "./FormInputs";

function isValidEmail(email) {
  const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
  return regex.test(String(email).toLowerCase());
}

export default function OrderForm(props) {
  const [state, setState] = useState({
    firstName: { value: "", valid: false },
    lastName: { value: "", valid: false },
    email: { value: "", valid: false },
    color: { value: "red", valid: true },
    quantity: { value: "", valid: false },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const formIsValid = Object.values(state).every((field) => field.valid);

    if (formIsValid) {
      const data = {
        firstName: state.firstName.value,
        lastName: state.lastName.value,
        email: state.email.value,
        color: state.color.value,
        quantity: parseInt(state.quantity.value),
      };
      props.onSubmit(data);
    }
  };

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    const isEmpty = value === "" || value === undefined;

    let isValid = true;

    if (isEmpty) {
      isValid = false;
    }

    if (name === "email" && !isValidEmail(value)) {
      isValid = false;
    }

    if (name === "quantity" && parseInt(value) > 5) {
      isValid = false;
    }

    setState({
      ...state,
      [name]: {
        ...state[name],
        value,
        valid: isValid,
      },
    });
  };

  return (
    <form onSubmit={handleSubmit} className="flex flex-col space-y-10">
      <div className="space-y-2">
        <div className="flex space-x-4">
          <label htmlFor="firstName" className="flex flex-col">
            First name
            <Input
              type="text"
              id="firstName"
              name="firstName"
              onChange={handleInputChange}
              value={state.firstName.value}
            />
          </label>
          <label htmlFor="lastName" className="flex flex-col">
            Last name
            <Input
              type="text"
              id="lastName"
              name="lastName"
              onChange={handleInputChange}
              value={state.lastName.value}
            />
          </label>
        </div>
        <label htmlFor="email" className="flex flex-col">
          Email
          <Input
            type="text"
            id="email"
            name="email"
            onChange={handleInputChange}
            value={state.email.value}
          />
        </label>
      </div>
      <div className="space-y-2">
        <div className="flex space-x-4">
          <label htmlFor="color" className="flex flex-col">
            Color
            <Select
              name="color"
              id="color"
              onChange={handleInputChange}
              value={state.color.value}
            >
              <Option value="">Select...</Option>
              <Option value="red">Red</Option>
              <Option value="blue">Blue</Option>
              <Option value="green">Green</Option>
            </Select>
          </label>
          <label htmlFor="quantity" className="flex flex-col">
            Quantity
            <Input
              type="number"
              value={state.quantity.value}
              name="quantity"
              id="quantity"
              min="1"
              max="5"
              onChange={handleInputChange}
            />
          </label>
        </div>
        <div className="my-6">
          <Button type="submit">Submit</Button>
        </div>
      </div>
    </form>
  );
}

When we re-run our tests we see that they all still pass!

ds

When we avoid testing implementation details, we can use our tests as a checklist for knowing when we have successfully replicated the old behavior after our refactor.

Conclusion

Avoid testing implementation details and instead test your components from a user's perspective. That makes it easy to

  • make sure your components work as intended
  • change the implementation without having to change your tests

As an added bonus when testing forms this way — imagine all the time saved by not having to manually fill in the form as it evolves :)