frontend / testing / 04_user_event_vs_fireevent.md

user-event vs fireEvent

5 min read source

user-event vs fireEvent

TL;DR

fireEvent dispatches a single DOM event synchronously. user-event simulates a full user interaction — typing a character fires keydown, keypress, input, keyup; focusing first, blurring on Tab, respecting disabled and aria-* state. Always prefer user-event — it catches real bugs (forgotten focus management, missed input events) that fireEvent lets through. Use fireEvent only when you need to test a specific event no user could realistically fire.

Interview Q&A

Q: One-line difference?

A: fireEvent.click(button) fires click. userEvent.click(button) fires pointerdownmousedownpointerupmouseupclick, in order, with the right bubbles/cancelable settings, after checking the element is visible and clickable. user-event mimics real browser sequencing; fireEvent is the low-level “dispatch this one event.”

Q: API shape — they differ.

A:

// fireEvent — synchronous, single event
fireEvent.click(button);
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Enter" });

// user-event — async, you setup() once per test
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");      // types each char with full event sequence
await user.keyboard("{Enter}");

user-event v14+ requires userEvent.setup() once (returns an instance with the methods); all interactions are async and must be awaited.

Q: A bug fireEvent misses.

A: A form with onChange validation:

function Form() {
  const [value, setValue] = useState("");
  const [error, setError] = useState("");
  return (
    <form onSubmit={(e) => { e.preventDefault(); setError(value.length < 3 ? "too short" : ""); }}>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <button type="submit">Submit</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

fireEvent.change with the target value works:

fireEvent.change(input, { target: { value: "hi" } });
// works — value updates

But if the component depends on a keydown handler (“submit on Enter”), fireEvent.change doesn’t dispatch keys — so a “submit on Enter while typing” test silently passes when the real behavior is broken. userEvent.type(input, "hi{Enter}") does dispatch the keys.

userEvent.click checks the element isn’t disabled or pointer-events: nonefireEvent.click will “click” a disabled button without error, hiding a bug.

Q: When use fireEvent?

A: Rarely:

  • Testing a specific event no user can realistically dispatch (scroll on a non-scrollable container, programmatic focus/blur for edge cases).
  • Performance — user-event is slower because it does more.
  • Legacy tests in a codebase where wholesale migration isn’t worth it.

For new code: default to user-event.

Q: Show me userEvent.setup + common interactions.

A:

import userEvent from "@testing-library/user-event";

it("fills and submits a form", async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.type(screen.getByLabelText(/email/i), "ada@example.com");
  await user.type(screen.getByLabelText(/password/i), "secret");
  await user.click(screen.getByRole("checkbox", { name: /remember me/i }));
  await user.selectOptions(screen.getByLabelText(/country/i), "us");
  await user.tab();                                  // focus next field
  await user.keyboard("{Enter}");                    // submit via keyboard

  expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});

Common methods:

  • user.click(el), user.dblClick(el), user.tripleClick(el)
  • user.type(el, "text") — types char-by-char; supports special {Enter}, {Backspace}, {Tab}, modifiers {Shift>}A{/Shift}
  • user.keyboard("{Enter}") — keys without a target
  • user.clear(el) — clear input
  • user.tab({ shift: false }) — focus next/previous
  • user.hover(el), user.unhover(el)
  • user.selectOptions(select, values)
  • user.upload(fileInput, file) — file inputs

Q: How do you test paste behavior?

A: user.paste("text") (no target — pastes into the currently focused element):

await user.click(input);
await user.paste("pasted content");

For clipboard-related code, you may need jsdom’s clipboard polyfill or happy-dom’s built-in support.

Q: How do you simulate keyboard shortcuts with modifiers?

A: Curly-brace syntax with > to hold, / to release:

await user.keyboard("{Control>}a{/Control}");        // Ctrl+A
await user.keyboard("{Meta>}{Shift>}P{/Shift}{/Meta}"); // Cmd+Shift+P

For testing global shortcut handlers, ensure focus is in the right place first (user.click(document.body) if needed).

Q: Async vs sync user-event?

A: Async (v14+) is the default and recommended:

const user = userEvent.setup();
await user.click(button);

The async version uses setTimeout(0) between events to mimic real browser timing — important for code that reads state in between events.

Legacy userEvent.click(button) (no setup) is synchronous; deprecated. Don’t write new tests with it.

Q: What’s userEvent.setup options for?

A: Tune the simulator:

const user = userEvent.setup({
  delay: null,                  // no delay between events (faster, less realistic)
  pointerEventsCheck: 0,        // skip pointer-events check (escape hatch)
  writeToClipboard: true,       // write to test clipboard
  advanceTimers: vi.advanceTimersByTime,  // integrate with fake timers
});

Most tests use defaults. advanceTimers is useful when you’ve called vi.useFakeTimers() — let user-event advance them automatically so debounce/throttle behave naturally.

Gotchas / edge cases

  • user.type doesn’t replace existing content — append. Use user.clear() first or {selectall} modifier.
  • disabled elementsuser-event skips clicks; fireEvent doesn’t.
  • fireEvent.change(input, { target: { value: ... } }) is the only way to set a value programmatically with fireEvent. With user-event, use user.type(input, "x") or user.clear() + user.type().
  • act warningsuser-event already wraps in act; usually no manual act needed. If you see warnings, your async code probably needs await-ing.
  • Slow tests with user.type — typing 50 chars one-at-a-time isn’t free. Use user.paste("long string") for bulk input.
  • Form submit by Enter — works in user-event when the form has a single text input or a submit button (HTML behavior). Multiple-input forms without a submit button: Enter does nothing, same as browsers.
  • onChange for React’s <input> — React’s synthetic onChange is actually the DOM input event. Both user.type and fireEvent.change work; user.type is more thorough.

What a senior is expected to say

  • user-event for almost everything — it simulates the full event sequence and respects disabled/aria-*. fireEvent is the low-level escape hatch.”
  • userEvent.setup() once per test, async API, await every interaction. v14+ pattern.”
  • user.type dispatches each key — keydown/keypress/input/keyup per char. Catches handlers that depend on the right event (‘submit on Enter’, ‘autocomplete on keyup’).”
  • fireEvent.click on a disabled button silently succeeds — user.click fails as a real browser would. The bug-catching difference.”
  • “Pair user-event’s advanceTimers with vi.useFakeTimers() so debounce/throttle don’t hang in tests.”

Cross-references

Further reading