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.
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.
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()).
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
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
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
Let fireEvent fire a keydown
In the third example, add
fireEvent.keyDown(screen.getByLabelText("PIN"), { key: "1" })before thefireEvent.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
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
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 fieldEnter 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
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 tooA 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
Console
Nothing logged yet.Expected console output
fireEvent: value = 123456, Keys pressed: 0
user-event: value = 1234, Keys pressed: 6import { 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.
Break #2
Test with fireEvent and miss a maxLength bug
Use fireEvent.change(pin, { target: { value: "123456" } }) and assert the value is "123456".
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'sonChangefires per character. Settinginput.value = "x"yourself and dispatchinginputdoesn't triggeronChange, because React's value tracker sees no change.fireEvent.changeworks around this internally. - ▸
setup()options:delay(ms between actions, default 0, which still yields to the event loop between keystrokes;nullskips that),advanceTimers(passvi.advanceTimersByTimewhen using fake timers, Topic 17.5),pointerEventsCheckandskipHover. ForgettingadvanceTimerswith fake timers is the classic reason a user-event test hangs until it times out. - ▸
RTL configures
@testing-library/domso user-event's events are wrapped inact(). That is one reason RTL 16 makes@testing-library/doma peer dependency: user-event and RTL must share the same copy for the act wrapping to apply. - ▸
user.keyboardusesKeyboardEvent.keyvalues in braces ({ArrowDown}) and[KeyA]-style codes in brackets. Holding a modifier ({Shift>}a{/Shift}) setsshiftKeyon the events but still types the key as written (a), so write the uppercase letter yourself when you need it.
Remember this
- 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 fireskeydown,keypress,beforeinput,inputandkeyup, and the field's value changes one character at a time. user-event reproduces these sequences and the browser's default behaviour. - 2
You start with **
const user = userEvent.setup()** at the top of each test, beforerender.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 alwaysawaitit:await user.click(button). Forgettingawaitmeans your assertion runs before the interaction finishes. - 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), anduser.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
user-event respects what real users can't do. It won't type more than
maxLengthallows. A click on a disabled button does nothing. An element withpointer-events: nonethrows 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 bugsfireEventmisses. - 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 singlechangeevent. No keydown, no focus, nomaxLength. It is still useful for events user-event doesn't cover (such asscroll, or a media element'sended), but for clicks, typing and keyboard work, prefer user-event. Testing Library's docs say the same. - 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'stoHaveFocus()) and keys like Escape is cheap here and catches real problems (Phase 18).
Explain it without notes
Why does Testing Library recommend user-event over fireEvent?
Why must you await user-event calls, and why call userEvent.setup()?
How do you test keyboard navigation and focus with user-event?
When would you still use fireEvent?
Practice
Write a test (live helper style) for a Newsletter component: typing an email and pressing Enter calls onSubscribe with the email.
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 andawaitevery 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.