testing and automating accessibility
Writing Custom axe-core Rules
axe-core's built-in rules cover the machine-checkable parts of WCAG, but they can't know your design system's conventions: that every icon-only button must carry a specific data attribute, that your Modal component must always render an aria-labelledby, or that a custom widget must expose a particular role. axe.configure() lets you register your own checks and rules so these project-specific requirements run through the same engine—and the same violations/incomplete/passes pipeline—as the standard ones. This guide, part of Automated Accessibility Testing with axe-core, shows how to author, scope, and test a custom rule, and when a lint rule or jest-axe assertion is the better tool.
Prerequisites
- A working axe-core integration (browser,
@axe-core/react, jest-axe, or Playwright). - A concrete, falsifiable requirement—something a small
evaluatefunction can decide true/false from the DOM. - Familiarity with the result model:
axe.configure()rules emit the sameviolations/incomplete/passesbuckets covered in the guide.
Anatomy: Checks vs Rules
axe separates two concepts, and axe.configure() registers both:
- A check is the unit of logic. Its
evaluatefunction receives a DOM node and returnstrue(passed),false(failed), orundefined(incomplete—needs review). This is where your actual test lives. - A rule binds one or more checks to a set of nodes via a CSS
selector(and optionalmatchespredicate), and carries metadata:id,tags, and the help text shown in reports.
A rule passes a node only if its checks resolve favourably; a single failing any/all check produces a violation. Mapping that to 4.1.2 Name, Role, Value: a check that returns false when an interactive element lacks a computed accessible name behaves exactly like axe's own name checks.
Registering a Custom Check and Rule
Below is a complete, project-specific rule: every icon-only button in our design system is marked data-icon-button, and must expose an accessible name (via aria-label, aria-labelledby, or visually-hidden text). Built-in button-name covers generic buttons, but we want a dedicated rule with our own help text and tag so violations are obviously ours in reports.
// axe-custom-rules.ts
import axe from 'axe-core';
axe.configure({
// 1) The check: the actual decision logic.
checks: [
{
id: 'icon-button-has-name',
// evaluate runs in the page context against each matched node.
evaluate(node: HTMLElement) {
// axe.commons exposes the same accessible-name computation
// the engine uses for 4.1.2 Name, Role, Value.
const name = (axe as any).commons.text.accessibleText(node);
return typeof name === 'string' && name.trim().length > 0;
},
// Message shown for pass/fail in the result data.
metadata: {
impact: 'serious',
messages: {
pass: 'Icon button exposes an accessible name.',
fail: 'Icon-only button has no accessible name (aria-label, aria-labelledby, or visually-hidden text).',
},
},
},
],
// 2) The rule: bind the check to the nodes it should run on.
rules: [
{
id: 'ds-icon-button-name',
// Only our design-system icon buttons match.
selector: 'button[data-icon-button], [role="button"][data-icon-button]',
// Optional secondary predicate for finer matching.
matches: (node: HTMLElement) => node.querySelector('svg') !== null,
// Tag it so teams can include/exclude it via runOnly.
tags: ['cat.name-role-value', 'best-practice', 'ds-internal'],
// 'any' = passes if any listed check passes.
any: ['icon-button-has-name'],
metadata: {
description: 'Design-system icon buttons must have an accessible name.',
help: 'Add aria-label or visually-hidden text to icon-only buttons.',
helpUrl: 'https://internal.docs/a11y/icon-button-name',
},
},
],
});
Run it like any other rule—target your custom tag to run only your rules, or merge it into a full audit:
// Run only design-system rules during a focused check.
const results = await axe.run(document, {
runOnly: { type: 'tag', values: ['ds-internal'] },
});
console.log(results.violations.filter((v) => v.id === 'ds-icon-button-name'));
axe.configure() is global and replaces configuration, so call it once at setup. Re-running it discards prior custom config; in tests, use axe.reset() between suites if you reconfigure.
Enabling and Disabling Rules
You don't always need a new check to change behaviour. axe.configure() (and run options) can toggle existing rules and adjust their tags:
import axe from 'axe-core';
axe.configure({
rules: [
// Turn ON a best-practice rule that's off by default.
{ id: 'region', enabled: true },
// Turn OFF a rule that conflicts with a known, documented exception.
{ id: 'duplicate-id', enabled: false },
// Re-tag an existing rule so it runs under your gate's tag set.
{ id: 'color-contrast', tags: ['wcag2aa', 'wcag21aa', 'ds-internal'] },
],
});
// Per-run override without changing global config:
await axe.run(document, { rules: { 'region': { enabled: false } } });
Prefer per-run overrides for one-off scoping and reserve axe.configure() for permanent, project-wide policy. Document every disable with a reason and owner so it surfaces in review.
Testing the Custom Rule
A custom rule is code; it needs its own tests against known-good and known-bad DOM. Drive it through jest-axe or the raw API so a logic bug doesn't silently pass everything.
// ds-icon-button-name.test.ts
import axe from 'axe-core';
import './axe-custom-rules'; // registers the rule via axe.configure()
function mount(html: string) {
document.body.innerHTML = html;
return document.body;
}
it('fails an icon button with no accessible name', async () => {
mount(`<button data-icon-button><svg aria-hidden="true"></svg></button>`);
const results = await axe.run(document.body, {
runOnly: { type: 'tag', values: ['ds-internal'] },
});
const ids = results.violations.map((v) => v.id);
expect(ids).toContain('ds-icon-button-name');
});
it('passes an icon button with aria-label', async () => {
mount(`<button data-icon-button aria-label="Close"><svg aria-hidden="true"></svg></button>`);
const results = await axe.run(document.body, {
runOnly: { type: 'tag', values: ['ds-internal'] },
});
expect(results.violations.map((v) => v.id)).not.toContain('ds-icon-button-name');
});
it('does not match plain buttons (selector scoping)', async () => {
mount(`<button>Save</button>`); // no data-icon-button → rule inapplicable
const results = await axe.run(document.body, {
runOnly: { type: 'tag', values: ['ds-internal'] },
});
expect(results.violations.map((v) => v.id)).not.toContain('ds-icon-button-name');
});
Test all three outcomes: a fail, a pass, and an inapplicable case proving your selector/matches scoping doesn't leak onto unrelated nodes. If your check can return undefined, add a fourth test asserting it lands in incomplete.
For verifying the real components rendered by your framework, run the same registered rule inside component testing with jest-axe so it gates actual JSX output, not just hand-written HTML fixtures.
How to Verify
- Automated: run the rule against fixtures and against a real rendered component; confirm it appears in
violationsfor bad input,passesfor good input, andinapplicablewhen the selector shouldn't match. - Cross-check the engine version: custom config is tied to the loaded axe build. Log
results.testEngine.versionin CI and re-validate your rule when you upgrade axe-core—internal helpers likeaxe.commonsare not a frozen public API. - Manual: for any rule about accessible names or roles, confirm with a screen reader that the thing your rule enforces actually produces the intended announcement. A rule that checks for
aria-labelpresence still can't tell you the label is meaningful—verify that by ear.
When a Lint Rule or jest-axe Is Better
Custom axe rules are powerful but not always the right layer. Choose deliberately:
- Reach for a lint rule (ESLint,
eslint-plugin-jsx-a11y) when the defect is visible in source. Missingalton an<img>, a staticonClickon a<div>—catching these at author time, before render, is faster and needs no runtime. axe can't see source; lint can't see the rendered/hydrated DOM. They're complementary. - Reach for a plain jest-axe assertion when the requirement is one-off or component-local. If only your
Modalneedsaria-labelledby, aexpect(container.querySelector('[role=dialog]')).toHaveAttribute('aria-labelledby')assertion in that component's test is simpler than a global rule and lives next to the component. - Reach for a custom axe rule when the requirement is cross-cutting and runtime-dependent. A convention that must hold across every page and only manifests in the live DOM (computed names, applied roles, conditionally rendered ARIA) is exactly what
axe.configure()is for—register once, run everywhere, including E2E.
A useful heuristic: if it's a source pattern, lint it; if it's one component, assert it; if it's a system-wide runtime invariant, make it an axe rule.
Common a11y Mistakes
- Calling
axe.configure()more than once. It replaces, not merges. Register all custom rules in a single setup module andaxe.reset()between test suites if needed. - Forgetting to scope the selector. A too-broad
selectormakes your rule fire on unrelated nodes and flood reports. Test the inapplicable case. - Returning
falsewhen you mean "can't tell." If the check genuinely can't decide, returnundefinedso the node lands inincomplete—don't fabricate a pass or fail. - Depending on
axe.commonsas a stable API. Internal helpers can change between versions; pin your axe-core version and re-test custom rules on upgrade. - Building a custom rule for something lint already catches. Source-visible issues belong in
eslint-plugin-jsx-a11y, not a runtime rule.
Conclusion
axe.configure() turns axe-core from a fixed checker into an extensible policy engine for your design system: a check holds the logic, a rule scopes and labels it, and both flow through the same violations/incomplete/passes model your team already reads. Author the rule, scope its selector tightly, test all three outcomes, and re-validate on every axe upgrade. Reserve custom rules for cross-cutting runtime invariants—let lint catch source patterns and jest-axe assertions handle the one-offs.
Frequently Asked Questions
What's the difference between a check and a rule in axe-core?
A check is the evaluate function that inspects a single node and returns true, false, or undefined. A rule binds one or more checks to nodes via a CSS selector and carries metadata and tags. You register both with axe.configure(); the rule decides which nodes get tested, the check decides whether each passes.
Does axe.configure() merge with existing custom rules?
No. axe.configure() replaces the active configuration each time it's called. Register all your custom checks and rules in one setup module, and call axe.reset() to return to defaults—important between test suites that reconfigure differently.
When should I write a custom axe rule instead of an ESLint rule?
Use ESLint (eslint-plugin-jsx-a11y) when the issue is visible in source code, like a missing alt attribute—it's caught before render with no runtime cost. Use a custom axe rule when the requirement depends on the rendered, hydrated DOM (computed accessible names, applied roles, conditional ARIA) and must hold across the whole app.
Can a custom check return a "needs review" result?
Yes. Return undefined from evaluate and axe classifies the node as incomplete rather than pass or fail. Use this when your logic genuinely can't decide automatically—the same mechanism the built-in color-contrast rule uses over images and gradients.
Related guides
- Automated Accessibility Testing with axe-core — the guide on the rules engine.
- Catching Color Contrast Failures with axe-core — interpret the built-in contrast rule and its incompletes.
- Component Testing with jest-axe — run your registered rules against real rendered components in CI.