Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~27 min· topic 4 of 7

Topic 17.4

Simulating Users with user-event

In one line

user-event plays the full sequence of browser events a real person causes (pointer, focus, keys, input, click), so tests catch bugs that a single fireEvent would hide; always setup() it and await every action.

Think of it like this

Testing a lift. You could open the control panel and send the electric signal "door open" directly. Or you could stand outside and press the button like a passenger: the button lights up, the lift arrives, the door opens. Only the second way finds out the button itself is broken. fireEvent sends one signal straight in; **user-event presses the button like a passenger**.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

user-event
A library that simulates real user interactions by firing the full series of browser events.
userEvent.setup()
Creates a simulated user with its own keyboard and mouse state. Call it at the start of each test.
fireEvent
Dispatches one single DOM event. Lower level than user-event.
Event sequence
All the events a browser fires for one action: a click is pointerdown, mousedown, focus, pointerup, mouseup, click.
Focus
Which element receives keyboard input right now. Tab moves it.
Special key
A non-letter key written in braces for user.keyboard, like {Enter} or {Escape}.
Mock function
A fake function that records how it was called, so a test can check it. Vitest's is vi.fn().
document.activeElement
The element that has focus right now.

Step by step

01Set up a user before rendering

Create the user first, then render, then interact. Each test gets a fresh user so held keys or pointer position don't leak between tests.

LoginForm.test.jsxwhole filejsx
test("submits the typed values", async () => {
  const user = userEvent.setup();
  const onLogin = vi.fn();
  render(<LoginForm onLogin={onLogin} />);

  await user.type(screen.getByRole("textbox", { name: "Email" }), "asha@example.com");
  await user.type(screen.getByLabelText("Password"), "secret1");
  await user.click(screen.getByRole("button", { name: "Log in" }));

  expect(onLogin).toHaveBeenCalledWith({ email: "asha@example.com", password: "secret1", role: "student" });
});

02Check what came out: callbacks and the screen

A component's "output" is what appears on screen and the callbacks it calls with data. A mock function records its calls: in Vitest vi.fn() with toHaveBeenCalledWith(...) and toHaveBeenCalledTimes(n). The live examples build a tiny one by hand to show there is no magic: it pushes each call's arguments into an array.

03One click is many events

This is what user.click(button) fires, in order. A handler on any of them (say onMouseDown that prevents focus, or onPointerDown that opens a menu) runs in the test as in the browser. fireEvent.click fires only the last one.

One click is many eventsdiagram
Rendering diagram…

04Use the keyboard like a keyboard user

user.tab() moves focus to the next focusable element in page order, user.tab({ shift: true }) back. user.keyboard("{Enter}") presses Enter on whatever has focus. Check focus with document.activeElement (or jest-dom's toHaveFocus()).

Menu.test.jsxwhole filejsx
await user.tab();
expect(screen.getByRole("textbox", { name: "Search" })).toHaveFocus();
await user.tab();
await user.keyboard("{Enter}");
expect(screen.getByRole("menu")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();

05Compare with fireEvent once, then mostly forget it

The third live example sends 123456 into a PIN box with maxLength={4} both ways. fireEvent.change puts in all six digits and never fires a keydown; user-event types four and fires six keydowns, exactly like a person.

Try it yourself

  1. 1

    Remove a label and watch the test explain it

    In the login example, change <label>Email <input ... /></label> so the input sits outside the label. Predict which tests fail and what the error lists. Put it back.

  2. 2

    Press Space instead of Enter

    In the keyboard example, change "{Enter}" to " " (a space). Predict: does the menu still open? (A focused button is clicked by Space too.)

  3. 3

    Let fireEvent fire a keydown

    In the third example, add fireEvent.keyDown(screen.getByLabelText("PIN"), { key: "1" }) before the fireEvent.change. Predict the new keys count. You can build every event by hand, but you must know and repeat the browser's whole sequence yourself.

Code & diagrams

Testing a login form with type, selectOptions, click and Enter New tab

Note the password input: it has no textbox role (screen readers don't expose password fields as text boxes), so the test finds it by its label. `{Enter}` inside `type` presses Enter, which submits the form.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

✓ the button is disabled until both fields are filled
✓ submits the typed values
✓ Enter in a field submits the form
✓ clear() empties a field
Keyboard tests: Tab order, Enter and Escape New tab

Enter on a focused button clicks it, exactly as in a browser, so the menu opens without any onKeyDown code on the button. The menu items have tabIndex -1, so Tab skips them.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

✓ Tab moves focus in page order
✓ Enter on the focused button opens the menu
✓ Escape closes the menu
✓ keyboard() types special keys too
fireEvent versus user-event on the same input New tab

A person can't type six digits into a four-character box, and every key they press fires keydown. fireEvent skipped both rules, so a test using it would pass on behaviour no user can produce.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

fireEvent:  value = 123456, Keys pressed: 0
user-event: value = 1234, Keys pressed: 6
The same login test in Vitest with vi.fn() and jest-domjsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import LoginForm from "./LoginForm";

test("submits the typed values", async () => {
  const user = userEvent.setup();
  const onLogin = vi.fn();
  render(<LoginForm onLogin={onLogin} />);

  await user.type(screen.getByRole("textbox", { name: "Email" }), "asha@example.com");
  await user.type(screen.getByLabelText("Password"), "secret1");
  await user.selectOptions(screen.getByRole("combobox", { name: /i am a/i }), "Teacher");
  await user.click(screen.getByRole("button", { name: "Log in" }));

  expect(onLogin).toHaveBeenCalledTimes(1);
  expect(onLogin).toHaveBeenCalledWith({ email: "asha@example.com", password: "secret1", role: "teacher" });
});

test("the button starts disabled", () => {
  render(<LoginForm onLogin={vi.fn()} />);
  expect(screen.getByRole("button", { name: "Log in" })).toBeDisabled();
});

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget await

In "submits the typed values", remove await from every user. call.

terminal
$ Console
── what you'll see ──
✗ submits the typed values: expected [[{"email":"asha@example.com","password":"secret1","role":"teacher"}]], got []

Break #2

Test with fireEvent and miss a maxLength bug

Use fireEvent.change(pin, { target: { value: "123456" } }) and assert the value is "123456".

terminal
$ Console
── what you'll see ──
fireEvent: value = 123456, Keys pressed: 0

Myth vs fact

Myth

userEvent.click(el) without setup() is the same thing.

Fact

The direct APIs still exist in v14 but call setup() internally for every call, so keyboard and pointer state (a held Shift, the pointer position) don't carry between calls. The docs recommend const user = userEvent.setup() per test.

Myth

user-event is slow, so use fireEvent for speed.

Fact

Typing a long string fires many events, but it is still milliseconds in jsdom. For very long text you can paste instead: await user.click(input); await user.paste(longText). Correctness beats microseconds.

Myth

user-event drives a real browser.

Fact

It fires synthetic DOM events inside jsdom (or whatever DOM it runs in) and imitates default browser behaviour. It doesn't render or lay out anything; Playwright is the tool for real browsers (Topic 17.7).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    user-event types into the element's value through the same native value setter React tracks, then fires input, which is why React's onChange fires per character. Setting input.value = "x" yourself and dispatching input doesn't trigger onChange, because React's value tracker sees no change. fireEvent.change works around this internally.

  • ▸

    setup() options: delay (ms between actions, default 0, which still yields to the event loop between keystrokes; null skips that), advanceTimers (pass vi.advanceTimersByTime when using fake timers, Topic 17.5), pointerEventsCheck and skipHover. Forgetting advanceTimers with fake timers is the classic reason a user-event test hangs until it times out.

  • ▸

    RTL configures @testing-library/dom so user-event's events are wrapped in act(). That is one reason RTL 16 makes @testing-library/dom a peer dependency: user-event and RTL must share the same copy for the act wrapping to apply.

  • ▸

    user.keyboard uses KeyboardEvent.key values in braces ({ArrowDown}) and [KeyA]-style codes in brackets. Holding a modifier ({Shift>}a{/Shift}) sets shiftKey on the events but still types the key as written (a), so write the uppercase letter yourself when you need it.

Remember this

  1. 1

    @testing-library/user-event (version 14) simulates interactions the way a browser produces them. When a person clicks a button, the browser fires many events: pointerover, pointerenter, mouseover, pointermove, pointerdown, mousedown, focus moves, pointerup, mouseup, click. When they type one letter into a field, it fires keydown, keypress, beforeinput, input and keyup, and the field's value changes one character at a time. user-event reproduces these sequences and the browser's default behaviour.

  2. 2

    You start with **const user = userEvent.setup()** at the top of each test, before render. setup() creates a simulated user with its own keyboard and pointer state (which keys are held, where the pointer is) and wires it to the document. Every method returns a promise, so you always await it: await user.click(button). Forgetting await means your assertion runs before the interaction finishes.

  3. 3

    The main methods: user.click(el), user.dblClick(el), user.hover(el) / user.unhover(el), user.type(el, "text") (clicks the element, then types), user.clear(el) (selects all and deletes), user.selectOptions(select, "Teacher") (by value or visible text), user.upload(input, file), and user.tab() / user.tab({ shift: true }) to move focus like the Tab key. user.keyboard("...") types into whatever has focus and understands special keys in braces: {Enter}, {Escape}, {Backspace}, {ArrowDown}, {Tab}; {Shift>} holds a key down and {/Shift} releases it. To type a literal brace, double it: {{.

  4. 4

    user-event respects what real users can't do. It won't type more than maxLength allows. A click on a disabled button does nothing. An element with pointer-events: none throws an error instead of being clicked. Typing goes to the focused element only. Pressing Enter in a single field of a form submits the form, and Space or Enter on a focused button clicks it. These rules are exactly the bugs fireEvent misses.

  5. 5

    **fireEvent** (from @testing-library/react) dispatches one DOM event you describe: fireEvent.change(input, { target: { value: "123456" } }) sets the value directly and fires a single change event. No keydown, no focus, no maxLength. It is still useful for events user-event doesn't cover (such as scroll, or a media element's ended), but for clicks, typing and keyboard work, prefer user-event. Testing Library's docs say the same.

  6. 6

    Keyboard tests are accessibility tests too: if user.tab() can't reach your custom dropdown, or Enter doesn't open it, a keyboard-only user can't use it either. Testing focus order (document.activeElement, or jest-dom's toHaveFocus()) and keys like Escape is cheap here and catches real problems (Phase 18).

Explain it without notes

01

Why does Testing Library recommend user-event over fireEvent?

02

Why must you await user-event calls, and why call userEvent.setup()?

03

How do you test keyboard navigation and focus with user-event?

04

When would you still use fireEvent?

Practice

01

Write a test (live helper style) for a Newsletter component: typing an email and pressing Enter calls onSubscribe with the email.

02

Write a test that checks a dialog's "Close" button gets focus first when the dialog opens, using user.tab().

Trade-offs

  • ↔

    user-event is realistic and slightly slower and more async; fireEvent is synchronous and minimal but can test impossible interactions. Default to user-event.

  • ↔

    Keyboard tests in jsdom check focus order and key handling cheaply, but not visible focus rings or real screen reader output; those need a real browser and manual checks.

Done when you can

  • I call userEvent.setup() per test and await every action.

  • I can type, clear, select options, click and press Enter in a form test.

  • I can test tab order and Escape/Enter keys.

  • I can explain what fireEvent skips and when it is still useful.

  • I can check callbacks with a mock function.