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
Permalink to Types: positive, negative, extensionEvery 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.
The parent
Permalink to The parent# 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
An extension: bad password
Permalink to An extension: bad password# 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:
- Load the parent,
register-success. - Merge
data— the child'spassword: 'abc'replaces the parent's,emailis inherited. - Take the parent's steps 1 through 12 (everything before
from_step), then append the child'ssteps_override.steps. - 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
Permalink to 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
Permalink to What every negative case should assertThe pattern in all three children is the same and it's worth making a checklist:
- The error is announced —
verify: alert "..."(orverify: live_regionfor polite messages). - The field is programmatically invalid and the message is attached —
verify: field_error "...". - Focus goes somewhere useful —
verify: focus field "...". - The user didn't get navigated away —
verify: urlon 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
Permalink to A note on from_step, and where this is goingfrom_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.