EduFlow
Testing JavaScript Apps
0%

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.

  1. 1fireEvent versus user-event
  2. 2Testing the expense form
  3. 3The unhappy path is most of the work
  4. 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.

fireEvent.click sends only a click event. user.click sends pointerdown, mousedown, focus, pointerup, mouseup and then click. fireEvent.click click user.click pointerdown mousedown focus pointerup mouseup click handlers on any of these events run, as in a browser
One event versus the sequence a person causes

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:

src/expense-form.test.js
// @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:

  1. show the reason in an element with role="alert", which screen readers announce;
  2. not call onAdd;
  3. keep what the user typed, so they can fix one character instead of retyping.
JavaScript
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

  • fireEvent dispatches one DOM event; user-event plays the whole sequence a real person causes (pointer, focus, keydown, input, keyup, click).
  • Create a session with userEvent.setup() and await every 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.

Further reading

Check your understanding

3 questions

Question 1 of 3

The amount field formats input on each keydown. A test uses fireEvent.change(input, { target: { value: '45.50' } }), and the formatting code never runs. Why?

Question 2 of 3

What’s wrong with this test?

JavaScript
test('adds the expense', () => {
  const user = userEvent.setup();
  mountExpenseForm(document.body, onAdd);
  user.type(screen.getByRole('textbox', { name: 'Amount' }), '12');
  user.click(screen.getByRole('button', { name: 'Add expense' }));
  expect(onAdd).toHaveBeenCalled();
});
Question 3 of 3

Typing 'abc' in the amount field and clicking “Add expense” should show an error. Which set of assertions tests that path best?

Practice

Code playground

Use the expense form like a person

mountExpenseForm(container, onAdd) renders fields labelled Description and Amount and an Add expense button. A valid click calls onAdd({ description, amountCents }) and clears the fields; an invalid one shows the reason in role="alert", skips onAdd and keeps the typed text.

The starter includes the query stand-ins from the last exercise plus typeInto(el, text) and clickOn(el), which mimic user-event (they’re synchronous: no await). Add tests until your suite catches four hidden bugs: one calls onAdd even when the amount is invalid, one doesn’t clear the fields, one passes the amount in dollars instead of cents, and one never shows the alert.

function parseAmount(input) {
  const text = String(input).trim();
  if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error(`Not a valid amount: "${input}"`);
  const [whole, fraction = ''] = text.split('.');
  const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
  if (cents === 0) throw new Error('Amount must be greater than zero');
  return cents;
}

// Mounts the "Add expense" form into container.
// - Text fields labelled "Description" and "Amount", and an
//   "Add expense" button.
// - On click, if valid: calls onAdd({ description, amountCents }) with the
//   trimmed description and the amount in integer cents, hides any error,
//   then clears both fields.
// - If invalid: shows the reason in the element with role="alert",
//   does NOT call onAdd, and keeps what the user typed.
//   Blank description -> "Add a description"; bad amount -> parseAmount's message.
// (The playground blocks real form submission, so this reacts to the click.)
function mountExpenseForm(container, onAdd) {
  container.innerHTML = `
    <div>
      <label>Description <input name="description"></label>
      <label>Amount <input name="amount" inputmode="decimal"></label>
      <button type="button">Add expense</button>
      <p role="alert" hidden></p>
    </div>`;
  const [description, amount] = container.querySelectorAll('input');
  const alert = container.querySelector('[role="alert"]');
  container.querySelector('button').addEventListener('click', () => {
    let amountCents;
    try {
      if (!description.value.trim()) throw new Error('Add a description');
      amountCents = parseAmount(amount.value);
    } catch (error) {
      alert.textContent = error.message;
      alert.hidden = false;
      return;
    }
    alert.hidden = true;
    alert.textContent = '';
    onAdd({ description: description.value.trim(), amountCents });
    description.value = '';
    amount.value = '';
  });
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real form
  • Catches the version that calls onAdd with an invalid amount
  • Catches the version that doesn’t clear the fields
  • Catches the version that sends dollars instead of cents
  • Catches the version that never shows the alert