Lesson 76 of 80
Clicking a button in a test isn't the same as a real click. user-event simulates the whole sequence a browser actually produces — which is what makes tests of typing, tabbing, and validation trustworthy.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
This track already covers querying rendered output the way a user would find things — by role, label, or text. That's about finding elements and asserting on what's on screen. This lesson is about the other half: doing things to them realistically.
@testing-library/user-event simulates a full, real interaction — not a single synthetic event, but the actual sequence a browser produces. Typing one character fires pointerdown, focus, keydown, input, and keyup, in that order, for every single character. That matters whenever a component reacts to the interaction as it happens — a controlled input validating as-you-type, a dropdown that opens on focus, a form that disables its submit button until the input looks valid.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("typing an invalid email disables submit until fixed", async () => {
const user = userEvent.setup();
render(<SignupForm />);
const emailInput = screen.getByLabelText(/email/i);
const submitBtn = screen.getByRole("button", { name: /sign up/i });
await user.type(emailInput, "not-an-email");
expect(submitBtn).toBeDisabled();
await user.clear(emailInput);
await user.type(emailInput, "real@example.com");
expect(submitBtn).toBeEnabled();
});
user.type calls the input's onChange once per character, exactly like a real keyboard would — so if SignupForm validates on every keystroke, this test actually exercises that path. Compare that to fireEvent.change(emailInput, { target: { value: "real@example.com" } }), which sets the whole value in one synthetic event and calls onChange exactly once. That's fine for asserting a final result, but it can't catch a bug that only shows up mid-typing, like a validator that throws on a partial string.
userEvent.setup() belongs inside the test, not at module scope. It tracks per-session pointer and keyboard state (what's currently pressed, focused), so sharing one instance across tests can leak state between them.user.click, matching real browser behavior — clicking a disabled button fires nothing and the handler never runs. fireEvent.click, by contrast, dispatches the event directly and can still invoke a handler on a disabled element in some setups, which is a real gap between what it tests and what a user could actually do.testing-react-components's querying rules — getByRole, getByLabelText, and friends are still how you find the element. user-event only changes how you act on what you found, by making the interaction itself realistic.This lesson assumes the query-by-role approach from this track's testing-components lesson and adds the missing half: once you've found the element the way a user would, act on it the way a user would too — through a full, realistic event sequence rather than one synthetic shortcut. The controlled-input behavior it's exercising here is the same as this track's controlled-versus-uncontrolled-inputs lesson describes: a value fully driven by state, updated on every change event.