The failure mode
A Storybook interaction test for ThemeToggle asserted that the active button carried a specific background class:
await expect(lightButton).toHaveClass('bg-surface');It passed, and then I updated the design tokens and bg-surface became bg-surface-primary. The component still worked perfectly, but the test failed, because the assertion was checking an implementation detail rather than the behavior anyone actually interacts with.
This pattern was scattered all over the component library: toHaveClass('opacity-0') on hidden tooltips, toHaveClass('bg-surface') on active themes, .tagName checks on breadcrumb items. Every Tailwind refactor risked breaking tests that had nothing to do with the change.
Six rules
So I wrote down six rules for interaction tests and applied them across the library in one pass. They all target the same gap: the space between what a test checks and what a user actually experiences.
1. Query by role first
Reach for getByRole('button', { name: 'Label' }) over getByText('Label') on interactive elements; getByText is for non-interactive content like headings, paragraphs, and empty-state messages. The distinction matters because a role query verifies the element is interactive, not just visible.
2. Always waitFor after state changes
Any userEvent that triggers React state needs a waitFor on the assertions that follow it, or the test reads stale DOM. This is the single most common source of CI flakes in Storybook interaction tests.
await userEvent.click(button);
// Wrong: assertion may read pre-click DOM
expect(menu).toBeInTheDocument();
// Right: wait for React to flush
await waitFor(() => expect(menu).toBeInTheDocument());3. Never assert CSS classes
This is the rule that kicked off the whole audit. The fix is ARIA attributes: aria-hidden, aria-pressed, aria-expanded, aria-selected. If the component doesn't expose a semantic attribute for the state you're testing, fix the component first.
4. No .tagName checks
Instead of expect(el.tagName).toBe('SPAN'), assert the absence of a role:
// Before
const currentPage = canvas.getByText('Current Page');
await expect(currentPage.tagName).toBe('SPAN');
// After
await expect(
canvas.queryByRole('link', { name: 'Current Page' })
).not.toBeInTheDocument();Now the test verifies that the current page isn't a link, which is the actual requirement; whether it renders as a <span> or a <div> is beside the point.
5. Use step() for three or more sequential interactions
Group the related phases so the Storybook Interactions panel documents itself:
await step('Open menu', async () => {
await userEvent.click(trigger);
await waitFor(() => expect(menu).toBeInTheDocument());
});
await step('Navigate items', async () => {
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => expect(items[0]).toHaveFocus());
});
await step('Close menu', async () => {
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(menu).not.toBeInTheDocument());
});6. Every fn() spy must be asserted
If a story defines onClick: fn() in its args, the play function has to assert it. A dead spy is just noise: it implies the test verifies click behavior when it doesn't. So either assert the spy or delete it.
Before and after
The ThemeToggle fix needed a one-line change to production code. The component didn't expose aria-pressed, so there was nothing semantic to assert in the first place; adding it fixed both the test and the accessibility in one go:
// ThemeToggle.tsx — added aria-pressed
<button aria-pressed={theme === value} onClick={() => setTheme(value)}>
// ThemeToggle.stories.tsx — before
await expect(lightButton).toHaveClass('bg-surface');
// ThemeToggle.stories.tsx — after
await waitFor(() =>
expect(lightButton).toHaveAttribute('aria-pressed', 'true')
);The Tooltip followed the same pattern, where opacity-0 and opacity-100 became aria-hidden:
// Before
await expect(tooltip).toHaveClass('opacity-0');
await waitFor(() => expect(tooltip).toHaveClass('opacity-100'));
// After
await expect(tooltip).toHaveAttribute('aria-hidden', 'true');
await waitFor(() => expect(tooltip).toHaveAttribute('aria-hidden', 'false'));The result
One commit touched 11 files: +123 lines, -88 lines. Every CSS class assertion got swapped for a semantic equivalent, three components gained ARIA attributes they should have had from the start, and zero interaction tests now lean on Tailwind class names.
The tests are steadier now because they assert behavior instead of styling, and they turned into better accessibility tests almost by accident: if aria-pressed ever gets dropped from ThemeToggle, the interaction test catches it before a screen reader user does.
A test should verify the contract between a component and its users, and a CSS class is never part of that contract; an ARIA attribute is.
