Adding a Regression Test for Every Fixed Violation
A closed accessibility finding leaves behind a small, precise record: a rule id, a target selector, a failureSummary, and — once a sweep has resolved it — a source location and a fingerprint. That record contains everything needed to write the assertion that keeps the fix in place, and the conversion from one to the other is mechanical enough to be done the same way every time. This guide is part of Regression Prevention After Fixes, and it covers that conversion end to end: turning the report’s brittle selector into a locator that survives refactors, writing an assertion that genuinely fails on the pre-fix code, proving it does by running it against the parent commit, and wiring the check that flags a pull request which closes a rule id without adding a test for it.
The worked example is a critical label finding on the cart quantity stepper at src/cart/QuantityStepper.tsx:28, route /cart, fingerprint 5d0c91a4. The input had no programmatic label; the fix associated the visible product name with it. Everything below is the process that turns that closed finding into a spec nobody has to remember to write.
Root Cause
The report’s target is not a locator, and using it as one is the single most common way these tests rot. axe generates that string with a selector serialiser whose only goal is to uniquely identify a node in the document it just scanned, so it reaches for whatever is available: hashed class names from CSS modules, nth-child positions, and ancestor chains. The finding above arrived as .cart-grid > div:nth-child(3) > div > input. Every segment of that is a hostage. Moving the quantity column left of the price column changes nth-child(3). A CSS-modules upgrade rewrites .cart-grid to .cart-grid__a7f31. Wrapping the input in a tooltip provider adds a div. None of those changes has anything to do with accessibility, and all of them break the selector.
A broken selector would be tolerable if it failed loudly. It does not. AxeBuilder.include() with a selector that matches nothing returns zero violations, and a scoped scan over zero nodes is a green test. So a regression test built on the report’s target does not start failing when the DOM moves — it starts passing unconditionally, and it keeps reporting success for as long as the repository exists. The test looks like coverage in every dashboard and protects nothing.
The failureSummary is the field that tells you what to assert, and it is worth reading rather than skipping. For this finding it reads “Fix any of the following: Form element does not have an implicit (wrapped) <label>; Form element does not have an explicit <label>; aria-label attribute does not exist or is empty”. That is a list of the mechanisms that could satisfy the rule, which means the assertion must not test any one of them. Asserting htmlFor is present would fail the day someone legitimately switches to aria-labelledby. Assert the outcome — the control has a non-empty accessible name — and let the implementation move.
Configuration
Resolve the node once, on the fixed code, and read what it actually exposes. This is the step that produces a durable locator, and it has to happen after the fix because the durable locator is written against the accessible name the fix created. Injecting axe-core and using its own commons gives the same role and name resolution the scanner used, so the values match the report rather than approximating it.
// a11y/locks/resolve-locator.mjs
// Usage: node a11y/locks/resolve-locator.mjs /cart '.cart-grid > div:nth-child(3) > div > input'
// Run against the FIXED code: the durable locator uses the name the fix created.
import { chromium } from 'playwright';
import axe from 'axe-core';
const [route, target] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(new URL(route, 'http://localhost:3000').href);
// axe.source is the packaged browser bundle; injecting it gives us the same
// role and accessible-name resolution the scan used to report the finding.
await page.addScriptTag({ content: axe.source });
const node = await page.evaluate((selector) => {
const el = document.querySelector(selector);
if (!el) return null;
return {
tag: el.tagName.toLowerCase(),
role: window.axe.commons.aria.getRole(el),
name: window.axe.commons.text.accessibleText(el).trim(),
testId: el.getAttribute('data-testid'),
};
}, target);
await browser.close();
if (!node) {
console.error(`target did not resolve on ${route}: ${target}`);
process.exit(1);
}
console.log(node);
// Preference order: role + accessible name, then a test id the fix added.
if (node.role && node.name) {
console.log(`page.getByRole('${node.role}', { name: '${node.name}' })`);
} else if (node.testId) {
console.log(`page.getByTestId('${node.testId}')`);
} else {
// No durable handle exists yet, so creating one is part of the fix.
console.error('add a data-testid to the repaired element in the fix commit');
process.exit(2);
}
For this finding the script prints role: 'spinbutton', name: 'Quantity for Trailhead Jacket', and the locator page.getByRole('spinbutton', { name: 'Quantity for Trailhead Jacket' }). Role plus accessible name is the first choice for a reason beyond durability: it is the same pair a screen-reader user navigates by, so a locator that stops resolving is itself evidence that something a user relies on has changed. When the repaired element has no name and no role worth addressing — a decorative wrapper, a container fixed for aria-allowed-attr — add a data-testid in the same commit as the fix, never in the test commit, so the handle the lock depends on is part of the change being reviewed.
The spec itself has four moving parts and no cleverness. It seeds the state the finding was reported in, guards that the locator resolves to exactly one node, asserts the outcome named by failureSummary, and re-runs the one rule that was violated over the repaired subtree. The name assertion uses a pattern anchored at the start so a product rename does not break the lock while a lost label still does.
// tests/a11y/locks/cart-quantity.lock.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const FINGERPRINT = '5d0c91a4';
test.describe('@a11y-lock cart quantity stepper', () => {
test.beforeEach(async ({ page, request }) => {
// Seeded basket: one line item, so the accessible name is deterministic.
await request.post('/test/basket', { data: { sku: 'TRL-JKT-01', qty: 2 } });
await page.goto('/cart');
await page.getByTestId('cart-line-1').waitFor();
});
test(`label stays fixed [${FINGERPRINT}]`, async ({ page }) => {
// Pattern, not a literal: the product name is data, "Quantity for" is not.
const qty = page.getByRole('spinbutton', { name: /^Quantity for / });
// Without this, a drifted locator makes the scan below pass on zero nodes.
await expect(qty).toHaveCount(1);
// Assert the outcome from failureSummary, not the mechanism: htmlFor,
// aria-label and aria-labelledby must all be allowed to satisfy it.
await expect(qty).toHaveAccessibleName(/^Quantity for \S/);
const results = await new AxeBuilder({ page })
.include('[data-testid="cart-line-1"]') // only the repaired subtree
.withRules(['label']) // only the rule that was closed
.analyze();
expect(results.violations).toEqual([]);
});
});
Validation
A regression test that has never failed is a hypothesis. Prove it by running the new spec against the commit immediately before the fix, where it must fail — and must fail for the right reason, which is the locator resolving to zero nodes because the accessible name does not exist yet. A worktree does this without disturbing the working copy.
# From the branch that contains both the fix and the new lock spec.
FIX=$(git rev-parse HEAD)
PARENT=$(git rev-parse HEAD~1) # the commit before the fix landed
git worktree add ../pre-fix "$PARENT" # a second checkout, no stashing needed
cp tests/a11y/locks/cart-quantity.lock.spec.ts ../pre-fix/tests/a11y/locks/
cd ../pre-fix
npm ci --silent
npx playwright test tests/a11y/locks/cart-quantity.lock.spec.ts --reporter=list
# ✘ label stays fixed [5d0c91a4]
# Expected: 1
# Received: 0
# Locator: getByRole('spinbutton', { name: /^Quantity for / })
# 1 failed, exit code 1
cd - && git worktree remove ../pre-fix # leaves no trace in the main checkout
Two failure signatures at this step mean different things and both are useful. Received: 0 is the expected outcome: the control had no accessible name before the fix, so a role-and-name locator cannot find it, which proves the assertion depends on the fix. Received: 1 followed by a passing axe scan means the spec is not testing what you think — usually because the pattern is loose enough to match something else on the page, or because the subtree include resolved to a different node. Tighten the pattern and re-run before trusting anything.
Then run it on the fix commit, which should be green in under a second, and record the pair of results in the pull-request body. That two-line note is what makes the box on the fix checklist meaningful rather than decorative: “red at a4f1e2 (Received: 0), green at e21d55 (1 passed, 0.9s)”.
Edge Cases and Conditional Guards
- The repaired node needs an interaction to exist. When the finding was reported inside a menu, a step of a wizard or a collapsed panel, the spec must replay that interaction in
beforeEachand the locator must be asserted after it. A sweep that records areachedByfield makes this mechanical; where it does not, read the route’s own test helpers rather than guessing, because a lock that scans the wrong state passes for the wrong reason. - The accessible name is entirely data. A row action named “Remove Trailhead Jacket” has no stable prefix if the product changes, and a locator anchored on the data will break on every fixture reset. Add a
data-testidin the fix commit and locate by it, then assert the name with a pattern that pins the verb:/^Remove \S/. That keeps the assertion about the label’s existence and shape rather than its content. - Several findings on one node. A single input can carry
label,aria-allowed-attrandaria-valid-attr-valuefindings at once. Write one test per rule id inside a shareddescribe, each with its ownwithRulescall and its own fingerprint in the name, so a later regression names exactly which rule came back rather than reporting a generic failure on a busy element. - Shadow DOM boundaries.
AxeBuilder.include()accepts an array of frame or shadow selectors, and a reporttargetfor a node inside a shadow root arrives as a nested array for that reason. Locate the host by test id, pass the include as the array form, and never flatten it into a single string — the flattened version silently matches nothing.
Pipeline Impact
The lock spec runs inside the tagged Playwright project described in the parent guide, so it adds one route load, one interaction and one single-rule scan — roughly 0.9 seconds — to the pull-request job. That cost only buys anything if the test exists, which is what the second half of this page is for: a check that compares the rule ids a pull request claims to have fixed against the specs it adds.
The claim comes from a commit trailer, A11y-Fixes: label@5d0c91a4, one line per closed finding. The guard reads the trailers between the merge base and HEAD, reads the lock spec files the branch touched, and requires that some changed spec mentions both the rule id and the fingerprint. Matching on the fingerprint as well as the rule id is what stops a rename or an unrelated edit to an existing spec from satisfying a fresh claim.
// a11y/locks/fix-has-test.mjs
// Every rule id claimed fixed in a trailer needs a spec naming its fingerprint.
import { execFileSync } from 'node:child_process';
const base = process.argv[process.argv.indexOf('--base') + 1];
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
// 1. What this branch claims to have closed.
const claimed = new Map(); // rule id -> fingerprint
for (const line of git('log', `${base}...HEAD`, '--pretty=%B').split('\n')) {
const trailer = line.match(/^A11y-Fixes:\s*([a-z0-9-]+)@([0-9a-f]{6,})\s*$/);
if (trailer) claimed.set(trailer[1], trailer[2]);
}
if (claimed.size === 0) {
console.log('no A11y-Fixes trailers; nothing to enforce');
process.exit(0);
}
// 2. The lock specs this branch added or changed, read at HEAD.
const specs = git('diff', '--name-only', `${base}...HEAD`)
.split('\n')
.filter((file) => /\.lock\.spec\.[tj]s$/.test(file));
const specText = specs.map((file) => git('show', `HEAD:${file}`)).join('\n');
// 3. A claim is satisfied only when both identifiers appear in a changed spec.
const missing = [...claimed].filter(
([rule, fingerprint]) =>
!(specText.includes(rule) && specText.includes(fingerprint)),
);
for (const [rule, fingerprint] of missing) {
// GitHub renders this as an annotation on the run, next to the diff.
console.log(`::error::${rule}@${fingerprint} was closed with no lock spec`);
}
console.log(`${claimed.size} claimed, ${claimed.size - missing.length} locked`);
process.exit(missing.length === 0 ? 0 : 1);
Register the guard as its own required status check rather than folding it into the test job, as covered in requiring accessibility status checks in branch protection, because its failure has a different remedy from a failing test: the author adds a spec rather than fixing code. When the annotations are also being forwarded to a review channel, emit them in the shape described in structuring JSON violation output for Slack and GitHub annotations so a missing lock is distinguishable from a new violation. The structural half of the same fix — the subtree snapshot that catches drift no rule reports — is covered in snapshot testing accessibility trees to prevent regressions, and the shared Playwright harness these specs run in is set up in integrating axe-core Playwright into an existing project.
Common Pitfalls
- Pasting the report’s
targetinto the spec, which produces a locator that stops resolving and starts passing rather than failing. - Omitting the count guard, so a scoped scan over zero nodes reports success for years.
- Asserting the mechanism (
htmlForis present) instead of the outcome (the control has a name), which breaks the first time someone switches toaria-labelledbyfor a good reason. - Adding the
data-testidin the test commit rather than the fix commit, so the lock depends on a change nobody reviewed as part of the fix. - Skipping the pre-fix run. Without it there is no evidence the assertion depends on the fix, and a tautological test is indistinguishable from a real one in every report.
- Matching the guard on rule id alone, which lets an unrelated edit to an existing spec satisfy a brand-new claim.
- Writing the trailer by hand from memory. Copy the rule id and fingerprint from the sweep’s plan so the strings match exactly; a typo reads as a missing lock.
FAQ
Why not generate these specs automatically from the report? Generation is fine for the boilerplate and useless for the parts that matter. A script can emit the route, the rule id, the fingerprint and the file name, and doing so saves real time on a sweep that healed thirty findings. What it cannot do is choose the durable locator, decide which part of an accessible name is data, or replay the interaction that makes the node exist — and those three decisions are exactly what separates a lock from a test that passes forever. Generate the skeleton, then resolve and edit each one by hand.
How many of these accumulate before the suite becomes a burden?
Slower than teams expect, because each spec is one route load and one single-rule scan. A suite of 37 locks across 14 components runs in under a minute on two workers, and the marginal cost of the thirty-eighth is under a second. The real limit is not runtime but attention: locks grouped into one file per component, each with a shared beforeEach, stay readable, while 37 separate files with 37 copies of the same setup do not. When a component is deleted, delete its lock file in the same pull request.
What if the violation was fixed long ago and there is no fingerprint or parent commit?
Write the lock against the current code and prove it differently: break the fix locally — delete the htmlFor, remove the aria-labelledby — confirm the spec goes red, then revert. It is the same falsifiability check with a hand-made counterexample instead of a historical one. Generate a fingerprint at that point from the rule id and the durable locator so the spec name and any future trailer have something stable to refer to, and note in the commit that the lock was added retroactively.
Related
- Regression Prevention After Fixes — the parent guide, where this assertion is the first of three locks a fix pull request carries.
- Snapshot Testing Accessibility Trees to Prevent Regressions — the structural lock that catches the drift a single-rule assertion cannot see.
- CI/CD Integration & Automated Quality Gating — the gating machinery these checks register with, including required checks and annotations.