Frontend / Testing / 10_a11y_and_visual_regression.md

A11y Testing + Visual Regression in CI

Updated 5 interview angles 7 min read source
On this page6
  1. A11y
  2. Visual Regression
  3. Gotchas
  4. Related
  5. Further reading
  6. Interview angle

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.

ts
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:

text
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 alt on <img>.
  • Invalid ARIA roles / attributes.
  • Heading order violations (<h1> → <h3> skipping <h2>).
  • Duplicate id attributes.
  • Form controls not associated with labels.

Misses (need manual review):

  • “This component is keyboard-navigable” — axe doesn’t tab through.
  • “The screen-reader announcement is meaningful” — axe can’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:

ts
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):

ts
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.

ts
// 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:

ts
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.

ts
// 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:

ts
// 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:

ts
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:
    ts
    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).
  • axe violations 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 — axe may underreport. Render the “active” state in the test.
  • Manual a11y testing isn’t replaced — keyboard nav, screen reader announcements, color blindness simulation. Quarterly audits.

Further reading

Interview angle 5

  • “How much accessibility can you automate?” - roughly a third to a half of WCAG issues, which is what axe-core detects 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.