UCDL Accessibility Use Case Definition Language
Contents

One Flow, Many Outcomes: Extension Cases and Negative Testing

Part 8 of "Testing the Way Users Actually Experience the Web" — a tutorial series on @afixt/usecase-runner.

Every form has one happy path and a dozen ways to go wrong. And it's the wrong ways — the error message that isn't announced, the invalid field that isn't marked, the focus that doesn't move — where accessibility most often breaks. Parts 4 and 5 covered the assertions for those. This post is about authoring them without copying the happy path twelve times.

Types: positive, negative, extension

Every use case declares a type:

  • positive — the happy path; the user achieves the goal.
  • negative — an error path; the user does something wrong and the system responds accessibly.

Both are labels for the report. type does not change how the file is assembled — extends: does, and it is independent of type: every example below is type: negative with an extends:, because they are error paths derived from a parent.

There is no third value such as extension, for exactly the reason this section gives: it would name a composition relationship in the field that records intent, and since it would have no effect on resolution it would have no defined meaning. A document declaring it is rejected.

A steps_override without an extends: is inert. The document validates, the override is ignored, and the case resolves to zero steps — which scores pass, because nothing in it failed.

# register-success.uc.yaml
id: register-success
title: 'Register a new account'
type: positive
start_location: 'https://app.example.com/register'
expected_result: 'The account is created and the welcome page loads'
preconditions:
  - 'No account exists for the test email'

data:
  email: '[email protected]'
  password: 'SecurePass123!'

steps:
  - verify: heading "Create your account" level 1 # 1
  - locate: field "Email" # 2
  - focus: field "Email" # 3
  - enter: field "Email" value "{{ email }}" # 4
  - locate: field "Password" # 5
  - focus: field "Password" # 6
  - enter: field "Password" value "{{ password }}" # 7
  - locate: checkbox "I agree to the Terms" # 8
  - select: checkbox "I agree to the Terms" # 9
  - locate: button "Create account" # 10
  - focus: button "Create account" # 11
  - activate: button "Create account" # 12
  - verify: url "/welcome" # 13
  - verify: heading "Welcome, Jane!" level 1 # 14
# register-weak-password.uc.yaml
id: register-weak-password
title: 'Registration rejects a weak password accessibly'
type: negative
extends: register-success
description:
  'With a password that fails the policy, the error is announced, the field is
  marked invalid, and focus returns to it.'
preconditions: []

data:
  password: 'abc' # override just this value

steps_override:
  from_step: 13 # replace from the first post-submit assertion onward
  steps:
    - 'verify: alert "Please correct the errors below"'
    - 'verify: field_error "Password"'
    - 'verify: focus field "Password"'
    - 'verify: url "/register"'

How it's assembled:

  1. Load the parent, register-success.
  2. Merge data — the child's password: 'abc' replaces the parent's, email is inherited.
  3. Take the parent's steps 1 through 12 (everything before from_step), then append the child's steps_override.steps.
  4. Run the result as one case.

The child file says only what's different: one data value and four assertions. Twelve lines of flow are shared, and when the registration form gains a "Confirm password" field, you update the parent and every extension picks it up.

Two more from the same parent

# register-duplicate-email.uc.yaml
id: register-duplicate-email
title: 'Registration rejects an existing email accessibly'
type: negative
extends: register-success
preconditions:
  - 'An account already exists for the test email'
data:
  email: '[email protected]'
steps_override:
  from_step: 13
  steps:
    - 'verify: alert "An account with that email already exists"'
    - 'verify: field_error "Email"'
    - 'verify: focus field "Email"'
# register-terms-not-accepted.uc.yaml
id: register-terms-not-accepted
title: 'Registration requires accepting the Terms'
type: negative
extends: register-success
preconditions: []
steps_override:
  from_step: 9 # drop the select, keep locating the checkbox
  steps:
    - 'locate: button "Create account"'
    - 'focus: button "Create account"'
    - 'activate: button "Create account"'
    - 'verify: alert "You must accept the Terms to continue"'
    - 'verify: checkbox "I agree to the Terms" attribute "aria-invalid" is
      "true"'
    - 'verify: focus checkbox "I agree to the Terms"'

The third one splices earlier — at step 9 — so the parent's select never runs. Extensions can branch from any point, not only the end.

What every negative case should assert

The pattern in all three children is the same and it's worth making a checklist:

  1. The error is announced — verify: alert "..." (or verify: live_region for polite messages).
  2. The field is programmatically invalid and the message is attached — verify: field_error "...".
  3. Focus goes somewhere useful — verify: focus field "...".
  4. The user didn't get navigated away — verify: url on the same page.

If a form passes its positive case and all of its negatives, a screen-reader user can not only complete it, but recover from mistakes on it. The second half is the part most suites never test.

A note on from_step, and where this is going

from_step is a 1-based index into the parent's step list. It's simple, and it has a known weakness: insert one step into the parent — a locate before a focus, say — and every child now splices at the wrong point. Nothing errors; the override still applies cleanly, just one step off, and the extension tests something nobody wrote. The # replace from the first post-submit assertion comment is the only record of intent, and nothing checks it.

This is precisely the failure mode the use-case literature (Cockburn's extensions) keys away from by branching at labelled steps. A proposed change (ADR-0005) adds a no-op anchor: step to parents and a from_anchor: field for children:

# parent
- activate: button "Create account"
- anchor: submitted
- verify: url "/welcome"

# child
steps_override:
  from_anchor: submitted
  steps: [...]

Until that lands: keep extensions short, comment the splice point with why rather than where, and when you edit a parent's steps, re-run its children and read the report — don't just check it's green.

Next: Generate for CI, Run for Right Now