C.W.K.
Stream
Lesson 03 of 04 · published

user-event: Acting Like a Human

~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);
});

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.