How to Map WCAG 2.2 Success Criteria to Automated Tests
To turn a WCAG 2.2 success criterion into an automated test, resolve one question first: does your pinned rule engine actually model this criterion, or is it silently unmapped? Where the engine has a check, you inherit its assertion; where it does not — as with several 2.2 additions like SC 2.5.8 Target Size (Minimum) and the resilience-based SC 1.4.12 Text Spacing — you must author a geometry- or layout-aware assertion yourself and emit a three-state result (pass / fail / cantTell) the rest of the pipeline can consume.
This is the hands-on walkthrough under the broader WCAG 2.2 vs 3.0 success criteria taxonomy reference, itself part of the Enterprise WCAG Audit Architecture & Standards Mapping strategy. The taxonomy page defines the registry and the projection model — how one atomic result becomes a 2.2 verdict; this page covers the narrower job of wiring an individual criterion into a running Playwright and axe-core enterprise configuration suite so its result is real rather than assumed.
When This Applies
Reach for a custom assertion only when the criterion is not fully decided by the rule engine you already run. That happens in three recurring cases:
- The engine has no rule for the criterion. Several WCAG 2.2 additions are geometric or interaction-dependent, and a given pinned
axe-coreversion may ship no automated check for them. Awcag22aatag filter then returns zero violations for that criterion — not because the page passes, but because nothing was tested. - The criterion is about resilience to a user override, not a static value. SC 1.4.12 Text Spacing does not require any particular line height; it requires that no content is lost when the user applies the spec’s spacing overrides. A rule that inspects the shipped stylesheet answers the wrong question.
- The criterion is only partly machine-decidable. Most criteria have a decidable slice (an image has no
altat all) and an undecidable slice (thealttext is present but meaningless). The automated assertion must own the decidable slice and route the rest to manual review withcantTell, never a silent pass.
If the criterion is fully covered by an ACT rule your engine implements, do not hand-roll it — register the engine’s rule ID against the criterion in the taxonomy layer and move on. This page is for the gaps.
Minimal Reproducible Example
The smallest way to see the problem is a suite that trusts the engine to cover all of WCAG 2.2 AA. A 20-by-20 pixel icon button sails through:
# NAIVE: assumes the rule engine models every WCAG 2.2 AA criterion.
def test_wcag_22_aa(page):
page.goto("https://app.example.com/checkout")
results = run_axe(page, tags=["wcag22aa"]) # engine returns zero violations
assert results["violations"] == [] # reported "AA conformant"The assertion passes, but the checkout page has a 20px close button that fails SC 2.5.8. The pinned engine version simply has no check wired to that criterion, so the wcag22aa tag never selects one, and the tiny target is reported as conformant. The criterion is unmapped, not satisfied — and a green build hides the difference. The fix is to detect unmapped criteria explicitly and supply an assertion for each.
Correct Implementation
Author the missing assertion so it measures the criterion directly and returns an atomic result the taxonomy layer already understands. Two representative criteria follow: one geometric (2.5.8), one resilience-based (1.4.12).
1. Measure geometry directly for SC 2.5.8 Target Size
The 24-by-24 CSS pixel floor is an absolute measure, not a viewport-relative one, so read each interactive target’s bounding box in CSS pixels and compare. Return cantTell for anything you cannot measure rather than absorbing it as a pass.
# custom_criteria.py — assertions for 2.2 criteria the engine does not model.
MIN_TARGET_PX = 24 # SC 2.5.8 (Minimum): absolute 24x24 CSS px floor.
def assert_target_size(page):
"""One atomic result per pointer target. Spec exceptions (inline links in
a sentence, user-agent-controlled widgets) must be filtered upstream."""
targets = page.locator("a[href], button, input, select, [role='button']")
results = []
for handle in targets.element_handles():
box = handle.bounding_box() # CSS pixels, layout viewport
if box is None: # display:none / off-screen node
results.append({"sc": "2.5.8", "outcome": "cantTell"})
continue
ok = box["width"] >= MIN_TARGET_PX and box["height"] >= MIN_TARGET_PX
results.append({"sc": "2.5.8", "outcome": "pass" if ok else "fail"})
return resultsBecause target size depends on rendered layout, drive the assertion after the page has settled — inject synthetic scroll and pointer emulation first if the target only mounts on interaction, using the same settle discipline as dynamic content boundary detection so you never measure a mid-transition box.
2. Inject the override for SC 1.4.12 Text Spacing
Text Spacing is a resilience test. The correct check applies the spec’s spacing overrides and then inspects the resulting layout for clipping or overlap — it does not read the shipped line-height.
# SC 1.4.12 — inject the user spacing overrides, then check for lost content.
SPACING_OVERRIDES = """
* { line-height: 1.5 !important;
letter-spacing: 0.12em !important;
word-spacing: 0.16em !important; }
p { margin-bottom: 2em !important; }
"""
def assert_text_spacing(page, selector):
page.add_style_tag(content=SPACING_OVERRIDES) # simulate the user override
clipped = page.locator(selector).evaluate(
"el => el.scrollHeight > el.clientHeight"
" || el.scrollWidth > el.clientWidth"
)
return {"sc": "1.4.12", "outcome": "fail" if clipped else "pass"}A false negative here almost always traces to the site’s own CSS winning the cascade — an !important line-height that blocks the injected override, so the clipping condition is never exercised. The engine log makes the root cause explicit:
[AUDIT_ENGINE] Rule 1.4.12 execution started.
[STYLE_INJECT] Applied user spacing overrides (line-height:1.5; letter-spacing:0.12em; word-spacing:0.16em).
[CSS_CASCADE] Override blocked: .enterprise-typography { line-height: 1.2 !important; }
[LAYOUT_CHECK] Spacing not applied -> clipping/overlap check could not run.
[RESULT] Violation confirmed: site CSS prevents user spacing overrides from taking effect.When site styles out-specify the override, raise the injection’s specificity or attach it through document.adoptedStyleSheets, and confirm the computed style actually changed with getComputedStyle() before asserting — otherwise the check silently no-ops into a pass. Validate every emitted result against the shared contract before it leaves the runner; the taxonomy layer relies on JSON Schema validation for accessibility data so a malformed atomic result fails loudly instead of skewing a verdict.
Pipeline Integration
These assertions produce atomic results, so they slot straight into the same projection the taxonomy layer uses — but the deploy gate should not treat every criterion identically. A binary pass/fail on borderline geometric or spacing checks floods the report with noise, so route each mapped result through a tiered gate: structural failures hard-block, heuristic or near-threshold failures soft-block with a tracked ticket and a documented manual-review waiver, and passes within a small computed-value tolerance merge. Feed the failing results into your error categorization triage pipelines and let the level ceiling from the A/AA/AAA compliance level mapping decide which criteria are in scope for the gate, exactly as when setting up AAA compliance thresholds for enterprise apps.
Gotchas
- Authenticated and multi-tenant states change which targets exist. A target that only renders behind login, or only for a heavier tenant’s feature set, is never measured on an anonymous fixture. Run the geometry pass behind the real auth state and against your densest tenant, or the 2.5.8 result covers a page your users never see.
- Viewport variance flips both criteria. Responsive layouts mount different controls per breakpoint and reflow text at each width, so a target that clears 24px at 1280px can shrink below it on a mobile drawer, and spacing that fits desktop clips on a narrow column. Pin the viewport explicitly and evaluate per breakpoint rather than trusting a single width.
- JS-disabled and CSP-locked contexts break style injection. Behind a strict Content Security Policy or WAF, inline style injection for the 1.4.12 check may be blocked, and JavaScript-disabled crawlers never run the geometry pass at all. Serve a prerendered snapshot of the critical checkpoints and route those requests through fallback routing for JS-disabled crawlers so a blocked injection is a known path, not a phantom pass.