Testing in React page

Write unit tests to verify components are functioning es expected.

Overview

How do you know your code is working correctly? How will you know it's still working correctly in the future after you make changes to it? Unit 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.

Objective 1: Render a component and verify the DOM structure

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.

Key Concepts

  • TODO

Introducing React testing-library

Most React unit testing is done with the React Testing Library. The goal of this library is to test the DOM components that are generated by React rather than the React code directly. As stated in their guiding principles:

  1. If it relates to rendering components, then it should deal with DOM nodes rather than component instances...
  2. It should be generally useful for testing the application components in the way the user would use it.

What do we mean by "unit tests?" In the context of this lesson a unit test will involve working with a single component: passing it props and rendering it; and validating the generated DOM.

Rendering and verifying a component in a test

Let's take a look at some code that we added in Handling User Inputs and Forms.

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 FormTextField from './FormTextField';

describe('FormTextField component', () => {
  const mockOnChange = vi.fn();
  
  it('renders with correct label and type', () => {
    render(<FormTextField label="Test Label" type="text" value="" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Test Label:/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/Test Label:/i)).toHaveAttribute('type', 'text');
  });

  it('renders with the correct value', () => {
    render(<FormTextField label="Test Label" type="text" value="Test Value" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Test Label:/i)).toHaveValue('Test Value');
  });

  it('calls onChange prop when input changes', async () => {
    render(<FormTextField label="Test Label" type="text" value="" onChange={mockOnChange} />);
    
    await userEvent.type(screen.getByLabelText(/Test Label:/i), 'New');
    
    expect(mockOnChange).toHaveBeenCalledTimes(3);
    expect(mockOnChange).toHaveBeenCalledWith('N');
    expect(mockOnChange).toHaveBeenCalledWith('e');
    expect(mockOnChange).toHaveBeenCalledWith('w');
  });

  it('respects different input types', () => {
    render(<FormTextField label="Email" type="email" value="" onChange={mockOnChange} />);
    
    expect(screen.getByLabelText(/Email:/i)).toHaveAttribute('type', 'email');
  });
});

Each test consists of arguments to the Vite provided test function, it. The first argument is a short description of what the test is examining. The convention is that the description string takes "it" as a prefix and proceeds from there, e.g. "(it) renders with correct label and type."

The second argument is a callback function that runs the test. In the example above the first line of the callback invokes render and passes it a single argument, JSX for the FormTextField component including props. After render completes the screen (a test DOM) will contain the elements created by our React code.

render(<FormTextField label="Test Label" type="text" value="" onChange={mockOnChange} />);

Now it's time to see if the DOM matches what we expected to create. The React Testing Library provides the screen object that allows us to select elements from the DOM. In the current scenario we'll use getByLabelText which returns the <input> associated by id with a single <label> that has the text "Test Label". You may have intuited that getByLabelText accepts a string, but it also accepts a regex, in this case one that matches any part of the label text and ignores case.

expect(screen.getByLabelText(/Test Label:/i)).toBeInTheDocument();

Once screen.getByLabelText returns, its result can be passed to Vite's expect function to see if the result matches what we intended. We use toBeInTheDocument (expect was augmented with this method by importing @testing-library/jest-dom) to verify that the element exists in the DOM. This satisfies the first part of the test description, "renders with correct label."

If a call to expect does not provide the expected result an error will be thrown ending the test.

The second expect will verify the second half of the description, "and type." expect is passed the <input> element that was rendered and it is examined to see if the value of its type attribute is set to "text."

Note that this test could also have been written to assign the result of getByLabelText to a const then passed that const to both of the expect invocations.

Setup

TODO

✏️ 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 = (newValue: string) => {
    setState(newValue)
    setCity("")
  }

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

  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

TODO

✏️ 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 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', () => {
    })

    it('calls onChange with the right value when selection changes', async () => {
    })
})

Exercise

TODO

Solution

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 right value when selection changes', async () => {
        const handleChange = vi.fn()
        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 userEvent.selectOptions(
            screen.getByRole('combobox'),
            'option2'
        )

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

Objective 2: Simulate a user-generated event

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

user-event allows you to interact with your component similarly to a user some of its methods may raise more than one event to do so, for example emitting a focus event then a click event. It also has some helpful features such as not firing a click event on an element that's hidden.

Key Concepts

  • The user-event library simulates user interactions - not events
  • Tests must be marked as async to work with user-event

Concept 1

We may want to update test code to do the following: "We recommend invoking userEvent.setup() before the component is rendered."

Consider the following example:

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

it("toggles pickup options when clicked", async () => {
  const user = userEvent.setup();
  render(<PickupOptions />);

  expect(screen.queryByText("In-store Options")).not.toBeInTheDocument();

  await user.click(screen.getByText("Store"));
  expect(screen.getByText("In-store Options")).toBeInTheDocument();
});

One difference between this example and the previous one is that the callback function is now preceded by async because some of of the test code will await an action. Failing to set a test callback function as async or use await with user-event methods is a common reason why tests do not function properly or provide the results developers expect.

Before the component is rendered the userEvent module has its setup function invoked to get a user that can interact with the component. The user has a variety of functions to simulate common user actions such as clicking or typing.

After calling render we verify that the component has initially rendered the proper DOM structure. Since the element is not expected the queryByText method is appropriate to use here, this method will return null if the element doesn't exist. We use expect with the not property to confirm that the DOM does not contain the element. In most cases prefer using the testing library's API methods rather than, for example, asserting on whether or not the result of queryByText is null.

Now that we know the proper initial DOM was rendered let's use user.click() to click on an element. We pass the element to be clicked to the click function as its argument. Once the call to click resolves the DOM can be queried again to see the effect. Assuming the component code made the right changes the call to getByText("In-store Options") should return the element so it exists in the document.

Setup

TODO

Verify

TODO

Exercise

TODO