Use userEvent.click() from @testing-library/user-event. It fires the
full sequence of pointer and mouse events a real click produces.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function Counter() {
const [count, setCount] = React.useState(0)
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</>
)
}
test('increments count on click', async () => {
const user = userEvent.setup() // setup once per test
render(<Counter />)
expect(screen.getByText('Count: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
Key points:
userEvent.setup()returns a user instance; create it once per test outside any loops to keep fake timers consistent.awaiteveryuser.*call — they're async since user-event v14.- Query by role + name so the test doubles as an accessibility check.
Rule of thumb: await user.click() → assert. One interaction, one
assertion block. Don't chain multiple un-awaited clicks.
The most reliable approach is to fill in all fields via userEvent.type,
then submit via userEvent.click on the submit button (or
userEvent.keyboard('{Enter}') if that's how users submit).
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('submits registration form', async () => {
const handleSubmit = vi.fn()
const user = userEvent.setup()
render(<RegistrationForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret123')
await user.click(screen.getByRole('button', { name: /register/i }))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'secret123',
})
})
})
For native form submission (no JS handler), you can also listen for the
submit event on the form element:
const handleSubmit = vi.fn(e => e.preventDefault())
render(<form onSubmit={handleSubmit}><button type="submit">Go</button></form>)
await user.click(screen.getByRole('button', { name: /go/i }))
expect(handleSubmit).toHaveBeenCalled()
Rule of thumb: Test the outcome of submission (callback called, success message shown, navigation triggered) rather than testing the form's internal state.
userEvent.type() fires a keydown/keypress/input/keyup sequence for
each character, causing controlled inputs to update through the standard
React synthetic event flow.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function SearchBox() {
const [query, setQuery] = React.useState('')
return (
<input
aria-label="Search"
value={query}
onChange={e => setQuery(e.target.value)}
/>
)
}
test('updates value as user types', async () => {
const user = userEvent.setup()
render(<SearchBox />)
const input = screen.getByRole('textbox', { name: /search/i })
await user.type(input, 'react hooks')
expect(input).toHaveValue('react hooks')
})
To clear an existing value before typing, use user.clear():
await user.clear(input)
await user.type(input, 'new value')
To type into a <select>:
await user.selectOptions(screen.getByRole('combobox'), 'Option B')
expect(screen.getByRole('combobox')).toHaveDisplayValue('Option B')
Rule of thumb: Prefer user.type() over manually setting
.value + dispatching a change event — it tests the full keyboard
interaction path.
Assert presence with getByRole/getByText and absence with
queryBy* returning null or not.toBeInTheDocument().
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function Alert({ type, message }) {
if (!message) return null
return <div role="alert" className={`alert-${type}`}>{message}</div>
}
test('renders nothing when message is empty', () => {
render(<Alert type="error" message="" />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
test('renders error message', () => {
render(<Alert type="error" message="Something failed" />)
expect(screen.getByRole('alert')).toHaveTextContent('Something failed')
})
Testing a toggle:
test('shows/hides details on button click', async () => {
const user = userEvent.setup()
render(<ExpandableSection title="Details" content="Hidden text" />)
expect(screen.queryByText('Hidden text')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /details/i }))
expect(screen.getByText('Hidden text')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /details/i }))
expect(screen.queryByText('Hidden text')).not.toBeInTheDocument()
})
Rule of thumb: Always use queryBy* (not getBy*) when asserting
an element is absent — getBy* throws before your assertion runs.
Wrap the component with the real provider (not a mock) in the wrapper
option or a custom render helper.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ThemeProvider } from './ThemeContext'
import ThemeToggle from './ThemeToggle'
function renderWithTheme(ui, { theme = 'light' } = {}) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
),
})
}
test('switches to dark mode', async () => {
const user = userEvent.setup()
renderWithTheme(<ThemeToggle />)
expect(document.body).toHaveClass('light')
await user.click(screen.getByRole('button', { name: /dark mode/i }))
expect(document.body).toHaveClass('dark')
})
test('reads initial theme from context', () => {
renderWithTheme(<ThemeToggle />, { theme: 'dark' })
expect(screen.getByRole('button', { name: /light mode/i })).toBeInTheDocument()
})
Avoid mocking the context value directly (vi.mock) unless the context
comes from a third-party library you can't control. Testing through the
real provider catches integration bugs between the provider and consumer.
Rule of thumb: Use the real provider with controlled initial values; mock only external context sources (auth libraries, feature-flag SDKs).
Wrap with MemoryRouter (or createMemoryRouter + RouterProvider for
v6 data routers) so the component has a router context without a real
browser history.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
// Testing useParams
test('renders product with correct id', () => {
render(
<MemoryRouter initialEntries={['/products/42']}>
<Routes>
<Route path="/products/:id" element={<ProductPage />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByText('Product 42')).toBeInTheDocument()
})
// Testing useNavigate
test('navigates to home after logout', async () => {
const user = userEvent.setup()
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<p>Home</p>} />
</Routes>
</MemoryRouter>
)
await user.click(screen.getByRole('button', { name: /logout/i }))
expect(screen.getByText('Home')).toBeInTheDocument()
})
For components using the newer data router APIs, use
createMemoryRouter + RouterProvider:
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
const router = createMemoryRouter(routes, { initialEntries: ['/dashboard'] })
render(<RouterProvider router={router} />)
Rule of thumb: MemoryRouter for simple path/param tests;
createMemoryRouter when you need loaders, actions, or data-router
features.
Query all list items with getAllBy* or queryAllBy* and check the
array length, then assert individual items by their content.
import { render, screen } from '@testing-library/react'
const todos = [
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk dog' },
{ id: 3, text: 'Read book' },
]
test('renders correct number of todo items', () => {
render(<TodoList todos={todos} />)
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(3)
})
test('renders each todo text', () => {
render(<TodoList todos={todos} />)
expect(screen.getByText('Buy milk')).toBeInTheDocument()
expect(screen.getByText('Walk dog')).toBeInTheDocument()
expect(screen.getByText('Read book')).toBeInTheDocument()
})
test('shows empty state when no todos', () => {
render(<TodoList todos={[]} />)
expect(screen.getByText(/no todos yet/i)).toBeInTheDocument()
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
})
Rule of thumb: Test the count and a sample of items; don't assert every item in a large list — that couples tests to data and makes them brittle.
RTL's screen queries target document.body, which includes portal
content — no special setup needed.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Modal from './Modal'
// Modal renders into document.body via createPortal
test('opens and closes modal', async () => {
const user = userEvent.setup()
render(<Modal trigger={<button>Open</button>}>Dialog content</Modal>)
// Before open — modal not in DOM
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Open the modal
await user.click(screen.getByRole('button', { name: /open/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText('Dialog content')).toBeInTheDocument()
// Close with Escape
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
If the portal target is a custom element (not document.body), you may
need to create it in setup:
beforeEach(() => {
const el = document.createElement('div')
el.setAttribute('id', 'modal-root')
document.body.appendChild(el)
})
afterEach(() => {
document.getElementById('modal-root')?.remove()
})
Rule of thumb: Test portals through screen like any other
element — their implementation detail (portal vs inline) is irrelevant
to user-facing behavior.
Render a child component that throws inside an error boundary, and assert
the fallback UI. Since React logs errors to console.error, suppress
that to keep test output clean.
import { render, screen } from '@testing-library/react'
import ErrorBoundary from './ErrorBoundary'
function ThrowOnMount() {
throw new Error('Test error')
}
test('renders fallback when child throws', () => {
// Suppress React's error output in test logs
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
render(
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<ThrowOnMount />
</ErrorBoundary>
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
consoleSpy.mockRestore()
})
test('renders children when no error', () => {
render(
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<p>All good</p>
</ErrorBoundary>
)
expect(screen.getByText('All good')).toBeInTheDocument()
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument()
})
For error boundaries with a "retry" button, update the child after error to test recovery:
test('recovers after reset', async () => {
const user = userEvent.setup()
let shouldThrow = true
function MaybeThrow() {
if (shouldThrow) throw new Error('oops')
return <p>Recovered</p>
}
// ... render, suppress, click retry, update shouldThrow, assert
})
Rule of thumb: Always mock console.error in error boundary tests —
RTL and React both log thrown errors, cluttering test output without it.
Render the top-level component that composes the components you want to test together, provide any required context or mocks at the boundary, and simulate the full user flow from start to finish.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import App from './App'
const server = setupServer(
rest.post('/api/login', (req, res, ctx) =>
res(ctx.json({ token: 'abc123', user: { name: 'Alice' } }))
)
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('full login flow: form → API → dashboard', async () => {
const user = userEvent.setup()
render(
<MemoryRouter initialEntries={['/login']}>
<App />
</MemoryRouter>
)
// Fill login form
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
// After successful login, should show dashboard
expect(await screen.findByText('Welcome, Alice')).toBeInTheDocument()
expect(screen.queryByRole('form')).not.toBeInTheDocument()
})
Integration tests give the highest confidence but are slower and harder to debug. Strategy:
- Unit tests for complex logic (reducers, utilities).
- Component tests for individual component behavior.
- Integration tests for critical user flows (login, checkout, onboarding).
Rule of thumb: Write integration tests for flows that cross component boundaries and involve real user journeys, not for individual component rendering.
Pass a vi.fn() (or jest.fn()) as the prop and assert on it after
the interaction.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, expect } from 'vitest'
import ProductCard from './ProductCard'
test('calls onAddToCart with product id when button clicked', async () => {
const onAddToCart = vi.fn()
const user = userEvent.setup()
render(<ProductCard id={42} name="Widget" onAddToCart={onAddToCart} />)
await user.click(screen.getByRole('button', { name: /add to cart/i }))
expect(onAddToCart).toHaveBeenCalledTimes(1)
expect(onAddToCart).toHaveBeenCalledWith(42)
})
For multiple interactions:
await user.click(incrementBtn)
await user.click(incrementBtn)
expect(onChange).toHaveBeenCalledTimes(2)
expect(onChange).toHaveBeenNthCalledWith(1, 1)
expect(onChange).toHaveBeenNthCalledWith(2, 2)
Rule of thumb: Use vi.fn() for callback props; use toHaveBeenCalledWith
rather than checking internal state — you're testing the contract between
parent and child.
RTL has dedicated userEvent methods for each form element type.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
const user = userEvent.setup()
// Checkbox
test('toggles checkbox', async () => {
render(<label><input type="checkbox" />Accept terms</label>)
const checkbox = screen.getByRole('checkbox', { name: /accept terms/i })
expect(checkbox).not.toBeChecked()
await user.click(checkbox)
expect(checkbox).toBeChecked()
await user.click(checkbox)
expect(checkbox).not.toBeChecked()
})
// Radio buttons
test('selects radio option', async () => {
render(
<fieldset>
<legend>Size</legend>
<label><input type="radio" name="size" value="S" />Small</label>
<label><input type="radio" name="size" value="L" />Large</label>
</fieldset>
)
await user.click(screen.getByRole('radio', { name: /small/i }))
expect(screen.getByRole('radio', { name: /small/i })).toBeChecked()
expect(screen.getByRole('radio', { name: /large/i })).not.toBeChecked()
})
// Select
test('selects option from dropdown', async () => {
render(
<select aria-label="Country">
<option value="">--</option>
<option value="us">US</option>
<option value="ca">Canada</option>
</select>
)
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'us')
expect(screen.getByRole('combobox', { name: /country/i })).toHaveDisplayValue('US')
})
Rule of thumb: Always query form elements by their accessible role +
name (derived from <label>) — this forces you to write accessible markup.
Assert the disabled state with toBeDisabled() and verify that
clicking a disabled button does not invoke callbacks.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('submit button is disabled while loading', () => {
render(<SubmitButton loading={true} />)
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
})
test('disabled button does not call onClick', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<button disabled onClick={onClick}>Click me</button>)
await user.click(screen.getByRole('button'))
// userEvent respects the disabled attribute and does not fire the click
expect(onClick).not.toHaveBeenCalled()
})
test('submit button becomes enabled after required fields filled', async () => {
const user = userEvent.setup()
render(<Form />)
const button = screen.getByRole('button', { name: /submit/i })
expect(button).toBeDisabled()
await user.type(screen.getByLabelText(/email/i), 'user@test.com')
expect(button).toBeEnabled()
})
Rule of thumb: toBeDisabled() and toBeEnabled() from
@testing-library/jest-dom are clearer than toHaveAttribute('disabled').
Snapshot testing captures the rendered output as a string on first run and
compares future runs against that snapshot. Vitest/Jest provide this via
toMatchSnapshot() or toMatchInlineSnapshot().
import { render } from '@testing-library/react'
test('renders badge correctly', () => {
const { container } = render(<Badge count={5} />)
expect(container.firstChild).toMatchSnapshot()
})
RTL's stance: use sparingly. Snapshots are good for:
- Preventing unintentional markup changes in leaf UI components.
- Locking down third-party rendered output you don't control.
Snapshots are bad for:
- Testing behavior — they catch "it changed", not "it's wrong".
- Large components — snapshots become huge and are mindlessly updated.
- Fast-moving codebases — you spend more time updating snapshots than fixing real bugs.
Prefer inline snapshots when you do use them:
expect(container.firstChild).toMatchInlineSnapshot(`
<span class="badge badge--blue">5</span>
`)
Rule of thumb: Write behavioral assertions (text content, role, visibility) instead of snapshots. Use snapshots only for small, stable, purely presentational components.
More Testing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.