A11y Testing + Visual Regression in CI
Two CI gates that prevent classes of regression code-review can’t catch:
- A11y testing with
axe-core— runs static analysis on rendered components (in unit tests) or full pages (in E2E) and flags violations: missing<label>, low contrast, invalid ARIA, focus traps, etc. Catches roughly 30-50% of WCAG issues automatically; the rest needs manual review. - Visual regression — captures screenshots and diffs against a baseline. Detects pixel-level changes (color, layout, font swap) that behavior tests miss.
Both are deltas, not absolute checks — they flag changes that need review. Used together with role-based RTL queries and Playwright E2E, they form a layered defense against the bug classes pure unit tests can’t see.
A11y
How does axe-core work?
A standalone JS engine that walks the DOM and checks against WCAG rules. Returns a list of violations with element selectors, rule IDs, severity, and remediation hints.
import { axe } from "vitest-axe";
import { render } from "@testing-library/react";
it("login form is accessible", async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});The matcher prints a readable list of violations on failure:
Expected the HTML to have no violations:
- color-contrast: Elements must have sufficient color contrast (selector: button.btn)
- label: Form elements must have labels (selector: input#search)What does axe catch and what does it miss?
Catches (~30-50% of WCAG issues):
- Missing
<label>on form inputs. - Insufficient color contrast (text vs background).
- Missing
alton<img>. - Invalid ARIA roles / attributes.
- Heading order violations (
<h1>→<h3>skipping<h2>). - Duplicate
idattributes. - Form controls not associated with labels.
Misses (need manual review):
- “This component is keyboard-navigable” —
axedoesn’t tab through. - “The screen-reader announcement is meaningful” —
axecan’t read context. - “Focus management on modal open/close is correct.”
- “Dynamic content is announced via
aria-live.” - Whether the layout makes sense visually for users with low vision.
The senior framing: axe is the floor, not the ceiling. Pair with periodic manual screen-reader testing (VoiceOver on macOS, NVDA on Windows) and keyboard-only navigation runs.
Per-component vs per-page a11y testing.
Per-component (in unit tests) — catches issues early, scoped to the component:
it("button is accessible", async () => {
const { container } = render(<Button>Save</Button>);
expect(await axe(container)).toHaveNoViolations();
});Per-page (in E2E) — catches integration issues (z-index conflicts, focus traps, real CSS contexts):
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("homepage has no a11y violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});Both have a place. Per-component catches issues at the unit; per-page catches integration bugs.
Storybook + a11y addon.
@storybook/addon-a11y runs axe against each story in the Storybook UI; violations appear in an “Accessibility” panel.
// stories/Button.stories.ts
export const Primary: Story = {
args: { children: "Save" },
parameters: {
a11y: {
config: {
rules: [{ id: "color-contrast", enabled: true }],
},
},
},
};For CI: @storybook/test-runner + axe runs every story through axe automatically. Catches a11y regressions per-state (default, hover, disabled, error).
Specific axe rules to know.
A senior should be able to name the common ones:
| Rule | What |
|---|---|
label |
form controls need accessible labels |
color-contrast |
text contrast against background ≥ 4.5:1 (3:1 for large text) |
image-alt |
<img> needs alt (use alt="" for decorative) |
button-name |
<button> needs accessible name (text, aria-label) |
link-name |
<a> needs accessible name |
heading-order |
headings don’t skip levels |
landmark-one-main |
exactly one <main> |
region |
content lives in a landmark (<main>, <nav>, <aside>) |
aria-valid-attr-value |
ARIA attribute values are valid |
duplicate-id-aria |
no duplicate id for aria-* references |
When to disable an axe rule.
Only with documented reason. The disable should be at the most specific level (single test, single component) and reviewed periodically:
const results = await axe(container, {
rules: {
// brand colors approved separately
"color-contrast": { enabled: false },
},
});Blanket disables (project-wide) are a smell — usually means the team gave up. Either fix or accept and document.
Visual Regression
What is visual regression testing?
Capture a screenshot of a component/page. Save as “baseline.” On subsequent runs, capture again, pixel-diff against the baseline. Flag any differences for human review.
// Playwright
await expect(page).toHaveScreenshot("homepage.png");First run: creates baseline. Subsequent runs: compare, fail on diff (with a threshold).
Tooling options.
| Notes | |
|---|---|
| Chromatic | Storybook-native, full UI for review/approval, paid service, free for OSS |
| Percy | similar, BrowserStack/Sauce ecosystem |
Playwright toHaveScreenshot |
built-in, free, file-based diffs, basic review (PR comments) |
| Reg-suit | self-hosted, configurable |
| Loki | Storybook-focused, free, headless Chrome |
For Storybook-centric teams: Chromatic is the smoothest UX. For Playwright E2E coverage: built-in screenshots are free. Mix as needed.
What catches visual regression that other tests miss?
- CSS regressions — refactored Tailwind utility caused a color change.
- Font swap layout shift — font loaded with different metrics.
- Layout breaks at viewport — flex/grid bug at mobile/tablet.
- Z-index bugs — modal disappearing behind a sticky header.
- Theme regressions — dark mode broken on one component.
- Browser-specific rendering — Safari blurring a transform.
Behavior tests pass; visual regression catches. Worth it especially when shipping a design system to many consumers.
Per-state visual regression with Storybook.
Each Storybook story is a screenshot target. Chromatic / Loki / @storybook/test-runner snap every story:
// Button.stories.ts
export const Default: Story = { args: { children: "Save" } };
export const Disabled: Story = { args: { children: "Save", disabled: true } };
export const Loading: Story = { args: { children: "Save", loading: true } };
export const Long: Story = { args: { children: "Save a really long button label" } };Every state visually verified per PR. Refactors that affect appearance fail loudly.
Multi-viewport visual regression.
Playwright lets you screenshot at multiple viewports per test:
test.describe("homepage", () => {
for (const viewport of [
{ width: 375, height: 667, name: "mobile" },
{ width: 768, height: 1024, name: "tablet" },
{ width: 1440, height: 900, name: "desktop" },
]) {
test(`renders ${viewport.name}`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/");
await expect(page).toHaveScreenshot(`home-${viewport.name}.png`);
});
}
});Catches responsive bugs that a single-viewport test misses.
Handling dynamic content in screenshots.
- Mask animations / dates / random IDs with
mask: [locator]in Playwright. - Mock time (
vi.setSystemTime) before rendering so dates are stable. - Disable animations in the test config:
await page.addStyleTag({ content: `*, ::before, ::after { animation: none !important; transition: none !important; }` }); - Wait for fonts to load before screenshot (
document.fonts.ready).
Without these, every run produces a slightly different screenshot and you live in --update-snapshots hell.
How do you review visual diffs?
- Chromatic / Percy: side-by-side, baseline + new + diff, approve/reject per change.
- Playwright: diff image generated as artifact; review in PR. Less ergonomic but free.
- Storybook test-runner: similar to Playwright artifacts.
Don’t review by glancing at “diff %” — actually look at the image. A 2% diff that moves a price by 10 pixels is a real bug.
Gotchas
- Flaky screenshots — fonts loading, animations, timing. Disable animations + wait for fonts.
- OS / browser font rendering differs — visual regression often pinned to a single OS in CI (Docker container).
axeviolations on third-party components — file with the vendor; can’t always fix yourself. Document the exception.- Chromatic’s “TurboSnap” mode only screenshots components affected by a PR — major speedup for large libraries.
- A11y testing skips disabled/hidden content —
axemay underreport. Render the “active” state in the test. - Manual a11y testing isn’t replaced — keyboard nav, screen reader announcements, color blindness simulation. Quarterly audits.
Related
Further reading
axe-corerules: https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.mdvitest-axe: https://github.com/chaance/vitest-axe@axe-core/playwright: https://playwright.dev/docs/accessibility-testing- Chromatic: https://www.chromatic.com/
- WCAG 2.2 guidelines: https://www.w3.org/WAI/standards-guidelines/wcag/
Interview angle 5
- “How much accessibility can you automate?” - roughly a third to a half of
WCAG issues, which is what
axe-coredetects statically: missing labels, contrast failures, invalid ARIA, structural problems. It is the floor, not the ceiling - focus order, meaningful announcements and whether the flow actually works by keyboard need a person. - “Where do the checks run?” - per component in unit tests, per page in E2E, and per component state via the Storybook addon. The state coverage matters because most violations live in an open menu or an error state rather than the default render.
- “What does visual regression catch that behaviour tests cannot?” - CSS regressions, layout breaking at a viewport, theme bugs, and browser-specific rendering. A behaviour test passes happily while the button is invisible.
- “Why are visual tests flaky, and how do you fix it?” - non-determinism. Disable animations and transitions, freeze time, and wait for fonts to load before capturing - a font swapping in after the screenshot is the classic intermittent diff.
- “Someone disabled an axe rule. Is that acceptable?” - a single documented disable with a reason, sometimes. A project-wide disable is a smell: it turns a category of real defects invisible, and nobody revisits it.