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.
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
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
-
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.
Related
- Test Scope Definition — the parent section on which tier a given assertion belongs to
- Unit vs Integration Test Boundaries for UI Components — classifying a test by the boundaries it crosses
- Visual Regression & Snapshot Strategies — the guide covering the pixel tier this page routes work into
- Tool Comparisons — choosing the tool once you know a visual test is the right answer