Introducing @accesslint/jest: progressive accessibility testing for Jest

This post is for the team rolling out accessibility testing: developer tooling, CI platform, design systems, frontend tooling, or their engineering managers. Enabling accessibility tests on greenfield code is straightforward. On an existing codebase it’s harder: the suites typically contain hundreds or thousands of known violations no team has prioritized.

Teams have typically tried four approaches and watched each erode. Gating on zero turns CI red across the suite. A suppressions file decays as reviewers add entries faster than they retire them. Per-test opt-in grows coverage slowly and leaves untouched components unchecked. Audit-once-and-ticket produces a backlog that drifts from reality. The common thread: none of them separate “the backlog that already exists” from “new regressions.”

@accesslint/jest addresses this rollout problem with two features: a snapshot-baseline workflow that gates CI on new violations rather than total count, and a trend-report sidecar that produces the evidence leadership typically asks for to justify continued accessibility investment. It adds a toBeAccessible() matcher on @accesslint/core, an independent WCAG 2.2 engine validated against the W3C ACT-R corpus. The rest of this post covers the rollout path, the reporting layer, the migration from jest-axe, and where the two libraries overlap.

For the migration command:

npx @accesslint/codemod jest-axe 'src/**/*.test.{ts,tsx}'

Snapshot baselines with auto-ratchet

Snapshot baselines address that gap directly. toBeAccessible({ snapshot: "name" }) locks the current violations as a baseline and fails only on new ones. When violations get fixed, the baseline shrinks automatically.

test("login form has no new a11y violations", () => {
  const { container } = render(<LoginForm />);
  expect(container).toBeAccessible({ snapshot: "login-form" });
});

On the first run, the matcher creates accessibility-snapshots/login-form.json and passes, capturing whatever violations exist today. Commit that file alongside your tests. On later runs:

  • New violations appear, the assertion fails with the diff (only the new ones).
  • Some existing violations get fixed and no new ones appear, the matcher trims them from the baseline. You commit the smaller snapshot file and move on.
  • Force a full refresh by running with ACCESSLINT_UPDATE=1 (for intentional scope changes).

jest-axe doesn’t offer this workflow directly. Adopting it on an existing codebase usually means either gating all tests on zero violations or writing per-test suppressions.

A rollout path

For a platform-team rollout across several repos or teams:

  1. Pilot in one repo with a known backlog. Wrap a handful of existing tests with toBeAccessible({ snapshot: "..." }). Run once to generate baselines, commit them with the tests. Verify the workflow end to end: CI stays green on the first push, a regression PR fails cleanly, a fix PR produces a baseline diff reviewers can read.
  2. Publish the matcher as a shared preset. Add @accesslint/jest to the setupFilesAfterEnv of your org’s internal Jest preset (or the shared test-config package your teams consume). Teams pick it up by upgrading the preset, not by hand-editing each repo.
  3. Let teams opt in at their pace, with the preset on by default. Each repo that adopts the preset generates its own accessibility-snapshots/ directory on first run. CI passes. The platform team isn’t responsible for each repo’s a11y debt; the matcher reports rather than enforcing.
  4. Track the trendline automatically. The matcher writes a history sidecar next to each snapshot, and @accesslint/report aggregates those into trend data for leadership. See the next section for details.
  5. Raise the gate per test, not per project. When a baseline reaches zero for a given test, the owning team drops the snapshot option. That test becomes a strict gate. The transition from “managed backlog” to “strict gate” is incremental and self-directed by the owning team, and you as the platform team don’t have to orchestrate a flag day.

The practical effect from the platform side: adoption doesn’t block on a cleanup sprint, and rollback is cheap (remove from the preset). The cost is the baseline JSON files committed per repo, which stay inside the repos that own them.

Evidence that the initiative is working

Accessibility initiatives tend to die between budget cycles. Leadership approves the work in Q1, developers fix violations through Q2, and by Q3 someone asks “where did the number go?” Without a quantitative trend, the answer is usually a screenshot of last quarter’s number. Which works for one cycle.

@accesslint/jest emits a .history.ndjson sidecar next to every snapshot. Each line records a snapshot event (created, ratchet-down, or force-update):

{"ts":"2026-04-18T10:42:03Z","name":"login-form","event":"ratchet-down","added":0,"removed":3,"total":12,"addedRules":[],"removedRules":["text-alternatives/img-alt","labels-and-names/form-label","distinguishable/color-contrast"]}

The sidecar is append-only, committed to git alongside the snapshot JSON, and written automatically. Test authors don’t need to be aware of it.

@accesslint/report reads the sidecars and produces a trend at whatever scope you point it to: a single repo, a directory of repos, or the whole monorepo. Output can be rolled into whatever cadence fits: a weekly digest for the platform channel, a JSON feed for an existing reporting system, a quarterly summary for a leadership review.

The property that matters: the measurement is a byproduct of the matcher running. Product teams don’t instrument anything. Platform teams don’t operate a service. The trend data exists because the test suite ran.

For many teams this is the feature that drives adoption, because it answers the question leadership asks before authorizing the next step: “can you show me it’s working?” The snapshot workflow keeps CI green during the rollout, and the report package keeps the case for continued investment defensible quarter over quarter.

Actionable failure messages

When an assertion fails, the matcher writes a failure block that’s designed to be useful in your test-runner output without opening a browser tab:

Expected element to have no accessibility violations, but found 2:

  [critical] text-alternatives/img-alt (WCAG 1.1.1, A) — Image element missing alt attribute.
    selector: div > img
    fix: add-attribute alt=""
    guidance: Every image needs an alt attribute. For informative images, describe the content or function concisely. For decorative images (backgrounds, spacers, purely visual flourishes), use alt='' to hide them from screen readers. [...]

  [critical] labels-and-names/form-label (WCAG 4.1.2, A) — Form element has no accessible label.
    selector: div > input
    context: type: email
    fix: Add a 

Each violation includes the impact in brackets, the namespaced rule ID, the WCAG success criterion and level, a one-line fix suggestion, and prose guidance, all pulled from rule metadata in @accesslint/core. The goal is that a developer reading a failing test log can usually tell what to change without leaving the terminal.

Component-scoped auditing, by default

When the element you assert on isn’t document.documentElement, page-level rules (html-has-lang, document-title, region, bypass, and others) are skipped automatically. Most Testing Library tests render into a container, so the matcher applies the right rule set for component tests without extra configuration:

test("SubmitButton is accessible", () => {
  const { container } = render(<SubmitButton label="Send" />);
  // region / document-title / html-has-lang rules skip automatically
  expect(container).toBeAccessible();
});

For full-page audits, pass document.documentElement or use { componentMode: false } to force them back on. It’s a small thing, but it means component tests stop flagging landmark rules that don’t apply to the unit under test.

Smaller ergonomic additions

Smaller conveniences:

  • Auto-registration. setupFilesAfterEnv: ["@accesslint/jest"] is the whole setup. No explicit expect.extend() call, no separate /extend-expect entry.
  • Synchronous matcher. expect(container).toBeAccessible() returns immediately: no await, no intermediate results variable. Reads the same as other jest-dom matchers (toBeInTheDocument, toHaveStyle).
  • Impact threshold. toBeAccessible({ failOn: "serious" }) only fails on serious and critical violations, useful when gating CI on a policy line rather than zero-tolerance. (jest-axe has an equivalent via its impactLevels config; the AccessLint variant is per-call rather than per-project.)
  • TypeScript out of the box. The package augments both the modern @jest/globals expect.Matchers interface and the legacy global jest.Matchers namespace, so expect(el).toBeAccessible(...) type-checks under either setup.

Migrating from jest-axe

The codemod handles the common patterns. Run with --dry --print first to inspect the diff:

npx @accesslint/codemod jest-axe 'src/**/*.test.{ts,tsx}' --dry --print

A typical test file transforms to:

- import { axe, toHaveNoViolations } from "jest-axe";
- expect.extend(toHaveNoViolations);
+ import "@accesslint/jest";

  test("form is accessible", async () => {
    const { container } = render(
); - const results = await axe(container); - expect(results).toHaveNoViolations(); + expect(container).toBeAccessible(); });

What the codemod leaves for human review:

  • configureAxe({ rules: ... }) globals. AccessLint passes options per call rather than per project. The codemod leaves configureAxe imports in place and adds a TODO(accesslint-codemod): comment reminding you to reapply those settings via toBeAccessible({ disabledRules, failOn, ... }) where needed.
  • Per-call rule filters with axe IDs. axe(container, { rules: { "color-contrast": { enabled: false } } }) collapses to expect(container).toBeAccessible() with a TODO. AccessLint uses namespaced IDs. Remap to toBeAccessible({ disabledRules: ["distinguishable/color-contrast"] }). The jest package README includes a mapping table for the most common rules.
  • Swapping the devDep. The codemod doesn’t touch package.json. Run npm install --save-dev @accesslint/jest && npm uninstall jest-axe after you’re happy with the diff.

Full transform rules and flags are documented in the @accesslint/codemod README.

What doesn’t change

Plenty stays the same across the two libraries, which is the point:

  • WCAG 2.2 Level A and AA coverage is the goal both implementations aim at. AccessLint and axe-core are independent rule engines; expect rule outputs to agree on most violations and differ in count or phrasing on others.
  • Testing Library compatibility is identical. toBeAccessible() accepts any Element: container, screen.getByRole('form'), document.body, or a plain document.createElement(...). React, Vue, Svelte, and Angular Testing Library all work the same way they did before.
  • Jest setup shape is the same. testEnvironment: "jsdom" and a setupFilesAfterEnv entry, one line added to your config.

Violation counts can differ from what axe-core reports on the same markup. That’s expected from independent implementations of the same specifications, not a bug. Treat those differences as a point to investigate rather than a regression signal.

Install and first test

For a new project, or to try the matcher alongside an existing test file:

npm install --save-dev @accesslint/jest
// jest.config.js
module.exports = {
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["@accesslint/jest"],
};
// LoginForm.test.tsx
import { render } from "@testing-library/react";
import { LoginForm } from "./LoginForm";

test("LoginForm is accessible", () => {
  const { container } = render(<LoginForm />);
  expect(container).toBeAccessible();
});

The package README has deeper coverage of snapshot baselines, options, and framework-specific recipes for Vue / Svelte / Angular.

Status and roadmap

A few things a platform team evaluating adoption should know:

  • Versioning. @accesslint/jest is on semver. The 0.x series is still settling, so minor bumps may change behavior in documented ways; patch releases are reserved for bug fixes. Published with npm provenance so attestations are verifiable in your registry. License is MIT.
  • Rule-engine stability. The rule catalog ships in @accesslint/core and is pinned by @accesslint/jest as a workspace dependency. Rule additions arrive in core minor versions; the matcher surfaces them only on upgrade. New rules that fire on existing content appear as new violations the snapshot baselines don’t yet account for, so they’re treated as regressions until the owning team fixes them or force-refreshes the baseline with ACCESSLINT_UPDATE=1. Plan core upgrades accordingly.
  • Sibling packages. Equivalent matchers ship for Vitest (@accesslint/vitest) and Playwright (@accesslint/playwright). All three share a runner-agnostic matcher core, so the option surface and failure-message format are consistent across stacks. Useful if your org runs mixed runners.
  • Reporting. @accesslint/report ships alongside the matcher packages; see the Evidence section above for how it fits into the rollout.
  • Testing Library ecosystem listing. A page is pending review at testing-library-docs PR #1535.
  • Feedback channel. GitHub Issues is the primary signal mechanism. Rule-coverage gaps, migration patterns the codemod doesn’t cover, and framework integrations that aren’t documented are all useful inputs for the v0.x series.

If @accesslint/jest ends up in your org’s Jest setup, please report anything you run into.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Consolidating Your Pipeline: Implementing Multi-Tenant Namespace Tunnels

Next Post

AI chip startup Cerebras files for IPO

Related Posts