~17 min · vitest-components, user-event, interaction
Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Don't fire a click event. Click."
Why user-event Exists
fireEvent.click(button) dispatches a single DOM click event. That's not what a real user does. A real click is a sequence — pointerdown, mousedown, pointerup, mouseup, click, focus transfer, sometimes keydown if it was keyboard-triggered. Code that handles focus, double-click detection, or pointer events can be broken in production and still pass fireEvent tests.
user-event simulates the full sequence. await user.click(button) walks through the whole event chain in the right order, which catches bugs fireEvent never sees.
The Setup Pattern (user-event v14+)
In v14+ you call userEvent.setup() once per test (typically right after render) to get a user instance. Every interaction goes through that instance, which manages internal state like which element currently has focus.
Every method is async. await user.click(...), await user.type(input, 'hello'), await user.keyboard('{Enter}'). Forgetting await is the most common mistake — the test reads as if it ran but actually didn't.
The Interactions You'll Use Daily
user.click(element) — the workhorse.
user.type(input, 'text') — types text character by character, firing the right events for each key. Use {Backspace}, {Enter}, {Shift>}A{/Shift} for special keys.
user.clear(input) — selects all and deletes (more reliable than type('{Backspace}') in a loop).
user.keyboard('{Enter}') — fires keyboard events without a target; uses whatever has focus.
user.tab() — moves focus forward; pair with user.tab({ shift: true }) for back.
user.hover(element) / user.unhover(element) — for hover-only UI.
user.selectOptions(select, value) — for <select> / role=combobox.
user.upload(input, file) — for file inputs.
If your test reads like a user story, it's probably right."A user types their email, clicks submit, sees the success message" maps almost directly to three user-event calls and one assertion. If the test reads like internal API choreography, step back and rewrite around what the user does.
When fireEvent Earns Its Keep
fireEvent isn't deprecated — it's the right tool for events that don't have a natural user-event equivalent. Examples: synthetic events on non-interactive elements (e.g., simulating a scroll or resize), or when you specifically want to test that your code handles a single event without the full user sequence. For 95% of UI testing, default to user-event.
Code
user-event v14+ — setup() per test, await every call·tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './login-form';
describe('<LoginForm />', () => {
it('submits the form when the user clicks Sign in', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'pippa@example.com'
);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith('pippa@example.com');
});
it('also submits on Enter inside the email field', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole('textbox', { name: /email/i }),
'pippa@example.com{Enter}'
);
expect(onSubmit).toHaveBeenCalledWith('pippa@example.com');
});
});
Keyboard testing — tab + focus assertions·tsx
// Keyboard navigation — testing tab order
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('cycles focus through the form fields in order', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
// No element has focus initially (no autoFocus prop).
await user.tab();
expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: /sign in/i })).toHaveFocus();
// Shift+Tab to go back
await user.tab({ shift: true });
expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
});
`user.clear` — one call, no Backspace gymnastics·tsx
// Clear vs type({Backspace}) — clear is more reliable
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('clears and replaces the input', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={() => {}} />);
const email = screen.getByRole('textbox', { name: /email/i });
await user.type(email, 'oldemail@example.com');
expect(email).toHaveValue('oldemail@example.com');
// Clear: selects all + deletes. One call, no off-by-one.
await user.clear(email);
expect(email).toHaveValue('');
await user.type(email, 'new@example.com');
expect(email).toHaveValue('new@example.com');
});
File upload — `user.upload`·tsx
// File upload — for <input type="file">
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('accepts an uploaded image', async () => {
const user = userEvent.setup();
render(<AvatarUploader onUpload={(f) => console.log(f.name)} />);
const file = new File(['fake image bytes'], 'avatar.png', { type: 'image/png' });
const input = screen.getByLabelText(/upload avatar/i);
await user.upload(input, file);
expect((input as HTMLInputElement).files?.[0]).toBe(file);
expect((input as HTMLInputElement).files).toHaveLength(1);
});
Build a <Counter /> component with +, -, and Reset buttons that shows the current count. Test it with user-event: (1) clicking + three times shows 3, (2) clicking - from 0 doesn't go negative (your component should clamp), (3) Reset returns to 0, (4) pressing the keyboard + or - (via user.keyboard) also works if you wire it up. Every interaction goes through userEvent.setup() and await.
Hint
If the count appears as text inside a <span>, query it with getByText for an exact number — or wrap it in <output aria-label="count"> and query by role for a cleaner test.
Progress
Progress is local-only — sign in to sync across devices.