Testing in React page

Write unit tests to verify components are functioning as expected.

Overview

In this section, we will:

  • Introduce React Testing Library
  • Render and verify a component in a test
  • Use @testing-library/user-event to simulate user interactions

Objective 1: Write a test for rendering a component and verifying the DOM structure

How do we know when our code is working correctly? How do we know it’s still working correctly in the future after we make changes to it?

Testing helps by verifying that given certain inputs our code generates expected outputs. So far we’ve copied existing tests to prove that we’ve completed the exercise correctly, now let’s dive in and learn about how React testing is done.

The most basic test is to render a component and validate the DOM that is generated. That’s what we’ll do in this first section.

Introducing React Testing Library

React Testing Library is a set of tools that helps us write robust tests for our React applications. Unlike some other testing libraries that focus on the internal state and implementation details of components, React Testing Library emphasizes testing the behavior of components as experienced by the end-users. It works with the DOM rendered by React components, allowing tests to cover a wide range of user interactions.

React Testing Library’s north star is:

The more your tests resemble the way your software is used, the more confidence they can give you.

React Testing Library encourages tests that focus on how the user interacts with the application, rather than the implementation details of the components.

Rendering and verifying a component in a test

OK, you're convinced that React Testing Library is a good idea, how do you use it to create tests for your React components?

Let's take a look at an example: say we want to test a component we created named EmailInputField that renders a form field. We expect the component will generate HTML like this:

<div class="form-field">
  <label htmlFor="inputId">Email:</label>
  <input id="inputId" type="email" value="test@example.com" />
</div>

We want to test EmailInputField to make sure it generates the DOM we expect. If you're already familiar with testing frontend JavaScript code, the following pattern will probably be recognizable: each test consists of arguments to the it function provided by Vite. The first argument is a short description of what the test is verifying. The convention is that the description string takes "it" as a prefix and proceeds from there, e.g. "[it] renders the correct label and value."

The second argument to it is a callback function that is called to run the test. Inside the callback, invoke the React Testing Library function render and pass it a single argument, the JSX for your component including props. After render completes, use screen to query the DOM and make assertions about the result.

it("renders the correct label and value", () => {
  render(<EmailInputField label="Email" value="test@example.com" />)
  const label = screen.getByText("Email:")
  expect(label).toBeInTheDocument()
})

In the test above, we validate that the label is correct. We use the getByText function to select a single element whose textContent matches the string, "Email:". If you look closely you can see that the <label> content in the HTML has a ":" (colon) at the end, but the label prop does not, we can conclude that EmailInputField appends the colon — but the purpose of the test isn't how EmailInputField works, it's the DOM output that it returns. After we get an element, we then use expect and toBeInTheDocument to verify the element was rendered properly. Our test passes because the generated DOM is what we expect the user will perceive.

We also want to validate the <input> element; let's update the test:

it("renders the correct label and value", () => {
  render(<EmailInputField label="Email" value="test@example.com" />)
  const label = screen.getByText("Email:")
  expect(label).toBeInTheDocument()

  // Validate the input value.
  const input = screen.getByDisplayValue("test@example.com")
  expect(input).toBeInTheDocument()
})

We've used a different query to select the <input> element: getByDisplayValue. It returns an input element whose value matches the provided string. React Testing Library has a suite of "query" functions that select elements based on characteristics like: role, label text, placeholder text, text, display value, alt text, and title. Our test continues to pass because the input's value in the DOM matches what we expect.

Before we move on, let's consider the type prop — shouldn't we test to be sure it was applied properly as the input's type attribute? The answer is maybe. type="email" doesn't affect the appearance of the field in a browser, but it might affect how the user can enter input. For example, a mobile device might display a special on-screen keyboard. For now, we'll hold off on writing tests that check attribute values and see if there is another, more user-focused way to test this behavior.

Setup 1

✏️ Create src/components/FormSelect/FormSelect.tsx and update it to be:

import { useId } from "react"

const FormSelect: React.FC<{
  children: React.ReactNode
  label: string
  onChange: (data: string) => void
  value: string
}> = ({ children, label, onChange, value }) => {
  const id = useId()

  return (
    <div className="form-group">
      <label htmlFor={id} className="control-label">
        {label}:
      </label>

      <select
        className="form-control"
        id={id}
        onChange={(event) => onChange(event.target.value)}
        value={value}
      >
        {children}
      </select>
    </div>
  )
}

export default FormSelect

✏️ Create src/components/FormSelect/index.ts and update it to be:

export { default } from "./FormSelect"

✏️ Update src/pages/RestaurantList/RestaurantList.tsx to be:

import { useState } from "react"
import {
  useCities,
  useRestaurants,
  useStates,
} from "../../services/restaurant/hooks"
import ListItem from "./ListItem"
import FormSelect from "../../components/FormSelect"

const RestaurantList: React.FC = () => {
  const [state, setState] = useState("")
  const [city, setCity] = useState("")

  const statesResponse = useStates()

  const citiesResponse = useCities(state)

  const restaurantsResponse = useRestaurants(state, city)

  const updateState = (stateShortCode: string) => {
    setState(stateShortCode)
    setCity("")
  }

  const updateCity = (cityName: string) => {
    setCity(cityName)
  }

  return (
    <>
      <div className="restaurants">
        <h2 className="page-header">Restaurants</h2>

        <form className="form">
          <FormSelect label="State" onChange={updateState} value={state}>
            <option key="choose_state" value="">
              {statesResponse.isPending
                ? "Loading states…"
                : statesResponse.error
                  ? statesResponse.error.message
                  : "Choose a state"}
            </option>
            {statesResponse.data?.map(({ short, name }) => (
              <option key={short} value={short}>
                {name}
              </option>
            ))}
          </FormSelect>

          <FormSelect label="City" onChange={updateCity} value={city}>
            <option key="choose_city" value="">
              {state
                ? citiesResponse.isPending
                  ? "Loading cities…"
                  : citiesResponse.error
                    ? citiesResponse.error.message
                    : "Choose a city"
                : "Choose a state before selecting a city"}
            </option>
            {state &&
              citiesResponse.data?.map(({ name }) => (
                <option key={name} value={name}>
                  {name}
                </option>
              ))}
          </FormSelect>
        </form>

        {city && restaurantsResponse.error && (
          <p aria-live="polite" className="restaurant">
            Error loading restaurants: {restaurantsResponse.error.message}
          </p>
        )}

        {city && restaurantsResponse.isPending && (
          <p aria-live="polite" className="restaurant loading">
            Loading restaurants…
          </p>
        )}

        {city &&
          restaurantsResponse.data &&
          (restaurantsResponse.data.length === 0
            ? !restaurantsResponse.isPending && (
                <p aria-live="polite">No restaurants found.</p>
              )
            : restaurantsResponse.data.map(
                ({ _id, slug, name, address, images }) => (
                  <ListItem
                    key={_id}
                    address={address}
                    name={name}
                    slug={slug}
                    thumbnail={images.thumbnail}
                  />
                ),
              ))}
      </div>
    </>
  )
}

export default RestaurantList

Verify 1

✏️ Create src/components/FormSelect/FormSelect.test.tsx and update it to be:

import "@testing-library/jest-dom"
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import FormSelect from "./FormSelect"

describe("FormSelect Component", () => {
  it("renders correctly with given props", () => {
    throw new Error("Test needs to be implemented")
  })
})

Exercise 1

  • Call render() with the JSX for the component you want to test.
  • Call screen.getByText() to get the <label> element.
  • Use .toBeInTheDocument() to check whether an element is in the DOM.
  • Call screen.getByRole() to get other elements.

Hint: Here’s the JSX you can use for the component:

const content = (
  <FormSelect label="Test Label" onChange={() => {}} value="">
    <option value="option1">Option 1</option>
    <option value="option2">Option 2</option>
  </FormSelect>
)

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 1

Click to see the solution

✏️ Update src/components/FormSelect/FormSelect.test.tsx to be:

import "@testing-library/jest-dom"
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import FormSelect from "./FormSelect"

describe("FormSelect Component", () => {
  it("renders correctly with given props", () => {
    render(
      <FormSelect label="Test Label" onChange={() => {}} value="">
        <option value="option1">Option 1</option>
        <option value="option2">Option 2</option>
      </FormSelect>,
    )

    // Check if label is rendered correctly
    expect(screen.getByText(/test label/i)).toBeInTheDocument()

    // Check if select options are rendered
    expect(screen.getByRole("option", { name: "Option 1" })).toBeInTheDocument()
    expect(screen.getByRole("option", { name: "Option 2" })).toBeInTheDocument()
  })
})

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Objective 2: Write a test for handling user interactions

Most components can respond to events raised by user interaction, like clicking a button. How can we test code that responds to these interactions? We use another React Testing Library package named user-event.

user-event allows your tests to interact with your component in a browser-like manner, where a single user action can raise multiple events. For example, when a button is clicked, it can emit a focus event and then a click event. user-event also has some helpful functionality to prevent interactions that are not possible in a browser environment, such as not firing a click event from an element that’s hidden.

All of the methods provided by user-event return a Promise, so we want to prefix the test callback function with async and use await when a user-event method is called.

Using @testing-library/user-event to simulate user interactions

Referring back to the HTML output by the EmailInputField component:

<div class="form-field">
  <label htmlFor="inputId">Email:</label>
  <input id="inputId" type="email" value="test@example.com" />
</div>

We want to write a test to verify that what the user types is displayed as the <input>'s value. The user-event library includes a method named keyboard that we can use to send a sequence of key events to the <input> element. The keyboard method provides a good simulation of what happens when a user is typing. Before we use keyboard in a test, we need to initialize user events with the setup method. Let's look at the test:

import userEvent from "@testing-library/user-event"

it("captures email input", async () => {
  const user = userEvent.setup()

  render(<EmailInputField label="Email" value="" />)

  const input = screen.getByLabelText("Email:")
  expect(input).toBeInTheDocument()
  expect(input).toHaveDisplayValue("") // Verify the beginning state: no value is set.

  await user.click(input)
  await user.keyboard("test@example.com")
  expect(input).toHaveDisplayValue("test@example.com")
})

There are some notable differences compared to the previous test:

  • The userEvent module is imported.
  • A user is created with the userEvent.setup() function.
  • The callback function argument of it is prefaced with async.
  • We need to await both the click, and keyboard methods to let them complete.
  • The input element needs to have focus before we "type" input; rather than using the input element's focus method, we prefer the more performant user.click() method.

After keyboard completes, we can make assertions about the current state of the input, including the current value, which should have been updated with the value of the single keyboard argument.

There is one final point to consider: testing the type attribute of the <input> element. We'd prefer to follow React Testing Library's philosophy of testing the DOM generated by a component. To put this into practice, we need some knowledge about different <input> type values, their effects on the user experience, and why we might choose one over another. Let's review what MDN has to say about the "email" type value:

The input value is automatically validated to ensure that it's either empty or a properly-formatted email address (or list of addresses) before the form can be submitted. The :valid and :invalid CSS pseudo-classes are automatically applied as appropriate to visually denote whether the current value of the field is a valid email address or not.

This is a great reason for choosing the "email" type rather than the "text" type. The "email" type prevents the user from entering an incorrectly formatted email address. This also helps guide our thinking — rather than testing an attribute value, we can test the input's behavior. We'll add another test to ensure incorrect email formats are flagged as invalid.

import userEvent from "@testing-library/user-event"

it("flags an incorrectly formatted email address as invalid", async () => {
  const user = userEvent.setup()

  render(<EmailInputField label="Email" value="" />)

  const input = screen.getByLabelText("Email:")
  expect(input).toBeInTheDocument()

  await user.click(input)
  await user.keyboard("test")
  expect(input).toHaveDisplayValue("test")

  await user.tab()
  expect(input).not.toHaveFocus()
  expect(input).toBeInvalid()
})

Compared to the prior test, this one inputs a string that is not a valid email address and has three additional lines at the end that move focus away from the <input> element, triggering the built-in validation to be executed, and then asserts that the <input> has been marked as invalid.

Verify 2

✏️ Update src/components/FormSelect/FormSelect.test.tsx to be:

import "@testing-library/jest-dom"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import FormSelect from "./FormSelect"

describe("FormSelect Component", () => {
  it("renders correctly with given props", () => {
    render(
      <FormSelect label="Test Label" onChange={() => {}} value="">
        <option value="option1">Option 1</option>
        <option value="option2">Option 2</option>
      </FormSelect>,
    )

    // Check if label is rendered correctly
    expect(screen.getByText(/test label/i)).toBeInTheDocument()

    // Check if select options are rendered
    expect(screen.getByRole("option", { name: "Option 1" })).toBeInTheDocument()
    expect(screen.getByRole("option", { name: "Option 2" })).toBeInTheDocument()
  })

  it("calls onChange with the correct value when selection changes", async () => {
    throw new Error("Test needs to be implemented")
  })
})

Exercise 2

  • Call vi.fn() to create a function you can observe.
  • Call userEvent.setup() to set up the object for simulating user interactions.
  • Call await user.selectOptions() to simulate selecting an option in a <select> element.
  • Use .toHaveBeenCalledWith() to confirm whether the onChange handler is called.

Having issues with your local setup? You can use either StackBlitz or CodeSandbox to do this exercise in an online code editor.

Solution 2

Click to see the solution

✏️ Update src/components/FormSelect/FormSelect.test.tsx to be:

import "@testing-library/jest-dom"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, expect, it, vi } from "vitest"
import FormSelect from "./FormSelect"

describe("FormSelect Component", () => {
  it("renders correctly with given props", () => {
    render(
      <FormSelect label="Test Label" onChange={() => {}} value="">
        <option value="option1">Option 1</option>
        <option value="option2">Option 2</option>
      </FormSelect>,
    )

    // Check if label is rendered correctly
    expect(screen.getByText(/test label/i)).toBeInTheDocument()

    // Check if select options are rendered
    expect(screen.getByRole("option", { name: "Option 1" })).toBeInTheDocument()
    expect(screen.getByRole("option", { name: "Option 2" })).toBeInTheDocument()
  })

  it("calls onChange with the correct value when selection changes", async () => {
    const handleChange = vi.fn()
    const user = userEvent.setup()

    render(
      <FormSelect label="Test Label" onChange={handleChange} value="option1">
        <option value="option1">Option 1</option>
        <option value="option2">Option 2</option>
      </FormSelect>,
    )

    // Simulate user changing the selection
    await user.selectOptions(screen.getByRole("combobox"), "option2")

    // Check if onChange was called with the right value
    expect(handleChange).toHaveBeenCalledWith("option2")
  })
})

Having issues with your local setup? See the solution in StackBlitz or CodeSandbox.

Next steps

Congrats, you’ve completed this Bitovi Academy training!