Accessibility Testing
Checking that everyone can use the software: WCAG 2.2 and the accessibility rules that build on it, automated checks with tools such as axe, keyboard and screen reader testing, and what automation cannot find.
What accessibility testing is
Accessibility testing checks that people with disabilities can perceive, operate and understand the software, with or without assistive technology such as a screen reader, magnification or voice control. The HTML & CSS topic explains how to build accessible pages; this page is about how to check that you did, as part of the team's normal testing.
It is a quality like any other, with one difference: when it fails, it does not fail for everyone. A missing label is invisible to the developer who wrote it and to every sighted tester using a mouse; it only fails for the screen reader user who hears "edit text, blank". That is why accessibility needs its own techniques, and why it is so often found late, by customers.
Kestrel's target
The European Accessibility Act has applied since 28 June 2025. Whether Kestrel's portal falls under it is a question for Kestrel's legal team: the act lists the services it covers, and energy supply is not on the list, but concluding a consumer contract online can count as an e-commerce service. That is an interpretation, not a ruling. Kestrel decided not to wait for the answer: the whole portal must meet WCAG 2.2 level AA, and the new cost page is checked before its release (CHG-0781). The building blocks of that check are the rest of this page.
What to test against
WCAG 2.2 is written as testable success criteria, 86 active ones (87 numbered; 4.1.1 Parsing was removed). Each is a ready-made test condition with a level: meeting AA means meeting every A and AA criterion, 55 in all. For a tester, each criterion has three useful parts in W3C's documentation: the normative text, the "Understanding" page that explains intent, and techniques and failures that describe concrete ways to pass or fail it.
The criteria added in WCAG 2.2 are the ones most often missing from older test checklists. The six at levels A and AA:
| Criterion | Level | How to test it on the cost page |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | Tab through the page: is the focused element ever completely hidden behind the sticky header or the cookie banner? |
| 2.5.7 Dragging Movements | AA | Can the date range be changed without dragging the chart slider, for example with buttons? |
| 2.5.8 Target Size (Minimum) | AA | Are the "previous month" and "next month" arrows at least 24 by 24 CSS pixels, or far enough apart? |
| 3.2.6 Consistent Help | A | Is the "Contact us" link in the same place on every page that has it? |
| 3.3.7 Redundant Entry | A | Does a multi-step form ask for the same meter number twice? |
| 3.3.8 Accessible Authentication (Minimum) | AA | Can a customer log in without solving a puzzle or retyping a code from memory, for example with a password manager or a copy-paste? |
EN 301 549 is the European harmonized standard of accessibility requirements for ICT (information and communication technology) products and services: websites and apps, but also documents, software and hardware such as ticket machines. For web content it incorporates WCAG. For documents the portal produces, such as PDF invoices, EN 301 549 has its own chapter: an accessible web page with an inaccessible invoice attached is not an accessible service. Which standard the law asks you to test against depends on where you work:
EN 301 549 is the technical reference behind the EU's accessibility laws. Version 3.2.1 (2021) is based on WCAG 2.1 AA; version 4.1.1, published in September 2026, is based on WCAG 2.2, but had not yet been cited in the EU's Official Journal as of October 2026. A team that tests against WCAG 2.2 AA covers both.
Section 508 of the Rehabilitation Act requires federal agencies to develop, buy, maintain and use accessible ICT; it reaches private companies through federal procurement. The revised Section 508 standards, required since January 2018, incorporate WCAG 2.0 A and AA by reference. The ADA Title II rule for state and local governments names WCAG 2.1 AA (compliance from 26 April 2027 or 26 April 2028, depending on size); for businesses under Title III there is no regulation with a technical standard. A team that tests against WCAG 2.2 AA covers all of these.
Vendors document the result in an Accessibility Conformance Report (ACR), usually written on ITI's VPAT template. As of October 2026 the current template is VPAT 2.5Rev (April 2025), in four editions: 508 (WCAG 2.0), EU (EN 301 549), WCAG (2.0, 2.1 and 2.2) and INT (all of them). Federal solicitations state their accessibility requirements, and an ACR is a common way to answer them.
For public-sector websites and apps, the Public Sector Bodies Accessibility Regulations 2018 apply, and the government's guidance asks for WCAG 2.2 AA; since October 2024 the Government Digital Service (GDS) has monitored sites against WCAG 2.2. Private companies fall under the Equality Act 2010, which requires reasonable adjustments but names no technical standard; WCAG 2.2 AA is the practical target there too.
Automated checks
Automated accessibility testing uses software to check pages against rules a machine can decide: an image without a text alternative, a form field without a label, text with too little contrast, a language that is not set, ARIA attributes that are invalid. It is fast, repeatable and cheap, so it belongs on every change, like any other test. To see the principle, here is a tiny checker for four such rules, using only Python's HTML parser:
from html.parser import HTMLParser
PAGE = """<html>
<body>
<h1>Your costs this month</h1>
<img src="costs-chart.png">
<label for="month">Month</label>
<input id="month" type="month">
<input type="checkbox" id="solar">
<button><svg viewBox="0 0 8 8"><path d="M6 0 2 4l4 4"/></svg></button>
<img src="logo.png" alt="Kestrel Energy">
</body>
</html>"""
class QuickA11yCheck(HTMLParser):
"""A few machine-checkable WCAG failures. A real tool checks far more."""
def __init__(self):
super().__init__()
self.findings, self.labels, self.inputs = [], set(), []
self.button, self.button_text = None, ""
def handle_starttag(self, tag, attrs):
a = dict(attrs)
line = self.getpos()[0]
if tag == "html" and not a.get("lang"):
self.findings.append((line, "3.1.1", "<html> has no lang attribute"))
if tag == "img" and "alt" not in a:
self.findings.append((line, "1.1.1", f"<img src={a.get('src')}> has no alt"))
if tag == "label" and a.get("for"):
self.labels.add(a["for"])
if tag == "input" and a.get("type") != "hidden":
self.inputs.append((line, a.get("id")))
if tag == "button":
self.button, self.button_text = line, a.get("aria-label", "")
def handle_data(self, data):
if self.button:
self.button_text += data.strip()
def handle_endtag(self, tag):
if tag == "button":
if not self.button_text:
self.findings.append((self.button, "4.1.2", "<button> has no accessible name"))
self.button = None
def close(self):
super().close()
for line, input_id in self.inputs:
if input_id not in self.labels:
self.findings.append((line, "4.1.2", f"<input id={input_id}> has no <label>"))
checker = QuickA11yCheck()
checker.feed(PAGE)
checker.close()
for line, criterion, message in sorted(checker.findings):
print(f"line {line:2} WCAG {criterion} {message}")
line 1 WCAG 3.1.1 <html> has no lang attribute line 4 WCAG 1.1.1 <img src=costs-chart.png> has no alt line 7 WCAG 4.1.2 <input id=solar> has no <label> line 8 WCAG 4.1.2 <button> has no accessible name
Four failures, each traceable to a success criterion: the page
language (3.1.1), a text alternative (1.1.1), and names for controls
(4.1.2). The checker also shows the limit of the approach: it is
satisfied by any alt text, so
alt="chart" would make it go quiet without helping
anyone. Do not write your own checker for real work; use a
maintained rules engine.
The tools
axe-core is the open-source accessibility rules engine from Deque Systems, written in JavaScript: it runs inside a page and reports each violated rule with an impact level (minor, moderate, serious or critical), the elements involved and the WCAG criteria it maps to. Many other tools build on it. As of October 2026:
- axe-core 4.14.0, used directly or through integrations for Playwright, Cypress and Selenium, and in the axe DevTools browser extension.
- Lighthouse 13.5.0, built into Chrome's developer tools; its accessibility score is calculated from axe-core results.
-
pa11y 10.0.0, a command-line tool for checking
lists of URLs; it uses HTML_CodeSniffer by default and axe-core
with
--runner axe. - WAVE from WebAIM, a browser extension that marks the problems on the page itself, handy for manual review.
How much do these tools find? Deque, which sells axe, analyzed about 300,000 issues from more than 2,000 first-time audits in 2021 and found that 57% of the issues by volume could be found automatically. The often-quoted "20–30%" counts something else: the share of WCAG success criteria that tools can fully check. Both are true. Tools catch the frequent, mechanical problems, which is a lot of issues, but most criteria still need a human.
axe in the pipeline
The Portal team already runs Playwright tests against the test environment, so adding axe is a few lines. The test opens the cost page as a logged-in synthetic test customer, waits until the content is there, and runs axe with the rule tags for WCAG 2.0, 2.1 and 2.2 at A and AA:
// tests/a11y/cost-page.spec.js (Playwright Test, logged in as a synthetic test customer)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('cost page has no detectable WCAG A and AA violations', async ({ page }) => {
await page.goto('/costs?month=2026-09');
await expect(page.getByRole('heading', { name: 'Your costs this month' })).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
// one readable line per rule: impact, rule id, where
const found = results.violations.map(
(v) => `${v.impact.padEnd(9)}${v.id.padEnd(15)}${v.nodes.map((n) => n.target).join(' | ')}`,
);
expect(found).toEqual([]);
});
1) [chromium] › tests/a11y/cost-page.spec.js:5:5 › cost page has no detectable WCAG A and AA violations
Error: expect(received).toEqual(expected) // deep equality
- Expected - 1
+ Received + 6
- Array []
+ Array [
+ "critical button-name .month-nav > button:nth-child(1)",
+ "serious color-contrast .bar-label | .bar-label | .bar-label",
+ "critical image-alt #cost-chart > img",
+ "serious target-size .month-nav > button:nth-child(1)",
+ ]
1 failed
[chromium] › tests/a11y/cost-page.spec.js:5:5 › cost page has no detectable WCAG A and AA violations
Four rules failed: an icon-only button without a name, which is also too small a target, light gray date labels under the chart bars, and the chart image without a text alternative. Two habits make such a test useful instead of noisy:
- Test states, not only pages. Run axe after opening the date picker, after a validation error and in the mobile layout. A closed menu hides its problems.
- Never switch off a rule to get green. If a finding is accepted for a while, exclude that one element with a ticket number in a comment, and remove the exclusion when the ticket is closed.
Keyboard testing
Many people do not use a mouse: people with motor disabilities, screen reader users, power users. WCAG 2.1.1 (level A) requires that everything can be done with a keyboard. The test needs no tools: put the mouse out of reach and use the page with these keys.
| Key | Expected |
|---|---|
| Tab / Shift+Tab | Moves to the next or previous control, in an order that follows the page's meaning (2.4.3 Focus Order) |
| Enter | Follows a link or activates a button |
| Space | Activates a button, toggles a checkbox |
| Arrow keys | Move within a group: radio buttons, a menu, tabs, the days in a date picker |
| Esc | Closes a dialog, menu or date picker and returns focus to the control that opened it |
At every step, check three things:
- Can you see where you are? The focused element needs a visible focus indicator (2.4.7), and it must not be hidden behind sticky content (2.4.11).
-
Can you reach and use everything? Custom controls
built from
divelements are the usual culprits. - Can you always get out again?
The last question has a name. A keyboard trap is a component that keyboard focus can enter but not leave with the keyboard: a date picker, an embedded map or video player, or a modal dialog without a working close button. For someone who cannot use a mouse, the page is over at that point. WCAG 2.1.2 No Keyboard Trap is level A and one of only four criteria that WCAG's non-interference requirement applies to all content on a page: a trap breaks conformance even in a widget for which the page offers an accessible alternative. During the cost page check, Bram Peeters found one: after Tab moved focus into the calendar of the month picker, neither Tab nor Esc moved it out.
Screen reader testing
A screen reader turns the page into speech or braille, using the names, roles and states the browser exposes. Testing with one shows whether the page makes sense without seeing it. Use combinations that real users use:
- NVDA (free) with Firefox or Chrome on Windows, and JAWS where your users use it.
- VoiceOver with Safari on macOS and iOS.
- TalkBack with Chrome on Android.
You do not need to be an expert user to find the big problems. A short routine for each page:
- Open the list of headings (in NVDA NVDA+F7, in VoiceOver the rotor, VO+U). Do the headings alone describe the page?
- Open the list of links and the list of form fields. Does every entry make sense out of context? Five links called "more" do not.
- Tab through the controls and listen. Every control should announce a name, a role ("button", "checkbox") and its state ("checked", "expanded").
- Complete the main task: here, find last month's night costs. Changes on the page, such as new totals after a month is picked, must be announced, typically through a live region.
The cost page's chart fails that last task even after it got alt text. "Bar chart of daily costs in September" tells a screen reader user that there is a chart, not what it says. The fix is a data table with the same numbers next to the chart, or one a click away (see text alternatives for charts). Only a person can check that the table and the chart say the same thing.
Zoom, contrast and color
The zoom and reflow tests (200% text, 400% zoom, a 320 CSS pixel wide viewport) are described on the HTML & CSS page. Add these checks for pages with charts and colored states:
- Color alone (1.4.1, level A): the chart shows day and night costs in two colors and nothing else. Print the page in grayscale or use the browser's color-vision simulation: can you still tell them apart? Patterns or direct labels fix it.
- Text spacing (1.4.12): with larger line, letter and word spacing applied by a bookmarklet or user style sheet, no text may be cut off or overlap.
- Contrast: 4.5:1 for normal text, 3:1 for large text and for the parts of graphics and controls you need to see (the contrast ratio).
Contrast is one of the few checks that can be moved all the way to the design system. If the portal's colors are defined as tokens, a unit test can check every pair the design actually uses, long before a page exists. The formula is WCAG's, the same as on the chart accessibility page:
import math
import pytest
# The portal's color tokens (from its CSS) and where they are used
TOKENS = {
"background": "#ffffff",
"text": "#1f1f1f",
"text-muted": "#8a8a8a",
"night-bar": "#0072b2",
"focus-ring": "#1d4ed8",
}
# (foreground, background, minimum): 4.5 for text, 3 for graphics and focus indicators
PAIRS = {
"body text": ("text", "background", 4.5),
"date under each bar": ("text-muted", "background", 4.5),
"night bars in the chart": ("night-bar", "background", 3.0),
"focus ring": ("focus-ring", "background", 3.0),
}
def luminance(hex_color):
def channel(value):
c = value / 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (int(hex_color[i:i + 2], 16) for i in (1, 3, 5))
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
def contrast(a, b):
light, dark = sorted((luminance(a), luminance(b)), reverse=True)
ratio = (light + 0.05) / (dark + 0.05)
return math.floor(ratio * 100) / 100 # WCAG: never round up
@pytest.mark.parametrize("use", PAIRS)
def test_contrast(use):
fg, bg, minimum = PAIRS[use]
assert contrast(TOKENS[fg], TOKENS[bg]) >= minimum
.F.. [100%]
=================================== FAILURES ===================================
______________________ test_contrast[date under each bar] ______________________
use = 'date under each bar'
@pytest.mark.parametrize("use", PAIRS)
def test_contrast(use):
fg, bg, minimum = PAIRS[use]
> assert contrast(TOKENS[fg], TOKENS[bg]) >= minimum
E AssertionError: assert 3.45 >= 4.5
E + where 3.45 = contrast('#8a8a8a', '#ffffff')
test_example.py:40: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_contrast[date under each bar] - AssertionError: ...
1 failed, 3 passed in 0.17s
The muted gray for the date labels reaches 3.45:1 and fails the
4.5:1 that normal-size text needs. Note the floor in
contrast(): WCAG's thresholds are exact, so a ratio of
4.499 fails, and rounding it up to 4.5 would hide a real failure.
The fix is a darker token, and the test makes sure no one lightens
it again for looks.
Audits and statements
An accessibility audit is a structured evaluation of a website or app against a standard, usually WCAG 2.2 AA, by people trained to do it. It combines automated tools, manual expert checks such as the keyboard and screen reader routines above, and a review of every relevant success criterion, on a sample of pages and processes chosen to represent the whole (W3C's evaluation methodology, WCAG-EM, describes how to choose it). The result is a report that lists, per criterion, pass or fail with evidence and advice. The Portal team's own audit of the cost page, before CHG-0781, produced this list:
| Bug | Finding | WCAG | Found by | Impact |
|---|---|---|---|---|
| BUG-1054 | Chart has no text alternative and no data table | 1.1.1 (A) | axe (missing alt); manual (meaning) | Blocks the main task for screen reader users |
| BUG-1055 | Day and night costs shown by color only | 1.4.1 (A) | Manual | Wrong reading for people with color vision deficiency |
| BUG-1056 | Keyboard trap in the month picker's calendar | 2.1.2 (A) | Manual (keyboard) | Blocks keyboard users completely |
| BUG-1057 | "Previous month" icon button has no name and is 16 by 16 pixels | 4.1.2 (A), 2.5.8 (AA) | axe | Announced as "button"; hard to hit |
| BUG-1058 | Date labels under the bars at 3.45:1 contrast | 1.4.3 (AA) | axe and the token test | Hard to read for people with low vision |
| BUG-1059 | Image puzzle CAPTCHA at login, no alternative | 3.3.8 (AA) | Manual | Some customers cannot log in at all |
Three of the six were found only by people. Prioritize by what the failure does to a user, not by WCAG level alone: a trap that ends the task for keyboard users comes before a contrast problem on a secondary label, even though both must be fixed for AA. Each fix gets a test where one is possible (the token test, the axe test, a Playwright test that presses Esc in the month picker), so the same problems do not come back with the next redesign.
An accessibility statement is a public page in which an organization says how accessible a website or app is: which standard it aims for, which parts do not yet conform and why, when they will be fixed, and how users can report a problem or get the information another way. It turns an audit into a promise users can hold you to. Kestrel publishes one for the portal and updates it after each audit.
For Dutch government websites a toegankelijkheidsverklaring is mandatory. Services covered by the European Accessibility Act must also publish information that explains how the service meets the accessibility requirements, in an accessible form (usually in the terms and conditions), for as long as the service is offered; microenterprises are exempt. For other organizations a statement is good practice.
US law has no general duty to publish an accessibility statement; many organizations publish one voluntarily. The document that matters in procurement is the Accessibility Conformance Report (ACR) described above: buyers, especially federal agencies, ask for it before they buy.
Public-sector bodies must publish an accessibility statement under the Public Sector Bodies Accessibility Regulations 2018, following the government's model; the Equality and Human Rights Commission and the Equality Commission for Northern Ireland enforce the rules. For private companies a statement is good practice.
Beyond tools and checklists
Conformance to WCAG is the floor, not the goal. What automation cannot judge, and what even an expert checklist can miss:
- Whether alt text, labels and error messages are meaningful: "Error: invalid input" passes a tool and helps nobody.
- Whether the focus order and reading order follow the logic of the task.
- Whether a page is understandable for people with cognitive disabilities: plain language, predictable behavior, enough time.
- Whether the whole journey works: sign up, log in, find costs, download the invoice, contact the service desk.
The only reliable way to find these is testing with disabled people doing real tasks, as part of usability testing. A few sessions with screen reader users, keyboard-only users and people with low vision or cognitive disabilities find problems that the team, after weeks inside the design, can no longer see. Pay them for their time, like any other expert.