skip to content

How does a Playwright component test prove that a toast's onUndo callback fired with the right payload?

level: middleimportance: must knowfreq 51%

answer

  1. Event happens where the test cannot see
  2. Story converts the event to DOM state
  3. Hidden input keyed by data-testid
  4. Serialise the payload for exact comparison
  5. Read it back with toHaveValue

basics

~20 s

The story wires the callback and writes what it received into a hidden input marked with a data-testid. The Playwright test drives the UI, then asserts on that input with toHaveValue, which retries until the recorded value appears.

solid answer

~40 s

The callback fires inside the browser, where the test has no reference to it, so the story turns the event into DOM state. The story export that `mount` renders owns the handler: `onUndo` appends what it received to story state, and the story renders a hidden `<input>` whose `value` is that state and whose `data-testid` names the callback. The test clicks Undo on the toast, then asserts `expect(component.getByTestId('undo-log')).toHaveValue('["row-42"]')`. Serialising with `JSON.stringify` gives structured payloads a stable string to compare, a second input holding the call count separates one invocation from two, and an initial empty string separates never fired from fired with nothing. Because `toHaveValue` retries until its timeout, no manual wait is needed after the click.

code

typescript · 18 lines
typescript
// toast.story.tsx
import { useState } from 'react';
import { Toast } from '../src/toast';

export function ToastWithUndo(props: { message: string; tone: 'success' | 'error' }) {
  const [log, setLog] = useState<string[]>([]);
  return (
    <>
      <Toast
        message={props.message}
        tone={props.tone}
        onUndo={(rowId: string) => setLog((prev) => [...prev, rowId])}
      />
      <input type="hidden" data-testid="undo-log" readOnly value={JSON.stringify(log)} />
      <input type="hidden" data-testid="undo-count" readOnly value={String(log.length)} />
    </>
  );
}

go deeper

for a junior

Learn the two halves: the story writes the callback payload into a hidden input, and the test reads that input by its test id.

for a middle

Be able to explain why a DOM recorder is needed at all, and what the serialised value plus a call count each prove about the callback.

for a senior

Talk about the failure modes you have hit: recorders that keep only the last call, duplicated test ids, and test scaffolding leaking into shipped components.

for a principal

Decide the convention for the package: one recorder helper, a naming scheme for test ids, and a rule about what evidence a component test is allowed to depend on.

## The callback fires where the test cannot watch it When a design-system toast invokes `onUndo('row-42')`, the call happens inside the browser page. The test file is a Node process holding a locator for the mounted root: it has no reference to the function, no way to observe the invocation, and no shared memory with the page. So the question "did the callback fire, and with what?" has to be answered the way every other Playwright question is answered - by observing something in the page. That is the whole idea behind the recorder. The story converts an invisible event into visible state, and the test reads that state. ## The recorder: turn an event into DOM state The story export owns the handler, so it can do whatever it likes with the payload. The convention that survives contact with a real suite is a hidden `<input>`: - an input's `value` is a property rather than layout, so the recorder never has to be visible or take up space; - `toHaveValue` retries until the timeout, so the assertion settles on its own once the story re-renders; - a single string is easy to compare exactly, which `JSON.stringify` gives you even for object payloads; - an initial `''` separates "never fired" from "fired with an empty payload"; - naming the input after the callback (`data-testid="undo-log"`) keeps the story self-documenting. ## What to record, and what it lets you assert | The question the test asks | What the story records | The assertion | |---|---|---| | Did it fire at all? | `String(log.length)` in a count input | `toHaveValue('1')` | | With what payload? | `JSON.stringify(payload)` | `toHaveValue('{"id":"row-42"}')` | | How many times? | the same count input | `toHaveValue('2')` | | In what order across callbacks? | appended entries in one log input | `toHaveValue('["open","undo"]')` | Recording a list rather than a single value is usually the better default. A story that keeps only the last payload cannot tell a test that the component fired twice, which is a real bug in a toast whose Undo button is not disabled after the first press. ## The recipe 1. Give the story one piece of state per callback you intend to observe. 2. Wire the component's callback prop to a handler that appends to that state. 3. Render a hidden input per recorder, with a stable `data-testid` and a serialised `value`. 4. In the test, act on the component, then assert with `toHaveValue`. ```ts await component.getByRole('button', { name: 'Undo' }).click(); await expect(component.getByTestId('undo-count')).toHaveValue('1'); await expect(component.getByTestId('undo-log')).toHaveValue('["row-42"]'); ``` The two assertions answer different questions, and keeping them separate makes failures readable: a count of `2` says the handler ran twice, while a log of `["row-41"]` says it ran once with the wrong row. ## Failure modes worth recognising - Asserting with a text matcher instead of `toHaveValue`; an input's value is not its text content, so the check can never pass. - Recording an object directly into `value`, which stringifies to `[object Object]` and makes every payload look identical. - Reusing one `data-testid` in two stories of the same package, so a copy-pasted assertion silently reads the wrong recorder. - Rendering the recorder with a `value` and no `readOnly`, which produces a controlled-input warning the suite then learns to ignore. - Recording only the last call and then claiming the test proves the callback fired exactly once. ## Keep the scaffolding out of the shipped component The recorder is test furniture. It belongs in the story file, never in the component the package publishes - a design-system toast should not ship a hidden input because a test wanted one. In a package with many stories, factor the pattern into one small helper that returns a `record` function plus the recorder element, so every story spells the convention the same way and the test ids stay predictable. The payoff is that callbacks stop being a special case. Props carry data in, the recorder carries evidence out, and the assertion surface is the same DOM-shaped one the rest of the suite already uses.

  • How would you prove the toast fires onUndo exactly once when the button is double-clicked?
    Record a list rather than a single value and expose its length in a second input. Double-click, then assert the count input has value `1`. A story that keeps only the last payload cannot distinguish one call from two, so the bug would pass unnoticed.
  • What breaks if the recorder input is styled hidden rather than typed hidden?
    Nothing for the assertion: `toHaveValue` reads the value property and does not require visibility. The practical argument for a hidden input type is that it cannot receive focus or appear in an accessibility snapshot, so it will not disturb role-based locators in the same story.
  • Why serialise the whole payload instead of recording individual fields?
    One serialised string keeps the recorder count fixed as the payload grows and makes the assertion an exact comparison. Individual fields multiply test ids, and each new field silently escapes the assertion until somebody remembers to add another input.

The story acts as a flight recorder. Nobody watches the flight from the tower, so the box is opened afterwards and read line by line.

saying these in an interview costs you the question

  • Asserting a recorder input with a text matcher instead of toHaveValue
  • Recording an object directly, so every payload reads as object Object
  • Keeping only the last payload and calling it proof of a single call
  • Adding recorder markup to the shipped component
  • Reusing one data-testid across several stories in a package