testing and automating accessibility

Failing Pull Requests on axe Violations

A pull request should not merge while it ships a broken accessible name or an invalid ARIA role. This guide—part of Gating Accessibility in CI/CD Pipelines—covers the precise mechanics of turning an axe violation into a blocked merge: making the test command exit non-zero, wiring that job into a required status check, and printing the offending nodes onto the PR so the fix is obvious. The chain is mechanical and unforgiving: a violation of 4.1.2 Name, Role, Value produces exit code 1, the GitHub Actions job fails, the required check turns red, and branch protection refuses the merge.

WCAG Coverage Mapping

  • 4.1.2 Name, Role, Value (Level A)
  • 1.3.1 Info and Relationships (Level A)
  • 4.1.1 Parsing / valid ARIA usage (Level A)

Prerequisites

  • A jest-axe or @axe-core/playwright suite already asserting against your components or routes.
  • A GitHub Actions workflow that runs that suite on pull_request.
  • Admin access to configure branch protection or a repository ruleset on the default branch.

Ensuring the Test Command Exits Non-Zero

The entire gate depends on one fact: the test process must return a non-zero exit code when an axe violation exists. A test runner does this automatically when an assertion fails—so the job is to make sure every violation triggers a failing assertion, and that nothing swallows the exit code afterward.

With jest-axe, the toHaveNoViolations matcher fails the test the moment axe returns a non-empty violations array, and Jest exits 1 on any failed test:

// Button.a11y.test.jsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { IconButton } from './IconButton';

expect.extend(toHaveNoViolations);

test('icon button exposes an accessible name', async () => {
  const { container } = render(<IconButton icon="close" />);
  const results = await axe(container);
  // Empty violations -> pass; any violation fails -> Jest exits non-zero.
  expect(results).toHaveNoViolations();
});

The component rules behind this matcher are covered in Component Testing with jest-axe. The most common way teams accidentally break the gate is masking the exit code in the shell:

# WRONG — '|| true' swallows the failure; the job always reports success.
npm run test:a11y || true

# WRONG — piping to tee drops the exit code to tee's (usually 0).
npm run test:a11y | tee a11y.log

# RIGHT — preserve the runner's exit code through the pipe.
set -o pipefail
npm run test:a11y | tee a11y.log

Gate Hook: After wiring the job, deliberately introduce one violation—remove an aria-label—and confirm the job fails red. A gate you have never seen fail is a gate you cannot trust.


The Actions Job, Required Status Check, and Branch Protection

The job itself is small; its power comes from being marked required. Give the job a stable name or rely on its key—this string is what you reference in branch protection.

# .github/workflows/a11y.yml
name: a11y

on:
  pull_request:
    branches: [main]

jobs:
  axe:                          # <-- this key is the required-check context
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      # No '|| true', no bare pipe: a violation propagates exit code 1.
      - run: npm run test:a11y -- --ci

Now make the axe context required. Without this step the job can fail and the PR still merges—the red check is advisory until branch protection enforces it.

# Repository ruleset on main (Settings → Rules → Rulesets)
target: branch
conditions:
  ref_name: { include: ["refs/heads/main"] }
rules:
  - type: required_status_checks
    parameters:
      strict_required_status_checks_policy: true
      required_status_checks:
        - context: axe            # exact job key from the workflow

The strict policy forces the branch to be up to date with main before merging, so a violation cannot slip in through a stale base that was green before a conflicting change landed.

Gate Hook: The context string must match the job key exactly, including case. A typo creates a required check that never reports, which silently blocks every PR. Verify it appears in the PR's check list after one run.


Printing the Violation Summary to the PR and Log

A red check tells engineers that something failed; the summary tells them what. Serialize the axe results to JSON, then render a table into the GitHub step summary so reviewers see the rule and selector without opening raw logs.

// jest reporter snippet — save results during the run
import fs from 'node:fs';
import { axe } from 'jest-axe';

export async function auditAndRecord(container, route) {
  const results = await axe(container);
  fs.appendFileSync('a11y-results.ndjson', JSON.stringify({ route, ...results }) + '\n');
  return results;
}
  - name: Publish axe summary to the PR
    if: always()                # run even after the test step fails
    run: |
      set -o pipefail
      node ./scripts/axe-summary.js >> "$GITHUB_STEP_SUMMARY"
// scripts/axe-summary.js
const fs = require('node:fs');
const lines = fs.readFileSync('a11y-results.ndjson', 'utf8').trim().split('\n');
console.log('### Accessibility violations\n');
console.log('| Route | Rule | Impact | Selector |');
console.log('| --- | --- | --- | --- |');
let count = 0;
for (const line of lines) {
  const { route, violations } = JSON.parse(line);
  for (const v of violations) {
    for (const node of v.nodes) {
      count++;
      console.log(`| ${route} | ${v.id} | ${v.impact} | \`${node.target.join(' ')}\` |`);
    }
  }
}
// Mirror the gate state so this step is also red when violations exist.
process.exit(count ? 1 : 0);

This renders a Markdown panel on the run showing, for example, button-name with impact serious on selector .toolbar > button:nth-child(2)—enough to locate and fix the 4.1.2 Name, Role, Value defect immediately.


Quarantining Known Issues Without Hiding New Ones

When a gate fails on pre-existing debt, the temptation is to disable a rule globally—which also hides every future instance of that rule. Instead, quarantine by rule plus selector so a brand-new occurrence of the same rule elsewhere still fails.

// a11y-quarantine.js — narrowly scoped, triaged debt
module.exports = [
  { rule: 'color-contrast', selector: '.legacy-footer a' },
  { rule: 'label', selector: '#archived-search-input' },
];
// subtract quarantine, then assert only on new violations
import quarantine from './a11y-quarantine';

export function expectNoNewViolations(results) {
  const isQuarantined = (v) =>
    v.nodes.every((n) =>
      quarantine.some((q) => q.rule === v.id && n.target.join(' ').includes(q.selector)),
    );
  const fresh = results.violations.filter((v) => !isQuarantined(v));
  expect(fresh).toEqual([]); // new debt fails; quarantined debt is tracked
}

Never quarantine an entire rule (disableRules: ['color-contrast'])—that blinds the gate to new contrast failures across the whole app. The diff-against-baseline approach that scales this to a full codebase is covered in Accessibility Regression Testing in GitHub Actions.


Common a11y Mistakes

  • Swallowing the exit code with || true, a bare pipe, or continue-on-error: true—the job reports green while violations ship.
  • Forgetting expect.extend(toHaveNoViolations), so the matcher is undefined and the test errors out in a way that gets mistaken for an unrelated failure.
  • Marking the workflow required instead of the job, or mistyping the context, creating a check that never reports.
  • Disabling a rule globally to clear legacy debt, hiding every future instance instead of quarantining one selector.
  • Skipping the workflow on a path filter, so the required check never runs and the PR blocks indefinitely.

How to Verify

Confirm the gate actually blocks a merge—do not assume it works because it is green.

  1. Force a failure (tool check): Remove an aria-label from one icon button, push to a branch, and open a PR. The axe job must fail and the merge button must be disabled with "Required check failing."
  2. Inspect the summary (manual check): Open the failed run and confirm the step summary lists the rule (button-name), impact, and selector for the broken node.
  3. Confirm the required check (manual check): In the PR's checks list, verify axe is labeled Required. If it is only "Expected," the branch protection context string does not match the job key.
  4. Confirm quarantine scoping (tool check): Add a new instance of a quarantined rule on a fresh element and confirm the job still fails—proving the quarantine is selector-scoped, not rule-wide.
  5. Restore and re-run: Re-add the label, push, and confirm the check returns green and the merge unblocks.

Conclusion

Failing a PR on axe violations is a three-link chain: a non-zero exit code, a required status check, and branch protection that honors it. Break any link—swallow the exit, mistype the context, leave the check advisory—and violations ship silently. Get all three right, scope your quarantine narrowly, and surface the failing nodes on the PR, and accessibility regressions stop at the pull request instead of in production.


Frequently Asked Questions

Why does my axe job pass even though there are violations? Almost always the exit code is being swallowed—check for || true, a bare | tee without set -o pipefail, or continue-on-error: true on the step. Also confirm you called expect.extend(toHaveNoViolations); without it the matcher is undefined and the assertion never fails as intended.

The check is red but the PR still merges—why? The check is not actually required. A red status is advisory until you add its exact job-key context to branch protection or a repository ruleset on the default branch. Verify the context appears as Required, not merely "Expected," in the PR's checks list.

How do I clear existing violations without disabling the rule everywhere? Quarantine by rule and selector rather than disabling the rule globally. A scoped allowlist suppresses the one known instance while still failing on any new occurrence of the same rule elsewhere in the app.

Where do reviewers see which element failed? Write the axe results to $GITHUB_STEP_SUMMARY as a Markdown table of route, rule, impact, and CSS selector. That renders a panel on the run, so the failing node is visible without opening raw logs, and pairs well with an uploaded HTML report.

Should the gate fail on best-practice rules or only WCAG rules? Gate on wcag2a and wcag2aa tags for a stable conformance target. Best-practice rules are useful as warnings but change between axe-core minor versions, which can turn a previously green build red on an unrelated dependency bump.