Section 3 · Testing the UI
Simulating User Interaction
18 min3 min readCode playground
Lesson at a glance
Drive forms and buttons in tests the way a person does, choose user-event over fireEvent, and assert on what the user sees and what the code reports.
- 1fireEvent versus user-event
- 2Testing the expense form
- 3The unhappy path is most of the work
- 4In the playground
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The expense form is where Splitwise-lite meets real people, and real people do messy things: they tab between fields, paste " 45.50 ", click “Add” with the amount still empty, double-click. A unit test of parseAmount can’t tell you whether the form wires all of that up correctly. A test that uses the form like a person can.
fireEvent versus user-event
Testing Library ships two ways to interact.
fireEvent (from @testing-library/dom) dispatches exactly one DOM event. fireEvent.click(button) sends a click. fireEvent.change(input, { target: { value: '45.50' } }) sets the value and sends a change. It’s fast and precise, and it’s not what happens when a person clicks or types.
user-event (from @testing-library/user-event) simulates the interaction. user.click(button) produces pointer and mouse events, moves focus, then fires click, and refuses to click a disabled button or one hidden under pointer-events: none, like a real browser. user.type(input, '45.50') clicks the field, then for each character sends keydown, keypress, input and keyup.
Default to user-event. Reach for fireEvent only for events user-event doesn’t model, such as a custom event or scroll.
Testing the expense form
The form has two labelled fields, an “Add expense” button and an alert area. Here is the happy path, written the way you’d describe it to a colleague:
// @vitest-environment jsdom
import { afterEach, test, expect, vi } from 'vitest';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { mountExpenseForm } from './expense-form.js';
afterEach(() => {
document.body.innerHTML = '';
});
test('adds a valid expense in cents and clears the form', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
mountExpenseForm(document.body, onAdd);
await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Dinner');
await user.type(screen.getByRole('textbox', { name: 'Amount' }), '45.50');
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(onAdd).toHaveBeenCalledWith({ description: 'Dinner', amountCents: 4550 });
expect(screen.getByRole('textbox', { name: 'Amount' }).value).toBe('');
});Three things to notice. userEvent.setup() comes first and gives you a user session that tracks keyboard and pointer state across calls. Every interaction is awaited: user-event methods are asynchronous, and without await the assertions run before the typing has finished. And the assertions are about outcomes: what onAdd received (the contract with the rest of the app) and what the user now sees.
Keyboard users matter too. await user.tab() moves focus like the Tab key, and await user.keyboard('{Enter}') presses Enter, so you can test that the form works without a mouse.
What user-event won’t do for you
user-event simulates the browser’s default behaviour for the interaction, but it can’t see your CSS layout. It won’t notice that a modal covers the button visually, or that the “Add expense” button is scrolled off-screen on a phone. jsdom has no layout engine, so sizes and positions are all zero. Those are jobs for end-to-end tests in a real browser, which is where section 4 goes. In unit and component tests, user-event gives you the right event sequence and focus behaviour, which covers the large majority of form bugs.
The unhappy path is most of the work
Most form bugs live in the error handling, so give it at least as many tests. Typing abc as the amount and clicking “Add expense” should:
- show the reason in an element with
role="alert", which screen readers announce; - not call
onAdd; - keep what the user typed, so they can fix one character instead of retyping.
test('explains an invalid amount and keeps the input', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
mountExpenseForm(document.body, onAdd);
await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Taxi');
await user.type(screen.getByRole('textbox', { name: 'Amount' }), 'abc');
await user.click(screen.getByRole('button', { name: 'Add expense' }));
expect(screen.getByRole('alert').textContent).toMatch('Not a valid amount');
expect(onAdd).not.toHaveBeenCalled();
expect(screen.getByRole('textbox', { name: 'Amount' }).value).toBe('abc');
});Each assertion guards a different bug. Drop the alert check and a silent failure passes. Drop the not.toHaveBeenCalled and a form that saves garbage passes. Drop the last one and a form that wipes the user’s input passes.
In the playground
There is no user-event package in the sandbox, so the exercise starter includes two small helpers: typeInto(element, text), which focuses the field and sends key and input events for each character, and clickOn(element), which sends the pointer and mouse sequence, focuses and clicks (and does nothing on a disabled button). They are synchronous, so you don’t await them. The sandbox also blocks real form submission, so the widget listens for the button’s click instead of a submit event; in your app, a real <form> with a submit handler is better, because it gives you Enter-to-submit for free.
Next, the hardest part of UI testing: things that happen later.
Key takeaways
fireEventdispatches one DOM event;user-eventplays the whole sequence a real person causes (pointer, focus, keydown, input, keyup, click).- Create a session with
userEvent.setup()andawaitevery interaction, because user-event methods are asynchronous. - Test a form through its outcomes: the callback’s arguments, the error a user would read, and the state of the fields afterwards.
- Test the invalid path as carefully as the valid one; “nothing happened” is a behaviour to assert.