Automated tests are programs that check that your code still works. They let you refactor without fear, catch regressions before users do, and double as living documentation. For React, a modern, fast setup is Vitest (the test runner that comes from the Vite family) plus React Testing Library (renders your components and finds elements the way a person would). Everything in this lesson was run in a fresh Vite React project; the output shown is what the tools printed.
What each tool does
| Tool | Job |
|---|---|
| Vitest | finds and runs your test files, reports passes and failures, provides test, expect, vi (mocking) |
| jsdom | a simulated browser DOM in Node, so components can render without a real browser |
| @testing-library/react | render, screen, renderHook: mount components and query the result |
| @testing-library/user-event | simulates real user actions: clicking, typing, tabbing |
| @testing-library/jest-dom | extra readable matchers: toBeInTheDocument, toHaveTextContent, toBeDisabled |
Setup
In your project folder:
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-domTell Vite to run tests in jsdom by adding a test section to vite.config.js:
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // fake browser
setupFiles: './src/setupTests.js',
globals: true, // test, expect, vi, describe available without imports
},
})Create src/setupTests.js to add the extra matchers to every test:
import '@testing-library/jest-dom/vitest'Add a script to package.json:
"scripts": {
"test": "vitest"
}npm test starts Vitest in watch mode: it re-runs the tests affected by each change as you save. Use npx vitest run for a single run, as in CI. Test files are found by name: *.test.jsx, *.test.js (or .spec.), usually placed next to the code they test.
Your first test: a counter
Here is the component under test:
import { useState } from 'react'
export default function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
)
}And its tests:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter.jsx'
describe('Counter', () => {
test('starts at the initial value', () => {
render(<Counter initial={5} />)
expect(screen.getByText('Count: 5')).toBeInTheDocument()
})
test('increments when the button is clicked', async () => {
const user = userEvent.setup()
render(<Counter />)
await user.click(screen.getByRole('button', { name: 'Increment' }))
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(screen.getByText('Count: 2')).toBeInTheDocument()
})
test('resets to zero', async () => {
const user = userEvent.setup()
render(<Counter initial={3} />)
await user.click(screen.getByRole('button', { name: 'Reset' }))
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
})Each test follows the same three-step shape, known as Arrange, Act, Assert:
- Arrange:
render(...)mounts the component into the fake DOM. - Act:
user.click(...)interacts like a person (it isasync, soawaitit). - Assert:
expect(...)checks what is on screen.
describe groups related tests, and each test (or it) has a name that reads like a sentence describing behaviour. Run it:
RUN v5.0.5
✓ src/Counter.test.jsx > Counter > starts at the initial value
✓ src/Counter.test.jsx > Counter > increments when the button is clicked
✓ src/Counter.test.jsx > Counter > resets to zeroFinding elements: queries
screen has queries for every way a user perceives the page. Prefer them in this order, because the first ones also verify accessibility:
| Query | Finds | Example |
|---|---|---|
getByRole |
by role and accessible name | getByRole('button', { name: 'Save' }) |
getByLabelText |
a form control by its label | getByLabelText('Email') |
getByPlaceholderText |
by placeholder | getByPlaceholderText('Search') |
getByText |
by visible text | getByText('Count: 5') |
getByTestId |
by data-testid (last resort) |
getByTestId('total') |
Each has three variants:
| Variant | If missing | Use for |
|---|---|---|
getBy... |
throws an error | elements that must be there now |
queryBy... |
returns null |
asserting something is not there |
findBy... |
waits, then throws | things that appear later (after a fetch or timer) |
Add All for several matches (getAllByRole('listitem')). When a query fails, the error message prints the current DOM, as in the failure shown below. Because you query by role and name, your tests fail when a button loses its accessible name, a nice side effect: they check accessibility as they go.
Testing a form
The signup form below validates an email address and calls onSubmit with it:
import { useState } from 'react'
export default function SignupForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [error, setError] = useState('')
function handleSubmit(event) {
event.preventDefault()
if (!/^\S+@\S+\.\S+$/.test(email)) {
setError('Enter a valid email')
return
}
setError('')
onSubmit(email)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={e => setEmail(e.target.value)} />
{error && <p role="alert">{error}</p>}
<button>Sign up</button>
</form>
)
}import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SignupForm from './SignupForm.jsx'
test('shows an error for an invalid email and does not submit', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<SignupForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), 'not-an-email')
await user.click(screen.getByRole('button', { name: 'Sign up' }))
expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid email')
expect(onSubmit).not.toHaveBeenCalled()
})
test('submits a valid email', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<SignupForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText('Email'), 'asha@example.com')
await user.click(screen.getByRole('button', { name: 'Sign up' }))
expect(onSubmit).toHaveBeenCalledWith('asha@example.com')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})Notable points: vi.fn() makes a mock function that records how it was called, so you can assert toHaveBeenCalledWith(...). The field is found by its label, so the test also proves the label is connected. queryByRole is used to assert the error is gone. Both tests passed (the run took 131 ms and 43 ms).
Testing data fetching: mock fetch
A component test should not call a real server. Replace fetch with a mock that returns the response you want, and use findBy to wait for the result:
import { render, screen } from '@testing-library/react'
import UserList from './UserList.jsx'
afterEach(() => {
vi.restoreAllMocks()
})
test('shows users after loading', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: 'Asha' }, { id: 2, name: 'Ravi' }],
})
render(<UserList />)
expect(screen.getByText('Loading...')).toBeInTheDocument()
expect(await screen.findByText('Asha')).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(2)
})
test('shows an error when the request fails', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 500 })
render(<UserList />)
expect(await screen.findByRole('alert')).toHaveTextContent('Could not load users')
})(UserList is the status-based fetching component from the data-fetching lesson, calling fetch('/api/users').) The first test checks the loading text, then waits for a name to appear. The second makes the "server" answer 500 and checks the error path, which is hard to trigger with a real server. afterEach(() => vi.restoreAllMocks()) removes the mocks so tests stay independent. For bigger apps, MSW (Mock Service Worker) intercepts requests at the network level and is popular for the same purpose.
Testing logic without components
Pure functions need no rendering at all, as the reducer lesson promised. Custom hooks use renderHook:
import { renderHook, act } from '@testing-library/react'
import { tasksReducer } from './tasksReducer.js'
import { useToggle } from './useToggle.js'
describe('tasksReducer', () => {
test('adds a task without changing the original array', () => {
const before = []
const after = tasksReducer(before, { type: 'added', id: 1, text: 'Write tests' })
expect(after).toEqual([{ id: 1, text: 'Write tests', done: false }])
expect(before).toEqual([])
})
test('toggles one task', () => {
const tasks = [{ id: 1, text: 'A', done: false }, { id: 2, text: 'B', done: false }]
expect(tasksReducer(tasks, { type: 'toggled', id: 2 })[1].done).toBe(true)
})
test('throws for an unknown action', () => {
expect(() => tasksReducer([], { type: 'nope' })).toThrow('Unknown action: nope')
})
})
test('useToggle flips its value', () => {
const { result } = renderHook(() => useToggle())
expect(result.current[0]).toBe(false)
act(() => result.current[1]())
expect(result.current[0]).toBe(true)
})act wraps state changes that happen outside user-event so React processes them before you assert. The whole project's run (four test files, eleven tests) printed:
Test Files 4 passed (4)
Tests 11 passed (11)
Duration 724msReading a failure
Tests earn their keep when they fail. I changed the expected text in the counter test to Count: 3 while the component showed 2:
FAIL src/Counter.test.jsx > Counter > increments when the button is clicked
TestingLibraryElementError: Unable to find an element with the text: Count: 3. ...
<body>
<div>
<div>
<p>
Count:
2
</p>
...
16| expect(screen.getByText('Count: 3')).toBeInTheDocument()
| ^The report names the test, shows the failing line with an arrow, and prints the DOM at the moment of failure: the count is 2. Read it as a debugging tool; the DOM dump usually shows the problem immediately. When you cannot see why, add screen.debug() to print the DOM, or use screen.logTestingPlaygroundURL() to find a better query.
What to test, and what not to
- Test behaviour from the user's side: after this action, this is visible. Do not test internal state, the number of renders or implementation details; those tests break when you refactor even though the app still works.
- Cover the paths that matter: the happy path, validation errors, loading, error and empty states, and edge cases (empty list, very long text).
- Keep tests independent and fast. Each test sets up its own world and cleans up (Testing Library unmounts automatically).
- Write a test when you fix a bug, so that it cannot return.
- Don't chase 100% coverage. Run
vitest run --coverage(needs@vitest/coverage-v8) to find untested code, but treat the number as a guide. - Don't mock what you own unless you must. The more real the setup, the more confidence the test gives.
- Add a few end-to-end tests with Playwright or Cypress for the most critical user journeys. They run a real browser against the real app and complement, not replace, component tests.
Common mistakes
- Forgetting
awaitonuser.clickoruser.type, then asserting too early. - Using
getByto assert something is absent (usequeryBy). - Using
getByfor things that appear later (usefindBy). - Querying by CSS class or test id when a role or label would do.
- Sharing state between tests (a module variable), so order matters.
- Forgetting the
setupFilesline, then seeing "Invalid Chai property: toBeInTheDocument". - Testing implementation details such as the name of a state variable.
- Leaving real network calls in tests.
Exercise
- Add a test for the
Counterthat clicks Increment three times and then Reset, and checks the final text. - Write tests for your task manager's
tasksReducer, including that adding does not mutate the original array. - Test a
TextFieldcomponent from the accessibility lesson: find its input withgetByLabelText. - Make one test fail on purpose and practise reading the DOM printout.
Key points
- Vitest runs your tests, jsdom provides a fake browser, and React Testing Library renders components and finds elements the way users do.
- Query by role, label and text (getByRole, getByLabelText, getByText), act with user-event, and assert with jest-dom matchers such as toBeInTheDocument.
- Use findBy queries for things that appear asynchronously, and mock fetch with vi.spyOn so tests are fast and need no server.
- Test behaviour, not implementation: what the user sees and does, not internal state; pure functions like reducers are tested directly.