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 theuserEvent.setup()
function. - The callback function argument of
it
is prefaced withasync
. - We need to
await
both theclick
, andkeyboard
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 performantuser.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 theonChange
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!