Verify Like a Screen Reader, Not Like a Screenshot
Part 4 of "Testing the Way Users Actually Experience the Web" — a tutorial
series on @afixt/usecase-runner.
Form validation is where accessibility most often quietly fails. The form looks right: the field gets a red border, an error message appears beneath it, a banner at the top says "Please fix the errors below." Visual QA signs off. Screenshot-based tests sign off.
And a screen-reader user who presses Submit hears... nothing. Or hears "edit text" when they land back on the field, with no indication anything is wrong.
verify: is the assertion keyword in usecase-runner, and its sub-types are
designed to assert what assistive technology can perceive, not what's painted on
screen. This post walks through the ones that matter most for forms and dynamic
content, with a form that looks perfect and fails every one of them.
The form
Permalink to The form<form>
<div class="banner error" style="display:none">
Please correct the errors below
</div>
<label for="email">Email</label>
<input id="email" type="email" class="is-invalid" />
<div class="error-text">Enter a valid email address</div>
<button type="submit">Create account</button>
</form>
After a submit with an empty email, JavaScript shows the banner, adds
is-invalid to the input, and shows the error text. Visually complete.
The use case
Permalink to The use caseid: register-empty-email
title: 'Registration rejects an empty email accessibly'
type: negative
start_location: 'https://app.example.com/register'
expected_result:
'The error is announced, the field is marked invalid, and focus moves to it'
preconditions: []
steps:
- locate: field "Email"
- focus: button "Create account"
- activate: button "Create account" via keyboard
- verify: alert "Please correct the errors below"
- verify: field_error "Email"
- verify: focus field "Email"
Three assertions at the end. Let's take them one at a time.
verify: alert — was the error announced?
Permalink to verify: alert — was the error announced?- verify: alert "Please correct the errors below"
This does not look for text on the page. It looks for an element with
role="alert" whose content contains that text:
await expect(
page
.getByRole('alert')
.filter({ hasText: 'Please correct the errors below' }),
).toBeVisible();
It filters before it asserts, rather than asserting against getByRole('alert')
directly, because a form with per-field alerts plus a summary banner has several
— and an unfiltered locator would fail Playwright's strict mode with a count,
which is a tooling error rather than a finding. The step means "at least one
alert says this", and that is what it compiles to.
role="alert" is an implicit assertive live region. When its content changes,
screen readers interrupt whatever they're reading and announce it. Our banner is
a <div class="banner error"> — it has no role, so making it visible announces
nothing. Fail.
The fix is one attribute: <div role="alert" class="banner error">. (And keep
the element in the DOM from page load, toggling its content rather than
inserting the region itself, so the live region is registered before it needs to
fire.)
verify: field_error — is the field programmatically marked invalid, with the message attached?
Permalink to verify: field_error — is the field programmatically marked invalid, with the message attached?- verify: field_error "Email"
This is a compound check. It finds the field by its label and asserts two things:
aria-invalid="true"is set — so the screen reader says "invalid entry" when the user lands on the field.aria-describedbyoraria-errormessageis present and points at the error text — so the screen reader reads "Enter a valid email address" after the label.
Our input has class="is-invalid". CSS classes are invisible to assistive
technology. Fail on both counts.
Fix:
<input
id="email"
type="email"
aria-invalid="true"
aria-describedby="email-err"
/>
<div id="email-err" class="error-text">Enter a valid email address</div>
A half-fix — aria-invalid without the association, or the association without
aria-invalid — still fails, on purpose. Either one alone gives the user an
incomplete picture.
verify: focus — did focus move to the problem?
Permalink to verify: focus — did focus move to the problem?- verify: focus field "Email"
After a failed submit, focus is still sitting on the submit button. A sighted user sees the red border and moves their eyes. A screen-reader user is still on "Create account, button" with no idea where the error is. The accessible pattern moves focus to the first invalid field (or to the alert). Fail.
Fix: document.getElementById('email').focus() after validation.
Live regions: match every region, not the "best" one
Permalink to Live regions: match every region, not the "best" onePolite status messages — "3 results found", "Draft saved" — use role="status"
rather than alert. The live_region sub-type covers all of them:
- verify: live_region "Draft saved"
This searches every live region on the page — an element with role="status",
role="alert", role="log", role="marquee" or role="timer", or any element
declaring aria-live other than off — for the text. That "every" matters.
Picking one region by role priority would let a role="status" somewhere in the
header make the alert branch unreachable, so a case asserting on an alert
could never pass. When no region carries the text, the failure lists every
region it did find, so you can tell "the message is missing" from "the message
is in the wrong region."
When politeness level is the point of the test, say so:
- verify: live_region role "alert" "Payment failed"
With one caveat: a region that is a live region because it carries aria-live
and nothing else has no role to name, so a role qualifier skips exactly those.
Reach for it when the page announces through a real live-region role, and leave
the assertion unqualified when it announces through the attribute.
The rest of the verify family, briefly
Permalink to The rest of the verify family, briefly| Form | What it asserts |
|---|---|
verify: url "/dashboard" |
Current URL matches |
verify: title "Dashboard" |
Document title |
verify: heading "Welcome" level 1 |
A heading with that name and level is visible |
verify: text "..." |
Visible text exists |
verify: visible | hidden | enabled | disabled | checked | unchecked <target> |
Element state |
verify: field "Email" has_value "..." |
Field value |
verify: count link "Remove" is 3 |
Exact count (name optional — verify: count main is 1) |
verify: <target> attribute "X" <predicate> |
ARIA attributes — eight predicates, covered in Part 5 |
verify: download "report.pdf" |
A download with that filename occurred |
Every one of these is phrased in terms of the accessibility tree or the document state, never in terms of pixels or CSS. That's the discipline: if a screen reader can't perceive it, the test doesn't count it.
Next: Strictness Is a Feature: Why We Reject What We Don't Understand