When to write a visual test instead of a unit test

This page is part of the test scope definition section of the component testing fundamentals guide, which covers deciding which tier an assertion belongs to.

The specific problem: you want to prove a Card component looks right. You can write a unit test that queries its elements, or a visual regression snapshot that compares its pixels. Choosing wrong produces either a test that cannot see the bug or a snapshot that fails constantly.

Problem statement

The two tiers observe different things. A unit test reads the DOM: elements, roles, attributes, text. A visual test reads the rendered raster: colour, spacing, typography, layout. Neither can see what the other sees, and treating them as interchangeable produces predictable disappointments.

The symptom of choosing a unit test when the concern was visual is a green suite and a broken UI. The symptom of choosing a visual test when the concern was structural is a snapshot that diffs on every unrelated CSS tweak while an actual missing label goes unnoticed.

Root cause

CSS does not appear in the DOM in a form an assertion can read. toHaveClass('btn--primary') proves a class name is present, not that it resolves to anything — a deleted rule, a specificity change, or a renamed token all leave the class intact and the component broken.

Conversely, a screenshot contains no semantics. A button that lost its aria-label looks identical, so a pixel comparison reports no change while a screen-reader user loses the control entirely.

Seven changes and which tier catches each Seven rows. Removing a conditional render is caught by both tiers. Deleting an aria-label and breaking an event handler are caught only by a unit test. Changing padding, changing a brand colour, removing a focus ring and text wrapping to a second line are caught only by a visual test. Only the first row overlaps, which is why neither tier substitutes for the other. The change Unit test sees it Visual test sees it Conditional render removed yes yes aria-label deleted yes no Handler stops firing yes no Padding 8px to 12px no yes Brand colour changed no yes Focus ring removed no yes Text wraps to two lines no yes

Minimal reproduction

// the unit test passes, and the component is visibly broken
it('renders a primary button', () => {
  render(<Button variant="primary">Save</Button>);
  expect(screen.getByRole('button')).toHaveClass('btn--primary');
});
/* somebody deletes the rule; the class is still applied */
- .btn--primary { background: #2563eb; color: #fff; padding: 8px 16px; }

The assertion still passes. The button now renders as unstyled text, and nothing in the suite notices.

Step-by-step fix

1. Ask what the regression would look like

Write down the failure you are trying to prevent, in one sentence, before choosing a tier. “The submit button stops being visible against the card background” is a visual concern. “The submit button stops calling onSubmit” is a behavioural one. Pages of test strategy usually collapse into this single question.

2. Route structural and behavioural claims to a unit test

it('submits once per click', async () => {
  const onSubmit = vi.fn();
  render(<Button onClick={onSubmit}>Save</Button>);

  await userEvent.click(screen.getByRole('button', { name: 'Save' }));

  expect(onSubmit).toHaveBeenCalledTimes(1);
});

What this does: asserts something a screenshot cannot see, in forty milliseconds, with a failure that names the behaviour.

3. Route appearance to a visual snapshot on a story

// Button.stories.ts — one story per visual state
export const Primary: Story = { args: { variant: 'primary', children: 'Save' } };
export const PrimaryDisabled: Story = { args: { variant: 'primary', disabled: true, children: 'Save' } };
// the pixel assertion lives with the visual tier, not in the unit suite
await expect(page.locator('#storybook-root')).toHaveScreenshot('button-primary.png', {
  maxDiffPixelRatio: 0.001,
});

What this does: puts the claim about appearance where a change in appearance will actually be detected.

4. Do not assert style properties in a unit test

// asserts the inline style, which is not where the styling lives
expect(screen.getByRole('button')).toHaveStyle({ backgroundColor: '#2563eb' });

jsdom does not apply stylesheets, so this only passes for inline styles — and hard-codes a colour that a token change is supposed to be allowed to move. The visual tier owns this claim.

5. Keep the tiers from testing each other’s concerns

A visual snapshot of a component’s error state is valuable. A visual snapshot asserting that clicking submit calls a handler is not — nothing in the image changes. Conversely a unit test asserting a class name is a weaker version of a visual test. Removing these duplicates usually shrinks both suites.

Verification

Routing by what the regression would look like A decision node asking what a regression would look like. A wrong element or wrong text is a unit test with a query and an assertion. Wrong behaviour on input is a unit test using userEvent. Wrong appearance is a visual test, where a pixel diff sees it. Something wrong at only one breakpoint is a visual test captured at that width. Something wrong for a screen reader is an accessibility test using axe rather than pixels. What would a regression here look like? Unit test query and assert wrong element or text Unit test userEvent plus assertion wrong behaviour on input Visual test a pixel diff sees it wrong appearance Visual test capture at that width wrong at one breakpoint Accessibility test axe, not pixels wrong for a screen reader

The check that the split is right is a deliberate break. Delete a CSS rule and the visual tier should fail while the unit tier stays green. Remove an aria-label and the unit tier should fail while the visual tier stays green. If either break is invisible to both, there is a gap; if both fail on both, there is duplication.

Edge cases and caveats

What each tier costs per test Horizontal bars of per-test cost. A unit test takes about 40 milliseconds and can run on every save. A local visual test takes 2.1 seconds and suits running on push. The same test across three engines takes 6.3 seconds and suits pull requests. A cloud visual review costs about 9 seconds plus human review time. Cost per test, and the cadence it can support Unit test 40 ms · runs on save Visual test (local) 2.1 s · runs on push Visual test (3 engines) 6.3 s · runs on PR Cloud visual review 9 s + review time
  • Conditional rendering that is also visual. A component that hides a control below a breakpoint is both: assert the DOM change in a unit test at that viewport, and capture the layout in a visual test. The two failures mean different things and both are worth having.

  • Design tokens. A token rename is a visual change even though no component source moved, which makes primitives the highest-value visual snapshots in any design system — they are where a one-line token edit propagates furthest.

  • Accessibility is a third tier. Neither pixels nor DOM queries reliably catch contrast or naming failures. An axe run in a play function covers what both miss.

FAQ

Can a visual test replace unit tests for a presentational component?

Not entirely, even for a component with no logic. A screenshot proves the component looks right; it says nothing about whether its button has an accessible name, whether its onClick fires, or whether it renders a button rather than a div. For a purely presentational component the unit test can be small — often a single accessibility-flavoured assertion — but zero is a gap.

How many visual tests does one component need?

One per visual state that a regression could plausibly break independently: each variant, each size, and the states that change rendering such as disabled, loading, or error. Combinations only earn a snapshot when the axes genuinely interact. Everything about behaviour stays in the unit tier, where it is two orders of magnitude cheaper to run.

What about toHaveStyle in a real-browser test runner?

In Vitest browser mode or a Playwright component test the stylesheet is applied, so computed styles are real and toHaveStyle means something. It is still usually the wrong assertion: it hard-codes a value that a token change should be free to move, whereas a snapshot compares the whole rendered result and lets the reviewer decide whether the change was intended.