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.
What they check
Section titled “What they check”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 |
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.
Why this level is cheap
Section titled “Why this level is cheap”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 boundary with journey tests
Section titled “The boundary with journey tests”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.
What it cannot tell you
Section titled “What it cannot tell you”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.
Where it connects
Section titled “Where it connects”- Component library is the contract these tests check.
- Storybook is where the stories they run against live.
- Accessibility tests run over the same stories, in the same pass.
- Journey tests are the level above, and the one that catches what composition breaks.