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:
- If it relates to rendering components, then it should deal with DOM nodes rather than component instances...
- 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