Add Vitest and React Testing Library to your Vite project. Find things by role, click like a user, wait for data, read a failing test, and avoid the common traps.
Every time you change an app, something else might break. You can click through the whole app by hand to check. Or you can write a test: code that uses your component and checks the result for you.
This part adds tests to the Vite project from Part 10. We’ll install the tools and write a first test. Then we’ll read a test that fails. We’ll find things on the page the way a user does, and click and type like a user. We’ll also test a component that loads data from a server, as in Part 13, and a custom hook from Part 16.
The playground on this page can’t run tests. So the components below run here, but the tests run in a real project. We ran every test in this part, and copied what it printed.
Try this first
Before we use any tools, here is a test written by hand. It runs in the page. Read it, but don’t press Run yet.
import { useRef, useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
}
export default function App() {
const boxRef = useRef<HTMLDivElement>(null)
const [result, setResult] = useState('not run yet')
function runTest() {
// 1. Arrange: find the counter's button.
const button = boxRef.current!.querySelector('button')!
// 2. Act: click it.
button.click()
// 3. Assert: check what the button says now.
const text = button.textContent
setResult(text === 'Clicked 1 times' ? 'PASS' : 'FAIL, it says ' + text)
}
return (
<div>
<div ref={boxRef}>
<Counter />
</div>
<button onClick={runTest}>Run the test</button>
<p>Test result: {result}</p>
</div>
)
}
runTest finds the counter’s button, clicks it, and reads its text. If the text is “Clicked 1 times”, the test passes.
Make a guess. You press Run, then “Run the test”. Will it say PASS or FAIL?
Now press Run, then “Run the test”.
It says “FAIL, it says Clicked 0 times”. But look at the counter. It does say “Clicked 1 times”! The click worked. The test just looked too soon.
React doesn’t change the page in the middle of your code. A click sets the state, and React updates the page a moment later. Our test read the text before that moment.
Here is the same test, with one change. It waits a moment before it checks.
import { useRef, useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
}
export default function App() {
const boxRef = useRef<HTMLDivElement>(null)
const [result, setResult] = useState('not run yet')
async function runTest() {
const button = boxRef.current!.querySelector('button')!
button.click()
// Wait a moment, so React can update the page.
await new Promise(resolve => setTimeout(resolve, 0))
const text = button.textContent
setResult(text === 'Clicked 1 times' ? 'PASS' : 'FAIL, it says ' + text)
}
return (
<div>
<div ref={boxRef}>
<Counter />
</div>
<button onClick={runTest}>Run the test</button>
<p>Test result: {result}</p>
</div>
)
}
Press Run, then “Run the test”. Now it says PASS. Press “Run the test” a second time, and it says “FAIL, it says Clicked 2 times”. The test expects a fresh counter, so it only works once.
We ran both versions in jsdom, a pretend browser for Node.js, with React 19.3. We ran them with React’s test mode on, and with it off, as in a real browser. Both times, the first version said FAIL and the second said PASS.
Waiting, finding things, clicking and starting fresh each time: real test tools do all of this for you. That’s what the rest of this part is about.
What a test is
Our hand-made test had three steps. Almost every test has the same three:
- Arrange: put the component on a page, and find what you need.
- Act: do what a user does, like a click.
- Assert: check the result. To assert means to say firmly that something is true. If it isn’t true, the test fails.
React has a test helper called act, which we’ll meet later. React’s docs say: “The name act comes from the Arrange-Act-Assert pattern.”
Why write tests at all? A test checks the same thing the same way, every time. It doesn’t get tired or forget a step. When you change one component, you run all the tests. If one fails, you know at once what you broke.
The tools
Six packages work together. Here is what each one does:
- Vitest is a test runner. It finds your test files, runs them, and reports which tests pass and which fail. It is built on Vite, and it reads Vite’s settings file.
- jsdom is the pretend browser from “Try this first”. Your tests run in Node.js, which has no page. jsdom gives them a
document, elements and events. - React Testing Library (
@testing-library/react) puts your component on the jsdom page and helps you find things there. It is built on DOM Testing Library (@testing-library/dom), which does the finding. - user-event (
@testing-library/user-event) clicks and types like a real user. - jest-dom (
@testing-library/jest-dom) adds checks liketoHaveTextContentandtoBeInTheDocument.
Add the tools to your Part 10 project
Open a terminal in the my-app folder from Part 10. Then install all six as devDependencies, the tools you only need while you work:
npm install -D vitest @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom jsdom
-D is short for --save-dev. Ours printed:
added 79 packages, and audited 107 packages in 4s
22 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
The numbers and the time can be different for you. These are the versions we got: vitest@5.0.3, @testing-library/react@16.3.3, @testing-library/dom@10.4.2, @testing-library/user-event@14.6.7, @testing-library/jest-dom@7.0.1 and jsdom@30.1.2.
Tell Vitest what it needs
Vitest reads vite.config.ts, the file from Part 10. Add a test part to it, and one line at the top:
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
},
})
Line by line:
/// <reference types="vitest/config" />tells TypeScript about the newtestpart. Vitest’s docs give this line for a project that already has a Vite settings file. Without it,npm run buildstops with a type error. We checked: it said'test' does not exist in type 'UserConfigExport'.environment: 'jsdom'runs the tests in jsdom. Vitest’s docs say the default is'node', with no page at all.globals: truemakes Vitest’s helpers, likeafterEachandbeforeAll, global. Global means every file can use them without animport. React Testing Library looks for them, and uses them for two jobs. It cleans up after each test, and it turns on React’sactwarnings. We’ll see both below, in Starting fresh for every test and Theact(...)warning.setupFilesnames a file that runs before each test file.
Now make that file, src/setupTests.ts, with one line:
import '@testing-library/jest-dom/vitest'
This adds jest-dom’s checks to Vitest. The jest-dom docs give this exact line for Vitest.
The test files in this part still import test and expect from vitest. Keep those imports. The project’s TypeScript settings don’t know about Vitest’s globals. We left the imports out of one test file. The test still passed, but npm run build failed: Cannot find name 'test'.
Last, add a test command to the scripts in package.json:
"test": "vitest"
Your first test
Here is the counter from “Try this first”, in its own file, src/Counter.tsx. You can run it here.
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
Its test goes in a new file next to it, src/Counter.test.tsx. Vitest runs every file whose name ends in .test.tsx, .test.ts, and a few more like them.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import Counter from './Counter'
test('counts each click', async () => {
// Arrange
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: 'Clicked 0 times' })
// Act
await user.click(button)
// Assert
expect(button).toHaveTextContent('Clicked 1 times')
})
Each part, in order:
test('counts each click', ...)makes one test. The text is its name. You’ll see it in the report.- The function is
async, because clicking takes time.awaitwaits for each click to finish. Part 13 explainedasyncandawait. userEvent.setup()makes a pretend user. Its docs ask you to call it beforerender.render(<Counter />)puts the component on the jsdom page.screenis the whole page.screen.getByRole('button', { name: 'Clicked 0 times' })finds the button whose name is “Clicked 0 times”. A button’s name is its text, unless it has anaria-label(Part 46).await user.click(button)clicks it, like a user.expect(button).toHaveTextContent('Clicked 1 times')is the assert step. If the button says something else, the test fails.
Now run it:
npm test
Vitest runs the test and prints a report. Then it keeps running in watch mode: it waits for you to change a file. Press q to quit. This is the end of what we saw:
✓ src/Counter.test.tsx (1 test) 368ms
✓ counts each click 361ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 17:05:34
Duration 1.63s (environment 50%, tests 25%, import 11%, setup 8%, transform 6%, worker 1%)
PASS Waiting for file changes...
press h to show help, press q to quit
The ✓ means it passed. Your times will be different. The second line names the test. Vitest’s docs say a test is “considered slow” after 300 ms, and Vitest lists slow tests by name. In our runs, a test that took less than that showed only its file’s line. To run the tests once, without watching, use npx vitest run.
Here is the same test as a picture. Step through it.
One test, step by step: render, find by role, click, check. Press play, or step through it.
renderputsCounteron the jsdom page. The button says “Clicked 0 times”.getByRolefinds the button by its role,button, and its name.user.clickclicks it. React updates the page, andawaitwaits for that.expectreads the page. The button says “Clicked 1 times”, so the test passes.
When a test fails
A test is only useful if it fails when something breaks. Let’s break Counter. Say someone changes count + 1 to count - 1 by mistake. We ran npx vitest run again. This is what it printed:
❯ src/Counter.test.tsx (1 test | 1 failed) 496ms
× counts each click 487ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/Counter.test.tsx > counts each click
Error: expect(element).toHaveTextContent()
Expected element to have text content:
Clicked 1 times
Received:
Clicked -1 times
❯ src/Counter.test.tsx:16:18
14|
15| // Assert
16| expect(button).toHaveTextContent('Clicked 1 times')
| ^
17| })
18|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Test Files 1 failed (1)
Tests 1 failed (1)
Read it from the top:
×andFAILname the file and the test:src/Counter.test.tsx > counts each click.- The next line names the check that failed:
toHaveTextContent. - Expected is what the test wanted. Received is what the page showed. Here the counter went down, not up.
src/Counter.test.tsx:16:18is the place: line 16, character 18. The lines under it show your code, with^under the check.- The last lines count the tests: 1 failed.
Read “Expected” and “Received” first. They usually tell you what went wrong. Then decide: is the component wrong, or the test? Here the component is wrong. Change it back, and the test passes again.
Test what the user sees
The test above never looked at count, the state inside Counter. It looked at the button’s text, which is what a user sees. That’s on purpose. Testing Library’s main idea is in its docs: “The more your tests resemble the way your software is used, the more confidence they can give you.”
To resemble something means to be like it. So a good test uses the app the way a person does. A user can’t see state, a function’s name, or a CSS class. Say a test checks one of these, and someone changes its name. The app still works, but the test fails. Or the state is right, but the button shows the wrong text. Then the user sees a bug, and the test passes. A test that does what the user does avoids both problems.
Finding things on the page
The functions that find things are called queries. To query means to ask. Each query asks the page for elements that match.
Testing Library’s docs list them in order of priority, the order to try them in:
getByRole: finds an element by its role and its name. A role says what kind of thing it is, likebutton,textboxorheading. The docs say it “should be your top preference for just about everything”. Part 46 explained roles.getByLabelText: finds a form box by its label. The docs say it’s “really good for form fields”. Part 48 explained labels.getByPlaceholderText,getByTextandgetByDisplayValue: by placeholder, by text, or by the value in a box.getByTextis the one for text that isn’t a control, like a<p>.getByAltTextandgetByTitle: by an image’salttext or atitle.getByTestId: finds an element by adata-testidattribute. The docs say users “cannot see (or hear) these”. Use it only when nothing else works.
Why is role first? A screen reader finds things by role and name too. So if getByRole can’t find your button, many users can’t find it either. The docs say the same thing. If you can’t find an element by its role, your page may not work for everyone. Part 50 tests accessibility in more depth.
Here is a small sign-up form, src/SignUp.tsx. Type an email and press “Sign up”.
import { useState } from 'react'
export default function SignUp() {
const [email, setEmail] = useState('')
const [done, setDone] = useState(false)
if (done) {
return <p>Thanks! We will write to {email}.</p>
}
return (
<form
onSubmit={e => {
e.preventDefault()
setDone(true)
}}
>
<label>
Email <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
</label>
<button>Sign up</button>
</form>
)
}
Its test, src/SignUp.test.tsx, uses three kinds of query:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import SignUp from './SignUp'
test('thanks the user after they sign up', async () => {
const user = userEvent.setup()
render(<SignUp />)
await user.type(screen.getByLabelText('Email'), 'ana@example.com')
await user.click(screen.getByRole('button', { name: 'Sign up' }))
expect(screen.getByText('Thanks! We will write to ana@example.com.')).toBeInTheDocument()
})
It passed. user.type types into the box one key at a time. toBeInTheDocument checks that the element is on the page.
getByRole('textbox', { name: 'Email' }) would find the same box. An <input type="email"> has the role textbox. We tried it, and the test still passed.
When a query finds nothing
A query that finds nothing makes the test fail, and it prints a lot of help. We changed the counter’s text to “Clicks: {count}” and ran the first test again. Here is the first part of the error:
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Clicked 0 times"
Here are the accessible roles:
button:
Name "Clicks: 0":
<button />
--------------------------------------------------
It lists every role on the page, with each name. So you can see that the button is there, but its name changed. After this list, it prints the whole page as HTML.
getBy, queryBy and findBy
Each query comes in three kinds. They differ in what happens when nothing matches:
| Kind | Nothing matches | One matches | Waits? |
|---|---|---|---|
getBy... |
throws an error | the element | no |
queryBy... |
null |
the element | no |
findBy... |
throws an error, after 1 second | the element | yes |
All three throw an error if more than one element matches. findBy throws only after it has waited: in our test, after 1001 ms. For several elements, use getAllBy..., queryAllBy... or findAllBy.... They give back an array. Part 39 used queryAllByRole('tab').
We tried each one in our project:
screen.getByText('Saved!'), with no “Saved!” on the page, threwUnable to find an element with the text: Saved!.screen.queryByText('Saved!')gave backnull, and the test went on.await screen.findByText('Saved!')kept looking. It threw the same error after 1003 ms.screen.getByRole('button'), with two counters on the page, threwFound multiple elements with the role "button".
So use each kind for its own job:
getByfor things that should be there now. That’s most of the time.queryByto check that something is not there:expect(screen.queryByText('Saved!')).not.toBeInTheDocument().findByfor things that will appear soon, like data from a server. Use it withawait.
Clicking and typing: user-event or fireEvent
Testing Library also has fireEvent. It sends one event to an element. But user-event acts like a person. A person’s click is more than one event.
We put a logging handler for twelve events on one button. Then we clicked it both ways:
| How | Events the button got, in order |
|---|---|
await user.click(button) |
pointerover, pointerenter, mouseover, mouseenter, pointermove, mousemove, pointerdown, mousedown, focus, pointerup, mouseup, click |
fireEvent.click(button) |
click |
The user-event docs say it acts out a full interaction, which can be many events. A real mouse click also moves the focus to the button. So after user.click, the button had the focus. After fireEvent.click, it didn’t. We checked with expect(button).toHaveFocus(). It passed after user.click, and failed after fireEvent.click.
Typing shows the same gap. We typed “Ana” into a box whose onChange logs the value:
| How | onChange saw |
|---|---|
await user.type(box, 'Ana') |
“A”, then “An”, then “Ana” |
fireEvent.change(box, { target: { value: 'Ana' } }) |
“Ana”, once |
A component that does something on each key, like a search box, needs user.type. Use fireEvent only for an event that user-event can’t make.
Two rules come with user-event:
- Call
userEvent.setup()at the start of the test, beforerender. - Put
awaitbefore everyuser.clickanduser.type. Common mistakes shows what happens without it.
Waiting for data: findBy and waitFor
Now a component that loads a user from a server, like the ones in Part 13. This one uses the real fetch, so it can’t run here. Put it in src/UserCard.tsx:
import { useEffect, useState } from 'react'
type User = { id: number; name: string }
export function UserCard({ id }: { id: number }) {
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let ignore = false
async function load() {
try {
const res = await fetch(`https://example.com/api/users/${id}`)
if (!res.ok) {
throw new Error('The server answered with status ' + res.status)
}
const data: User = await res.json()
if (!ignore) setUser(data)
} catch (e) {
if (!ignore) setError((e as Error).message)
}
}
load()
return () => {
ignore = true
}
}, [id])
if (error) return <p role="alert">{error}</p>
if (user === null) return <p>Loading...</p>
return <h2>Hello, {user.name}!</h2>
}
A test must not use the real network. A server can be slow, or down, or give back different data each day. Then a test passes one day and fails the next for no reason in your code. So the test replaces fetch with a fake one. Vitest has two tools for that:
vi.fn(...)makes a mock function: a fake function that remembers every call. You can ask it later how many times it was called, and with what.vi.stubGlobal('fetch', ...)puts something in place of a global, herefetch.vi.unstubAllGlobals()puts the real one back.
Here is the test file, src/UserCard.test.tsx:
import { render, screen } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import { UserCard } from './UserCard'
afterEach(() => {
vi.unstubAllGlobals()
})
test('shows the user from the server', async () => {
const fakeFetch = vi.fn(async () => Response.json({ id: 1, name: 'Ana' }))
vi.stubGlobal('fetch', fakeFetch)
render(<UserCard id={1} />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'Hello, Ana!' })).toBeInTheDocument()
expect(fakeFetch).toHaveBeenCalledTimes(1)
expect(fakeFetch).toHaveBeenCalledWith('https://example.com/api/users/1')
})
test('shows an error when the server fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('Oops', { status: 500 })))
render(<UserCard id={1} />)
expect(await screen.findByRole('alert')).toHaveTextContent('The server answered with status 500')
})
Both tests passed. Look at the steps of the first one:
fakeFetchanswers at once with a user.Response.json(...)makes a response with JSON in its body, like a real server’s.- Just after
render, the page says “Loading…”. The Effect has asked for the user, but the answer hasn’t arrived yet. await screen.findByRole('heading', ...)waits until the heading appears.- The mock function remembers its calls. It was called once, with the right address.
The second test gives back status 500. So res.ok is false, and the component shows the error. A test like this is the easy way to check your error screen. A real server rarely fails when you want it to.
waitFor
findBy waits for an element. To wait for anything else, use waitFor. It runs your function again and again until the function stops throwing:
import { render, screen, waitFor } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { UserCard } from './UserCard'
test('stops loading', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ id: 1, name: 'Ana' })))
render(<UserCard id={1} />)
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
})
})
It passed. In fact, findBy is getBy inside waitFor. The docs say so: findBy methods “are a combination of getBy* queries and waitFor“.
An everyday example
You wait for a friend at a bus stop. You don’t watch the road every second. You look up every minute. And if the friend hasn’t come in an hour, you go home and call them.
findBy and waitFor wait the same way. They check, wait a little, and check again. After a time limit, they give up, and the test fails.
The exact version
The docs give the numbers. waitFor runs your function at once, and then every 50 ms. It also runs it each time the page changes. After 1000 ms, it gives up. You can change both numbers with the interval and timeout options. We measured our failing findByText: it gave up after 1003 ms, with the same error that getByText throws.
Testing a custom hook
A hook can’t run on its own. It must run inside a component. So to test useCounter from Part 16, give it a small test component. Put the hook in src/useCounter.ts:
import { useState } from 'react'
export function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount(c => c + 1)
}
return { count, increase }
}
Then test it through a button, as a user would use it:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { useCounter } from './useCounter'
function TestCounter() {
const { count, increase } = useCounter()
return <button onClick={increase}>Count: {count}</button>
}
test('useCounter counts up', async () => {
const user = userEvent.setup()
render(<TestCounter />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveTextContent('Count: 2')
})
It passed. Part 16 mentioned the other way, renderHook. It makes the test component for you, and gives you the hook’s latest return value in result.current:
import { act, renderHook } from '@testing-library/react'
import { expect, test } from 'vitest'
import { useCounter } from './useCounter'
test('useCounter counts up, with renderHook', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increase()
})
expect(result.current.count).toBe(1)
})
It passed too. Notice act around the call. We took it away, and the test failed: count was still 0. React also printed a warning. The next section explains it.
Which way is better? The docs say renderHook is “mostly interesting for libraries publishing hooks”. For your own app, they say “You should prefer render“. A test component looks like real use, so it is easier to read.
Testing a reducer, and a component inside a provider
Part 15 said a reducer can be tested on its own. React’s docs say a reducer “doesn’t depend on your component”, so you “can export and test it separately”. Here is a small cart reducer, with its context and hook, in src/cart.ts:
import { createContext, useContext, type Dispatch } from 'react'
export type Action = { type: 'add'; item: string } | { type: 'clear' }
export function cartReducer(cart: string[], action: Action): string[] {
switch (action.type) {
case 'add':
return [...cart, action.item]
case 'clear':
return []
}
}
type CartValue = { cart: string[]; dispatch: Dispatch<Action> }
export const CartContext = createContext<CartValue | null>(null)
export function useCart() {
const value = useContext(CartContext)
if (value === null) {
throw new Error('useCart must be used inside <CartProvider>')
}
return value
}
A reducer is a plain function. So its test needs no render at all. This is src/cart.test.ts:
import { expect, test } from 'vitest'
import { cartReducer } from './cart'
test('add puts the item at the end', () => {
expect(cartReducer(['apple'], { type: 'add', item: 'pear' })).toEqual(['apple', 'pear'])
})
It passed. toEqual checks that two arrays hold the same items.
A component that reads the cart needs a provider around it, as in Part 24. Here are the provider, src/CartProvider.tsx, and a button that uses the cart, src/CartBadge.tsx:
import { useReducer, type ReactNode } from 'react'
import { CartContext, cartReducer } from './cart'
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, dispatch] = useReducer(cartReducer, [])
return <CartContext value={{ cart, dispatch }}>{children}</CartContext>
}
import { useCart } from './cart'
export function CartBadge() {
const { cart, dispatch } = useCart()
return <button onClick={() => dispatch({ type: 'add', item: 'apple' })}>Cart: {cart.length}</button>
}
The test gives render a wrapper. React Testing Library’s docs say it is “most useful for creating reusable custom render functions for common data providers”. This is src/CartBadge.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { CartBadge } from './CartBadge'
import { CartProvider } from './CartProvider'
test('counts what you add', async () => {
const user = userEvent.setup()
render(<CartBadge />, { wrapper: CartProvider })
await user.click(screen.getByRole('button', { name: 'Cart: 0' }))
expect(screen.getByRole('button')).toHaveTextContent('Cart: 1')
})
It passed. render(<CartProvider><CartBadge /></CartProvider>) works too. We tried it, and it also passed. Without any provider, the test failed with our own error: useCart must be used inside <CartProvider>.
The act(...) warning
In “Try this first”, we had to wait for React to update the page. act is React’s tool for that. Code inside act can change state. When act ends, React has put every change on the page. Then the test can check.
You rarely write act yourself. Testing Library’s render and the user-event calls use it for you. findBy and waitFor work another way. While they wait, Testing Library turns React’s test flag off, so updates that land during the wait don’t warn. The flag is globalThis.IS_REACT_ACT_ENVIRONMENT. We logged it: true in a click handler during await user.click, and false inside a waitFor function. Our tests above printed no warning.
But a state change can still happen outside act. Then React prints this warning. We got it from this test, which waits with its own timer:
import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { UserCard } from './UserCard'
test('waits by hand', async () => {
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ id: 1, name: 'Ana' })))
render(<UserCard id={1} />)
await new Promise(resolve => setTimeout(resolve, 50))
expect(screen.getByText('Hello, Ana!')).toBeInTheDocument()
})
An update to UserCard inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act
The test passed, but the warning is a sign of trouble. The answer from fetch set the state while nothing was watching for it. Here our timer was long enough. With a slower answer, the test would check too soon, as in “Try this first”.
The fix is to wait with Testing Library: expect(await screen.findByText('Hello, Ana!')).toBeInTheDocument(). We changed that line, and the warning went away.
The warning names the component whose state changed. With renderHook, it said An update to TestComponent, the test component that renderHook made.
One more thing. React prints these warnings only when globalThis.IS_REACT_ACT_ENVIRONMENT is true. Testing Library sets it in a global beforeAll step. Without globals: true, nothing set it in our tests, and React printed no act warnings at all.
Starting fresh for every test
Each test should start with an empty page. Otherwise one test can see what the last test left behind. Testing Library’s cleanup function takes every component off the page.
Its docs say cleanup “is called automatically” when the test runner makes afterEach global. Vitest doesn’t make its helpers global unless you ask. That’s why we set globals: true.
We tried it without globals: true. This file has two tests that both render a counter:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import Counter from './Counter'
test('starts at zero', () => {
render(<Counter />)
expect(screen.getByRole('button')).toHaveTextContent('Clicked 0 times')
})
test('counts one click', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button'))
expect(screen.getByRole('button')).toHaveTextContent('Clicked 1 times')
})
With globals: true, both passed. Without it, the first passed and the second failed:
TestingLibraryElementError: Found multiple elements with the role "button"
The first test’s counter was still on the page. If you’d rather not use globals, Testing Library’s docs give the other way. Call cleanup yourself, in your setup file:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
afterEach(() => {
cleanup()
})
The Object.assign line sets React’s test flag, which globals: true would set for you. We tried this file without globals: true. Both tests passed, and the “waits by hand” test printed the act warning. Without the Object.assign line, it printed no warning.
Strict Mode in tests
Your app runs inside <StrictMode>, as Part 2 explained. React Testing Library’s render does not add it. Its docs say the reactStrictMode setting “Defaults to false”.
We counted the calls to fakeFetch in the first UserCard test:
| Strict Mode | Calls to fakeFetch |
|---|---|
| off, the default | 1 |
on, with render(<UserCard id={1} />, { reactStrictMode: true }) |
2 |
The second call is Strict Mode running the Effect twice, as in Part 13. To turn it on for every test, add two lines to src/setupTests.ts:
import { configure } from '@testing-library/react'
configure({ reactStrictMode: true })
We tried it. Then the first UserCard test failed, because it says 1 call:
AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times
So pick one and write your counts to match it. With Strict Mode on, your tests run your components the way npm run dev does.
Snapshot tests
A snapshot is a copy of something at one moment. A snapshot test saves a copy of your component’s HTML the first time. After that, it fails whenever the HTML changes.
import { render } from '@testing-library/react'
import { expect, test } from 'vitest'
import Counter from './Counter'
test('looks the same as last time', () => {
const { asFragment } = render(<Counter />)
expect(asFragment()).toMatchSnapshot()
})
The first run passed and printed Snapshots 1 written. Vitest saved the copy in a new folder, src/__snapshots__:
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`looks the same as last time 1`] = `
<DocumentFragment>
<button>
Clicked 0 times
</button>
</DocumentFragment>
`;
Then we changed the text to “You clicked {count} times”. The test failed and showed the change:
Error: Snapshot `looks the same as last time 1` mismatched
- Expected
+ Received
<DocumentFragment>
<button>
- Clicked 0 times
+ You clicked 0 times
</button>
</DocumentFragment>
If the change is what you wanted, update the saved copy. In watch mode, press u. Or run npx vitest run -u. We did, and it printed Snapshots 1 updated.
Use snapshot tests rarely. Here is why:
- A snapshot fails on every change, the good ones too. People soon learn to press u without reading the change. Then the test checks nothing.
- A snapshot of a big component is long. Nobody reads it closely.
- It checks the HTML, not what happens when a user clicks or types.
A snapshot can help with a small piece of output that should almost never change. For everything else, check one thing a user can see.
Common mistakes
Testing details the user can’t see
import { render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import Counter from './Counter'
test('counts each click', async () => {
const user = userEvent.setup()
const { container } = render(<Counter />)
const button = container.querySelector('.counter-button')!
await user.click(button)
expect(button).toHaveTextContent('Clicked 1 times')
})
This finds the button by a CSS class. We gave Counter‘s button className="counter-button", and the test passed. Then we changed the class to counter. The app worked the same, but the test failed. querySelector found nothing, so button was null:
Error: expect(received).toHaveTextContent()
received value must be a Node.
Received has type: Null
Received has value: null
The fix: find the button the way a user does, with screen.getByRole('button', { name: 'Clicked 0 times' }).
getBy for something that should not be there
expect(screen.getByText('Saved!')).not.toBeInTheDocument()
You want to check that “Saved!” is not on the page. But getByText throws before expect even runs: Unable to find an element with the text: Saved!. We tried it, and the test failed.
The fix: expect(screen.queryByText('Saved!')).not.toBeInTheDocument(). queryByText gives back null, and the check passes.
Forgetting await before a user-event call
const button = screen.getByRole('button')
user.click(button)
expect(button).toHaveTextContent('Clicked 1 times')
user.click gives back a promise. Without await, the test goes on before the click happens. We tried it:
Expected element to have text content:
Clicked 1 times
Received:
Clicked 0 times
The fix: await user.click(button). The test function must be async for that.
Waiting with your own timer
await new Promise(resolve => setTimeout(resolve, 50)) guesses how long to wait. It gives the act warning you saw above. We also made the fake server take 100 ms. Then the test checked too soon and failed: Unable to find an element with the text: Hello, Ana!. The fix: await screen.findBy... or await waitFor(...).
Forgetting globals: true
Without it, or without your own afterEach(cleanup), each test sees what the tests before it left on the page. In our two-test file, the second test passed on its own. It failed only after the first one. We showed this in Starting fresh for every test.
Practice
The playground can’t run tests. So tasks 1 to 3 are for your Part 10 project, with the files from this part. Task 4 is in the playground.
- Write a test that renders
Counter, clicks it three times, and checks for “Clicked 3 times”. - Write a test that checks that
SignUp‘s thanks message is not on the page before anyone signs up. - Write a test where the fake
fetchgives back status 404. Check thatUserCardshows “The server answered with status 404”. - In the second “Try this first” example, call
button.click()twice before the wait. Change'Clicked 1 times'to'Clicked 2 times'. Guess what the test says. Then run it.
Answers
- Click three times, with
awaiteach time. It passed. The code is below. - Check that the form is there, and that the message is not. The first check is the strong one: it fails if the thanks message shows too early. The second uses
queryByText, becausegetByTextwould throw. Its text has no email in it, becauseemailis still empty. It passed. - Change the status, and look for the alert. It passed.
- It says “FAIL, it says Clicked 1 times”. The counter shows 1, not 2. Both clicks ran before React updated the page. So both saw
countas 0, and both set it to 1. Part 4 explained why: inside one render,countis a fixed value. A user-event click is different. Eachawait user.clickwaits for React to update the page. So the next click sees the new count. That’s why answer 1 reaches 3.
Answer 1, at the end of src/Counter.test.tsx:
test('counts three clicks', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button')
await user.click(button)
await user.click(button)
await user.click(button)
expect(button).toHaveTextContent('Clicked 3 times')
})
Answer 2, at the end of src/SignUp.test.tsx:
test('no thanks before signing up', () => {
render(<SignUp />)
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument()
expect(screen.queryByText('Thanks! We will write to .')).not.toBeInTheDocument()
})
Answer 3, at the end of src/UserCard.test.tsx:
test('shows a 404', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('Not found', { status: 404 })))
render(<UserCard id={1} />)
expect(await screen.findByRole('alert')).toHaveTextContent('The server answered with status 404')
})
Interview questions
Try to answer each one out loud before you open the answer.
What are the three steps of a test?
Arrange, act, assert. Arrange puts the component on the page and finds what you need. Act does what a user does, like a click. Assert checks the result, and fails the test if it’s wrong. A strong answer adds that React’s act helper is named after this pattern. It also says a test should check one behaviour, so a failure points at one cause.
Why is finding by role the first choice?
It finds elements the way people do: by what the element is, and what it’s called. People who use a screen reader find things this way too. A test that uses it keeps working when you change a class name or the HTML around the button. And when getByRole can’t find something, many users can’t find it either. So the test also checks a little accessibility. getByTestId is the last choice, because users can’t see or hear a test id.
What is the difference between the get, query and find kinds of query?
When nothing matches, getBy throws, queryBy gives back null, and findBy keeps trying. findBy gives back a promise, and throws if nothing appears within 1000 ms. All three throw when more than one element matches. Use getBy for things that are there now. Use queryBy to check that something is not there. Use findBy for things that appear later, like data from a server.
Why use user-event to click and type in a test?
fireEvent sends one event. user-event acts like a person. A click becomes pointer and mouse events, a focus change, and then the click. Typing becomes one key at a time, so onChange runs for each letter. So a test with fireEvent can miss bugs in code that runs on each key, or that needs the focus. A strong answer adds the rules: call userEvent.setup() before render, and await every call.
What does “An update to X inside a test was not wrapped in act(…)” mean?
Component X changed its state while the test wasn’t waiting for React. Usually something async finished, like a fetch or a timer, after the test moved on. The test may then check the page too soon. The fix is almost never to add act by hand. Wait with Testing Library instead: await screen.findBy... or await waitFor(...). render and user-event already use act, and findBy and waitFor turn the warning off while they wait. A strong answer adds that React warns only when globalThis.IS_REACT_ACT_ENVIRONMENT is true. Testing Library sets it when the test runner’s hooks are global, or you can set it in a setup file.
How do you test a component that loads data from a server?
Replace fetch with a fake, so the test never uses the network. In Vitest, vi.stubGlobal('fetch', vi.fn(...)) does that, and vi.unstubAllGlobals() puts it back. Then render, check the loading state, and await a findBy query for the data. Write a second test where the fake answers with an error status, to check the error screen. A strong answer adds checks on the mock itself, like toHaveBeenCalledWith(url). It may also name Mock Service Worker, a library for faking a server. Its docs say it works at the level of the network, so it doesn’t replace fetch in each test. It also says that with Strict Mode on, the Effect runs twice, so the call count doubles.
When are snapshot tests a bad idea?
When the snapshot is big, or the output changes often. Every change makes it fail, so people update it without reading. Then it checks nothing. It also checks HTML, not behaviour. A snapshot is fine for a small piece of output that should almost never change. For everything else, check one thing a user can see.
Sources
- Guiding Principles, testing-library.com: “The more your tests resemble the way your software is used, the more confidence they can give you.”
- About Queries, testing-library.com: the three kinds of query, the table, the 1000 ms limit, and the priority list with its quotes.
- ByTestId, testing-library.com: when a test id is right.
- React Testing Library: Introduction and Setup, testing-library.com: the install command, and cleanup in Vitest with
globalsorafterEach. - React Testing Library: API, testing-library.com:
render,cleanup,renderHookand thereactStrictModesetting. - Async Methods, testing-library.com:
findByandwaitFor, the 50 ms interval and the 1000 ms timeout. - user-event: Introduction and Setup, testing-library.com: how user-event differs from
fireEvent, and callingsetup()beforerender. - jest-dom, GitHub: the
@testing-library/jest-dom/vitestimport for Vitest. - Vitest: Getting Started, Config, environment, globals and setupFiles, vitest.dev: the
referenceline, thejsdomsetting and the defaults. - Vitest: vi, vitest.dev:
vi.fn,vi.stubGlobalandvi.unstubAllGlobals. - Vitest: Snapshot, vitest.dev: snapshot files and updating them with
-u. - Mock Service Worker, mswjs.io: what it is, and that it catches requests at the network level instead of replacing
fetch. - slowTestThreshold, vitest.dev: a test is slow after 300 ms by default.
- Extracting State Logic into a Reducer, react.dev: a reducer can be exported and tested on its own.
- react-testing-library
src/pure.js, GitHub:waitForruns withIS_REACT_ACT_ENVIRONMENTturned off. - act, react.dev: what
actdoes, and “The name act comes from the Arrange-Act-Assert pattern.” - Every test result, error message, warning, event list and count above comes from running React 19.3.0 for this post. The tests ran in the Part 10 project (
create-vite9.2.1, Vite 8.3.3, TypeScript 6.0.3) with Vitest 5.0.3, jsdom 30.1.2, React Testing Library 16.3.3, DOM Testing Library 10.4.2, user-event 14.6.7 and jest-dom 7.0.1, on Node.js 24.18.0. The hand-made tests ran in jsdom 30.1.2. - This part is new. The katas don’t have a lesson on testing components.