UCDL Accessibility Use Case Definition Language
Contents

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.

<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.

id: 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?

- 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?

- verify: field_error "Email"

This is a compound check. It finds the field by its label and asserts two things:

  1. aria-invalid="true" is set — so the screen reader says "invalid entry" when the user lands on the field.
  2. aria-describedby or aria-errormessage is 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?

- 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

Polite 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

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