Skip to content

Interaction tests

An interaction test exercises one component in one declared state and asserts that it behaves as its contract says.

It runs against a story, which is the whole trick: the state is already named, already reproducible, and already the thing a designer reviews. The test does not construct a scenario; it picks one that exists.

The clauses of the component contract, mechanically:

Clause Asserted as
Props The declared variants render; an unsupported combination fails at the type level rather than at runtime
States Each state’s story renders and behaves — disabled does not fire, loading does not double-submit
Accessible behaviour Role and name are correct, the keyboard model works, changes are announced
Tokens consumed No raw values — enforced by lint rather than by assertion, but it belongs to the same contract
src/components/VoucherField/VoucherField.stories.ts
export const Refused: Story = {
args: { value: 'SPRING20', error: 'That voucher expired on 4 April' },
play: async ({ canvas, userEvent }) => {
const field = canvas.getByRole('textbox', { name: 'Voucher code' });
// The error is associated with the field, not merely next to it.
await expect(field).toHaveAccessibleDescription(/expired on 4 April/);
await expect(field).toHaveAttribute('aria-invalid', 'true');
// Typing clears the error, so the person is not corrected twice.
await userEvent.type(field, 'X');
await expect(field).not.toHaveAttribute('aria-invalid', 'true');
},
};

Note what is being asserted: association and announcement, not colour and position. The red text is the design system’s job and is decided once; whether a screen reader user ever learns the voucher was refused is this component’s promise, and it is invisible in a screenshot.

Three properties, and together they are why there is no reason to be sparing here:

The failure has an address. It names a component and a state. Nobody has to bisect a page to find it.

It is fast. No navigation, no data, no session — one component, mounted.

It is written once and pays forever. A component’s contract changes rarely; the screens that use it change constantly, and none of that churn touches this test.

The line is composition.

Question Level
Does the field announce its error? Interaction
Does submitting with an invalid voucher leave the basket unchanged? Interaction, if the component owns it; otherwise journey
Does the person, having been refused, find their way to a valid voucher? Journey
Does the modal trap focus? Interaction
Does focus return sensibly to the page that opened it? Journey

The pattern: a component test can only see what the component can see. Anything that depends on where the user came from, what they did before, or where they go next belongs one level up.

Whether this is the right component for this place. A perfectly-behaved date picker used where a plain text field belonged passes every interaction test it has.

That question is answered in design critique and, eventually, by watching people use the thing.