From rendering to interaction
Most RTL tutorials start with render and stop at "assert text is visible."
Real-world components are interactive. Users click buttons, fill forms, open
modals, navigate routes, and trigger conditional rendering. This guide covers
how to test all of it.
The userEvent setup pattern
Since @testing-library/user-event v14, all interactions are async and require
a user instance created via userEvent.setup(). Create it once per test:
import userEvent from '@testing-library/user-event'
test('some interaction', async () => {
const user = userEvent.setup() // one instance per test
render(<MyComponent />)
await user.click(...)
await user.type(...)
})
Never create the instance inside a loop or call it before render — fake
timers and the internal event queue depend on a stable instance.
Testing button clicks and state updates
The simplest interaction test: click a button, assert the resulting state change in the DOM.
test('counter increments on click', async () => {
const user = userEvent.setup()
render(<Counter />)
expect(screen.getByText('Count: 0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
Three things to note:
- Query by
role + name— tests accessibility alongside behavior. awaitthe click — userEvent v14 is async.- Assert the DOM outcome, not the component's internal state.
Testing callback props
When a component calls a callback prop in response to user action, verify both that the callback was called and what arguments it received:
test('calls onAddToCart with product id', 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)
})
Use vi.fn() (Vitest) or jest.fn() for callback spies. Never mock internal
implementation functions — only the public contract.
Form testing
Testing a form means filling every required field, submitting, and asserting the outcome (callback called, navigation, success message).
test('submits registration form with correct data', async () => {
const onSubmit = vi.fn()
const user = userEvent.setup()
render(<RegistrationForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret123')
await user.click(screen.getByRole('button', { name: /register/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
})
})
Controlled inputs
Controlled inputs update React state on every keystroke. user.type() fires
the correct keyboard event sequence, so controlled inputs work without any
special setup:
await user.type(screen.getByRole('textbox', { name: /search/i }), 'react hooks')
expect(screen.getByRole('textbox', { name: /search/i })).toHaveValue('react hooks')
To clear before typing:
await user.clear(input)
await user.type(input, 'new value')
Select, checkbox, and radio
// Select
await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'us')
expect(screen.getByRole('combobox')).toHaveDisplayValue('United States')
// Checkbox
const checkbox = screen.getByRole('checkbox', { name: /accept terms/i })
await user.click(checkbox)
expect(checkbox).toBeChecked()
// Radio
await user.click(screen.getByRole('radio', { name: /monthly/i }))
expect(screen.getByRole('radio', { name: /monthly/i })).toBeChecked()
expect(screen.getByRole('radio', { name: /annual/i })).not.toBeChecked()
Form validation
Test that the form shows validation errors when required fields are missing:
test('shows validation error for empty email', async () => {
const user = userEvent.setup()
render(<LoginForm />)
// Submit without filling email
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i)
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
})
Testing conditional rendering
Use getBy* for elements that must be present, and queryBy* for elements
that might not be:
test('hides error when input is valid', async () => {
const user = userEvent.setup()
render(<EmailInput />)
await user.type(screen.getByRole('textbox'), 'not-an-email')
expect(screen.getByRole('alert')).toBeInTheDocument()
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'valid@email.com')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
Testing a toggle:
test('accordion expands and collapses', async () => {
const user = userEvent.setup()
render(<Accordion title="Details"><p>Hidden content</p></Accordion>)
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /details/i }))
expect(screen.getByText('Hidden content')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /details/i }))
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
})
Testing components with context
Wrap the component in its required provider via the wrapper option, or
create a custom render helper:
function renderWithTheme(ui, { theme = 'light' } = {}) {
return render(ui, {
wrapper: ({ children }) => (
<ThemeProvider initialTheme={theme}>{children}</ThemeProvider>
),
})
}
test('switches theme on toggle', async () => {
const user = userEvent.setup()
renderWithTheme(<ThemeToggle />)
await user.click(screen.getByRole('button', { name: /dark mode/i }))
expect(document.body).toHaveClass('dark')
})
Use the real provider — not a mocked context value — to catch integration bugs between the provider and consumer.
Testing components with React Router
Wrap with MemoryRouter to provide a router context without a real browser:
test('shows 404 for unknown route', () => {
render(
<MemoryRouter initialEntries={['/unknown']}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="*" element={<NotFound />} />
</Routes>
</MemoryRouter>
)
expect(screen.getByText('Page Not Found')).toBeInTheDocument()
})
test('navigates to profile after login', async () => {
const user = userEvent.setup()
render(
<MemoryRouter initialEntries={['/login']}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
</MemoryRouter>
)
await user.type(screen.getByLabelText(/email/i), 'user@test.com')
await user.type(screen.getByLabelText(/password/i), 'pass123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByText('My Profile')).toBeInTheDocument()
})
Testing modals and portals
screen queries target document.body, so portal content is automatically
included — no special setup needed:
test('opens and closes a modal', async () => {
const user = userEvent.setup()
render(<ConfirmDeleteModal trigger={<button>Delete</button>} onConfirm={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /delete/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
Use within() to scope queries inside the modal:
const dialog = screen.getByRole('dialog')
expect(within(dialog).getByRole('heading')).toHaveTextContent('Confirm Deletion')
await user.click(within(dialog).getByRole('button', { name: /confirm/i }))
Testing keyboard navigation
user.tab() moves focus forward; user.keyboard('{Shift>}{Tab}{/Shift}') moves
it backward. Combine with toHaveFocus():
test('tab cycles through interactive elements', async () => {
const user = userEvent.setup()
render(<Modal open><input aria-label="Name" /><button>Save</button></Modal>)
const input = screen.getByRole('textbox', { name: /name/i })
const saveBtn = screen.getByRole('button', { name: /save/i })
input.focus()
await user.tab()
expect(saveBtn).toHaveFocus()
await user.tab()
expect(input).toHaveFocus() // focus trap wraps back
})
Testing error boundaries
Error boundaries catch children that throw during render. Suppress
console.error to keep test output clean:
function ThrowOnMount() {
throw new Error('Render failure')
}
test('renders fallback on child error', () => {
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()
})
Integration tests for full user flows
Integration tests render a larger component tree and simulate a complete user journey. They give high confidence but run slower.
test('login → dashboard flow', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/login', () =>
HttpResponse.json({ token: 'tok', user: { name: 'Alice' } })
)
)
render(
<MemoryRouter initialEntries={['/login']}>
<App />
</MemoryRouter>
)
await user.type(screen.getByLabelText(/email/i), 'alice@test.com')
await user.type(screen.getByLabelText(/password/i), 'secret')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(await screen.findByText('Welcome, Alice')).toBeInTheDocument()
})
A good testing strategy:
- Unit tests — pure logic (utils, reducers).
- Component tests — individual component behavior.
- Integration tests — critical multi-component flows (auth, checkout).
Snapshot testing: use sparingly
Snapshots catch unintentional markup changes but don't verify behavior. Prefer behavioral assertions:
// ❌ Snapshot — only catches "something changed", not "it broke"
expect(container.firstChild).toMatchSnapshot()
// ✅ Behavioral — verifies actual user-facing output
expect(screen.getByRole('heading')).toHaveTextContent('Dashboard')
expect(screen.getAllByRole('listitem')).toHaveLength(3)
Use inline snapshots for small, stable, purely presentational components.
Testing disabled states
test('disabled button does not fire callback', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<button disabled onClick={onClick}>Go</button>)
await user.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
expect(screen.getByRole('button')).toBeDisabled()
})
Interview checklist
Before your next interview, make sure you can:
- Test clicks, typing, form submission, and select/checkbox/radio.
- Assert callback props are called with correct arguments using
vi.fn(). - Test conditional rendering with
getBy*andqueryBy*. - Wrap components in context providers using the
wrapperoption. - Test routing behavior with
MemoryRouter. - Test portals through
screenwithout special setup. - Test keyboard focus with
user.tab()andtoHaveFocus(). - Write an integration test that covers a multi-step user flow.
Rule of thumb: Each test should cover exactly one behavior — one user action and its observable consequence. Compound tests that click five things and check three outcomes are hard to debug when they fail.