Accessibility CI/CD: Integrating jest-axe, cypress-axe, and pa11y

Accessibility CI/CD: Integrating jest-axe, cypress-axe, and pa11y

Getting accessibility testing into your CI/CD pipeline is the difference between accessibility that holds over time and accessibility that degrades with each release. Without pipeline integration, you rely on developers remembering to run checks manually — and that doesn't scale.

This guide covers the complete picture: jest-axe configuration and custom matchers, cypress-axe for E2E coverage, pa11y in CLI pipelines, GitHub Actions workflows, baseline snapshot strategies, acceptable violation configuration, and PR blocking.

jest-axe: Setup and Configuration

jest-axe wraps axe-core as a Jest custom matcher, giving you accessibility assertions directly alongside your unit and integration tests.

Installation and Global Setup

npm install --save-dev jest-axe
// jest.setup.js
import { configureAxe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

// Configure default axe options applied to all tests
configureAxe({
  rules: {
    // Disable rules you've consciously decided are out of scope
    // Document the reason inline
    'color-contrast': { enabled: true },
    'region': { enabled: false }, // Off by default — not all content needs landmarks
  },
  runOnly: {
    type: 'tag',
    values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
  }
});
// jest.config.js
module.exports = {
  setupFilesAfterFramework: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jsdom'
};

Custom Matchers

The built-in toHaveNoViolations fails on any violation. For projects with existing violations that need incremental remediation, custom matchers let you distinguish between violations you're actively fixing and new regressions:

// test-utils/a11y-matchers.js
import { axe } from 'jest-axe';

/**
 * Custom matcher that fails only on impact levels above a threshold.
 * Use during migration: block critical/serious, warn on moderate/minor.
 */
function toHaveNoViolationsAbove(received, impactLevel) {
  const impactOrder = ['minor', 'moderate', 'serious', 'critical'];
  const threshold = impactOrder.indexOf(impactLevel);

  if (threshold === -1) {
    throw new Error(`Invalid impact level: ${impactLevel}. Use minor|moderate|serious|critical`);
  }

  const blocking = received.violations.filter(v =>
    impactOrder.indexOf(v.impact) >= threshold
  );

  if (blocking.length === 0) {
    return { pass: true, message: () => 'No violations above threshold' };
  }

  const formatted = blocking.map(v => {
    const nodes = v.nodes.map(n =>
      `    - ${n.target.join(', ')}\n      ${n.failureSummary.split('\n')[0]}`
    ).join('\n');
    return `  [${v.impact.toUpperCase()}] ${v.id}: ${v.help}\n${nodes}`;
  }).join('\n\n');

  return {
    pass: false,
    message: () =>
      `Found ${blocking.length} accessibility violation(s) at or above "${impactLevel}" impact:\n\n${formatted}`
  };
}

/**
 * Custom matcher that checks for specific rules only.
 * Use for targeted testing of known-risky areas.
 */
function toPassAccessibilityRules(received, ruleIds) {
  const failing = received.violations.filter(v => ruleIds.includes(v.id));

  if (failing.length === 0) {
    return { pass: true, message: () => 'All specified rules pass' };
  }

  const formatted = failing.map(v =>
    `  [${v.id}] ${v.help}${v.nodes.length} element(s)`
  ).join('\n');

  return {
    pass: false,
    message: () => `Failed accessibility rules:\n${formatted}`
  };
}

export { toHaveNoViolationsAbove, toPassAccessibilityRules };
// jest.setup.js
import { toHaveNoViolations } from 'jest-axe';
import { toHaveNoViolationsAbove, toPassAccessibilityRules } from './test-utils/a11y-matchers';

expect.extend({
  toHaveNoViolations,
  toHaveNoViolationsAbove,
  toPassAccessibilityRules
});

Usage in tests:

// Phase 1: block on critical/serious only (migration mode)
expect(results).toHaveNoViolationsAbove('serious');

// Phase 2: full WCAG AA compliance
expect(results).toHaveNoViolations();

// Targeted: specific rules only
expect(results).toPassAccessibilityRules(['color-contrast', 'label', 'button-name']);

Testing with React Testing Library

// ProductCard.test.jsx
import React from 'react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { ProductCard } from './ProductCard';

const mockProduct = {
  id: '1',
  name: 'Wireless Headphones',
  price: 79.99,
  image: 'headphones.jpg',
  rating: 4.5,
  reviewCount: 234
};

describe('ProductCard accessibility', () => {
  it('has no violations in default state', async () => {
    const { container } = render(<ProductCard product={mockProduct} />);
    expect(await axe(container)).toHaveNoViolations();
  });

  it('has no violations in loading state', async () => {
    const { container } = render(<ProductCard loading />);
    expect(await axe(container)).toHaveNoViolations();
  });

  it('has no violations when out of stock', async () => {
    const { container } = render(
      <ProductCard product={{ ...mockProduct, inStock: false }} />
    );
    expect(await axe(container)).toHaveNoViolations();
  });

  it('has no violations when in cart', async () => {
    const { container } = render(
      <ProductCard product={mockProduct} inCart />
    );
    expect(await axe(container)).toHaveNoViolations();
  });
});

cypress-axe: End-to-End Accessibility Testing

cypress-axe integrates axe-core into Cypress tests, giving you accessibility audits in a real browser against fully rendered application states — including authenticated sessions, dynamic content, and complex component compositions.

npm install --save-dev cypress-axe axe-core
// cypress/support/e2e.js
import 'cypress-axe';

Basic Usage

// cypress/e2e/accessibility.cy.js
describe('Accessibility', () => {
  beforeEach(() => {
    cy.injectAxe(); // Inject axe-core into the page
  });

  it('home page has no WCAG AA violations', () => {
    cy.visit('/');
    cy.checkA11y(null, {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
      }
    });
  });

  it('checkout flow has no violations', () => {
    cy.visit('/cart');
    cy.injectAxe();
    cy.checkA11y();

    cy.get('[data-testid="checkout-btn"]').click();
    cy.injectAxe(); // Reinject after navigation
    cy.checkA11y();
  });

  it('modal has no violations when open', () => {
    cy.visit('/products');
    cy.injectAxe();

    cy.get('[data-testid="quick-view"]').first().click();
    cy.get('[role="dialog"]').should('be.visible');

    // Audit only the modal
    cy.checkA11y('[role="dialog"]');
  });
});

Custom Violation Handler

By default, cy.checkA11y() throws a Cypress error with a generic message. A custom handler gives you structured, actionable output:

// cypress/support/a11y-helpers.js
export function logA11yViolations(violations) {
  cy.task('log', `\n${violations.length} accessibility violation(s) found:\n`);

  violations.forEach(({ id, impact, description, nodes }) => {
    cy.task('log', `[${impact.toUpperCase()}] ${id}`);
    cy.task('log', `  ${description}`);
    nodes.forEach(({ target, html, failureSummary }) => {
      cy.task('log', `  → ${target.join(', ')}`);
      cy.task('log', `    HTML: ${html.substring(0, 100)}`);
      cy.task('log', `    ${failureSummary.split('\n')[0]}`);
    });
    cy.task('log', '');
  });
}

export function checkA11yWithReport(context, options = {}) {
  cy.checkA11y(
    context,
    {
      runOnly: {
        type: 'tag',
        values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
      },
      ...options
    },
    logA11yViolations,
    false // Don't fail on violations — use custom assertion below
  );
}

Scoped Audits Per Component

// cypress/e2e/navigation.cy.js
import { checkA11yWithReport } from '../support/a11y-helpers';

describe('Navigation accessibility', () => {
  it('main navigation has no violations', () => {
    cy.visit('/');
    cy.injectAxe();

    checkA11yWithReport('header nav');
  });

  it('mobile nav has no violations when open', () => {
    cy.viewport('iphone-x');
    cy.visit('/');
    cy.injectAxe();

    cy.get('[data-testid="mobile-menu-toggle"]').click();
    cy.get('[data-testid="mobile-nav"]').should('be.visible');

    checkA11yWithReport('[data-testid="mobile-nav"]');
  });
});

Excluding Third-Party Content in Cypress

cy.checkA11y(
  null,
  {
    exclude: [
      ['#intercom-frame'],
      ['[data-ad-container]'],
      ['.grecaptcha-badge']
    ]
  }
);

pa11y: CLI Integration for CI Pipelines

pa11y is a command-line accessibility testing tool that runs axe-core (and optionally HTML_CodeSniffer) against URLs. It's ideal for quick CI checks without a full test framework.

npm install --save-dev pa11y pa11y-ci

CLI Usage

# Basic audit
npx pa11y https://example.com

<span class="hljs-comment"># WCAG AA standard, JSON output
npx pa11y \
  --standard WCAG2AA \
  --reporter json \
  https://example.com

<span class="hljs-comment"># Ignore specific rules
npx pa11y \
  --standard WCAG2AA \
  --ignore <span class="hljs-string">"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail" \
  https://example.com

<span class="hljs-comment"># Threshold — fail only if violation count exceeds N
npx pa11y \
  --threshold 5 \
  https://example.com

pa11y-ci: Multi-URL Pipeline Testing

pa11y-ci tests multiple URLs and produces a pass/fail result based on configurable thresholds.

// .pa11yci.json
{
  "defaults": {
    "standard": "WCAG2AA",
    "chromeLaunchConfig": {
      "args": ["--no-sandbox", "--disable-setuid-sandbox"]
    },
    "timeout": 30000,
    "wait": 2000,
    "reporters": ["cli"],
    "threshold": 0,
    "ignore": [
      "color-contrast"
    ]
  },
  "urls": [
    "https://example.com/",
    "https://example.com/about",
    "https://example.com/products",
    {
      "url": "https://example.com/checkout",
      "threshold": 2
    }
  ]
}
# Run pa11y-ci
npx pa11y-ci --config .pa11yci.json

pa11y with Authentication

// pa11y-config.js
module.exports = {
  defaults: {
    standard: 'WCAG2AA',
    actions: [
      'navigate to https://example.com/login',
      'set field #email to test@example.com',
      'set field #password to testpassword',
      'click element [type="submit"]',
      'wait for url to be https://example.com/dashboard'
    ],
    chromeLaunchConfig: {
      args: ['--no-sandbox']
    }
  },
  urls: [
    'https://example.com/dashboard',
    'https://example.com/settings',
    'https://example.com/account'
  ]
};

GitHub Actions Workflow

A complete CI workflow that runs all three tools:

# .github/workflows/accessibility.yml
name: Accessibility Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-accessibility:
    name: Unit Tests (jest-axe)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run jest-axe tests
        run: npm run test:a11y
        env:
          CI: true

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: jest-a11y-results
          path: coverage/

  e2e-accessibility:
    name: E2E Tests (cypress-axe)
    runs-on: ubuntu-latest
    needs: unit-accessibility
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build application
        run: npm run build

      - name: Start application
        run: npm run start:ci &
        env:
          PORT: 3000

      - name: Wait for application
        run: npx wait-on http://localhost:3000 --timeout 60000

      - name: Run Cypress accessibility tests
        uses: cypress-io/github-action@v6
        with:
          spec: 'cypress/e2e/accessibility.cy.js'
          browser: chrome
          headless: true
        env:
          CYPRESS_BASE_URL: http://localhost:3000

      - name: Upload Cypress artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-screenshots
          path: cypress/screenshots/

  pa11y-audit:
    name: pa11y Audit
    runs-on: ubuntu-latest
    needs: e2e-accessibility
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run pa11y-ci
        run: |
          npx pa11y-ci \
            --config .pa11yci.json \
            --json > pa11y-results.json || true

          # Parse results and fail if violations found
          node -e "
            const results = require('./pa11y-results.json');
            const totalErrors = Object.values(results.results).flat()
              .filter(r => r.type === 'error').length;
            console.log('Total pa11y errors:', totalErrors);
            if (totalErrors > 0) process.exit(1);
          "

      - name: Upload pa11y results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: pa11y-results
          path: pa11y-results.json

PR Comment with Violation Summary

Add a step that posts violation details as a PR comment:

      - name: Post accessibility report to PR
        if: github.event_name == 'pull_request' && failure()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');

            let report = '## Accessibility Test Failures\n\n';

            try {
              const pa11y = JSON.parse(fs.readFileSync('pa11y-results.json', 'utf8'));
              const errors = Object.entries(pa11y.results).flatMap(([url, results]) =>
                results
                  .filter(r => r.type === 'error')
                  .map(r => ({ url, ...r }))
              );

              if (errors.length > 0) {
                report += `### pa11y: ${errors.length} error(s)\n\n`;
                errors.slice(0, 10).forEach(e => {
                  report += `- **${e.url}**: ${e.message}\n`;
                  report += `  \`${e.selector || 'N/A'}\`\n\n`;
                });
                if (errors.length > 10) report += `_...and ${errors.length - 10} more_\n`;
              }
            } catch (e) {
              report += '_pa11y results not available_\n';
            }

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: report
            });

Baseline Snapshots to Prevent Regression

A baseline approach captures the current set of violations and fails only if new violations are introduced — not if existing ones remain. This is the right strategy for codebases with known violations you're working through.

Snapshot Strategy with axe-core

// scripts/capture-a11y-baseline.js
const { chromium } = require('playwright');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');
const path = require('path');

const PAGES = [
  '/',
  '/about',
  '/products',
  '/checkout'
];

const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';

async function captureBaseline() {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const baseline = {};

  for (const pagePath of PAGES) {
    const url = `${BASE_URL}${pagePath}`;
    console.log(`Auditing ${url}...`);

    await page.goto(url, { waitUntil: 'networkidle' });

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
      .analyze();

    baseline[pagePath] = results.violations.map(v => ({
      id: v.id,
      impact: v.impact,
      // Store selector targets to identify specific instances
      nodes: v.nodes.map(n => n.target.join(', '))
    }));

    console.log(`  ${results.violations.length} violation(s)`);
  }

  await browser.close();

  const baselinePath = path.join(__dirname, '../.a11y-baseline.json');
  fs.writeFileSync(baselinePath, JSON.stringify(baseline, null, 2));
  console.log(`\nBaseline saved to ${baselinePath}`);
}

captureBaseline().catch(console.error);
// scripts/check-a11y-regression.js
const { chromium } = require('playwright');
const AxeBuilder = require('@axe-core/playwright').default;
const fs = require('fs');
const path = require('path');

async function checkRegression() {
  const baselinePath = path.join(__dirname, '../.a11y-baseline.json');

  if (!fs.existsSync(baselinePath)) {
    console.error('No baseline found. Run capture-a11y-baseline.js first.');
    process.exit(1);
  }

  const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';

  let totalNew = 0;
  let totalFixed = 0;

  for (const [pagePath, baselineViolations] of Object.entries(baseline)) {
    await page.goto(`${BASE_URL}${pagePath}`, { waitUntil: 'networkidle' });

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
      .analyze();

    const currentIds = new Set(results.violations.map(v => v.id));
    const baselineIds = new Set(baselineViolations.map(v => v.id));

    const newViolations = [...currentIds].filter(id => !baselineIds.has(id));
    const fixedViolations = [...baselineIds].filter(id => !currentIds.has(id));

    if (newViolations.length > 0) {
      console.error(`\n[NEW VIOLATIONS] ${pagePath}:`);
      newViolations.forEach(id => {
        const violation = results.violations.find(v => v.id === id);
        console.error(`  [${violation.impact.toUpperCase()}] ${id}: ${violation.help}`);
        violation.nodes.forEach(n => console.error(`    → ${n.target.join(', ')}`));
      });
      totalNew += newViolations.length;
    }

    if (fixedViolations.length > 0) {
      console.log(`\n[FIXED] ${pagePath}: ${fixedViolations.join(', ')}`);
      totalFixed += fixedViolations.length;
    }
  }

  await browser.close();

  console.log(`\nSummary: ${totalNew} new violations, ${totalFixed} fixed`);

  if (totalNew > 0) {
    console.error('\nBuild failed: new accessibility violations introduced.');
    process.exit(1);
  }
}

checkRegression().catch(console.error);

Add to your CI workflow:

      - name: Check accessibility regression
        run: |
          node scripts/check-a11y-regression.js
        env:
          BASE_URL: http://localhost:3000

Configuring Acceptable Violations

For teams using an incremental approach, maintain a suppression registry that is code-reviewed and audited:

// a11y-suppressions.js
/**
 * Accessibility violation suppressions.
 * Each suppression must have:
 *   - rule: the axe rule ID
 *   - reason: why it's suppressed
 *   - ticket: tracking issue for resolution
 *   - expiry: date after which this suppression blocks CI (optional)
 *   - scope: CSS selector for the affected element(s), or 'global'
 */
module.exports = [
  {
    rule: 'color-contrast',
    scope: '.legacy-widget',
    reason: 'Third-party widget we cannot modify. Being replaced in Q3.',
    ticket: 'JIRA-1234',
    expiry: '2026-09-01'
  },
  {
    rule: 'color-contrast',
    scope: '.chart-tooltip',
    reason: 'D3 generated tooltip — manual verification shows 5.1:1 on our backgrounds, axe samples incorrectly against gradient.',
    ticket: 'JIRA-1256'
  },
  {
    rule: 'region',
    scope: 'global',
    reason: 'Rule disabled globally — our layout uses explicit landmarks but not every content block is enclosed.',
    ticket: 'JIRA-1300'
  }
];

Apply suppressions in CI:

const suppressions = require('./a11y-suppressions');

function buildAxeOptions() {
  const today = new Date().toISOString().split('T')[0];
  const rules = {};

  suppressions
    .filter(s => !s.expiry || s.expiry > today)
    .filter(s => s.scope === 'global')
    .forEach(s => {
      rules[s.rule] = { enabled: false };
    });

  return { rules };
}

function buildExcludes() {
  const today = new Date().toISOString().split('T')[0];
  return suppressions
    .filter(s => !s.expiry || s.expiry > today)
    .filter(s => s.scope !== 'global')
    .map(s => s.scope);
}

// In Playwright test
const options = buildAxeOptions();
const excludes = buildExcludes();

let builder = new AxeBuilder({ page })
  .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
  .options(options);

excludes.forEach(selector => builder.exclude(selector));

const results = await builder.analyze();

Blocking PRs on New Violations

The goal: main branch violations cannot increase. New violations introduced in a PR block merge.

# .github/workflows/accessibility-gate.yml
name: Accessibility Gate

on:
  pull_request:
    branches: [main]

jobs:
  gate:
    name: Accessibility Regression Gate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Need full history for baseline comparison

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build PR branch
        run: npm run build

      - name: Start server
        run: npm run start:ci &
        env:
          PORT: 3001

      - name: Wait for server
        run: npx wait-on http://localhost:3001

      - name: Run accessibility regression check
        id: a11y_check
        run: |
          node scripts/check-a11y-regression.js \
            --baseline .a11y-baseline.json \
            --url http://localhost:3001 \
            --output pr-a11y-results.json
        continue-on-error: true

      - name: Post results to PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(
              fs.readFileSync('pr-a11y-results.json', 'utf8')
            );

            if (results.newViolations.length === 0) {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: '✅ **Accessibility Gate passed** — no new violations introduced.'
              });
            } else {
              const summary = results.newViolations
                .map(v => `- [\`${v.id}\`] **${v.impact}**: ${v.help} (${v.page})`)
                .join('\n');

              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `❌ **Accessibility Gate failed**  ${results.newViolations.length} new violation(s) found:\n\n${summary}\n\nFix these before merging.`
              });
            }

      - name: Fail if new violations
        if: steps.a11y_check.outcome == 'failure'
        run: |
          echo "New accessibility violations found. See PR comment for details."
          exit 1

Accessibility Score Tracking Over Time

For production monitoring, track the Lighthouse accessibility score after each deploy:

// scripts/track-a11y-score.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
const fs = require('fs');
const path = require('path');

async function trackScore() {
  const historyPath = path.join(__dirname, '../.a11y-score-history.json');
  const history = fs.existsSync(historyPath)
    ? JSON.parse(fs.readFileSync(historyPath, 'utf8'))
    : [];

  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
  const url = process.env.SITE_URL || 'https://example.com';

  const lhr = await lighthouse(url, { port: chrome.port });
  await chrome.kill();

  const score = Math.round(lhr.lhr.categories.accessibility.score * 100);
  const entry = {
    date: new Date().toISOString(),
    score,
    commit: process.env.GITHUB_SHA?.slice(0, 8),
    url
  };

  history.push(entry);
  fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));

  console.log(`Accessibility score: ${score}/100`);

  // Alert if score dropped more than 5 points from last recorded
  if (history.length >= 2) {
    const previous = history[history.length - 2].score;
    const delta = score - previous;
    if (delta < -5) {
      console.error(`Score dropped ${Math.abs(delta)} points (${previous}${score})`);
      process.exit(1);
    }
  }
}

trackScore().catch(console.error);

Summary: The Three-Layer Strategy

The most robust accessibility CI/CD pipeline uses three complementary layers:

Layer 1: jest-axe in unit tests

  • Catches violations at component level during development
  • Fast (no browser launch)
  • Runs on every file save in watch mode
  • Cost of fix: minimal (code is right there)

Layer 2: cypress-axe in E2E tests

  • Catches violations in real application states
  • Covers component composition issues unit tests miss
  • Verifies authenticated states, dynamic content, complex flows
  • Cost of fix: moderate (requires understanding full page context)

Layer 3: pa11y-ci or Lighthouse against production

  • Final gate before or after deploy
  • Catches anything that slipped through previous layers
  • Tracks score trend over time
  • Cost of fix: high (production impact, coordinated deploy required)

The goal is to catch violations at Layer 1 — where they're cheapest to fix and closest to the code that caused them. Layers 2 and 3 exist as backstops, not primary mechanisms.

When violations reach Layer 3, that's a signal that your Layer 1 and 2 coverage needs to expand to include that class of component or interaction pattern. The pipeline becomes self-improving: every production violation becomes a gap to close in unit or integration tests.

Read more