select Is Not toggle: Verbs With Contracts
Part 6 of "Testing the Way Users Actually Experience the Web" — a tutorial
series on @afixt/usecase-runner.
A short post about a small verb that exists because of a very common bug.
The bug
Permalink to The bugSomewhere in a React component:
<button
aria-expanded={open}
onClick={() => setOpen(true)} // should be setOpen(!open)
>
Show details
</button>
It opens. It never closes. Click it ten times and aria-expanded stays
"true". Users notice eventually; tests usually don't, because most tests click
once and check that the panel appeared.
Why select can't catch it
Permalink to Why select can't catch itusecase-runner has select and deselect for checkboxes, radios, switches, and
dropdowns. They map to Playwright's check() / uncheck() / selectOption(),
and they are idempotent by definition: select means set to on. If the
control is already on, it does nothing and reports success.
That's the right behaviour for a flow — "make sure the Terms box is ticked before submitting" shouldn't fail because it was pre-ticked. But it means this test is broken in a way that's hard to see:
# Looks like a toggle test. Isn't.
- select: checkbox "Subscribe"
- verify: checkbox "Subscribe" attribute "aria-checked" is "true"
- select: checkbox "Subscribe"
- verify: checkbox "Subscribe" attribute "aria-checked" is "false"
The second select is a no-op on a checked box, so the last line can never pass
on a working component either. And if you "fix" it by deleting the last line,
the case is green and asserts nothing about toggling.
toggle: the verb whose contract is "it changed"
Permalink to toggle: the verb whose contract is "it changed"- toggle: checkbox "Subscribe" # was unchecked -> must now be checked
- toggle: checkbox "Subscribe" # was checked -> must now be unchecked
toggle reads the control's governing state, activates it, reads the state
again, and fails if the value did not change. The state attribute is
inferred from the role:
| Role | Attribute |
|---|---|
checkbox, switch, radio |
aria-checked, falling back to the native checked property |
option, tab |
aria-selected |
treeitem |
aria-expanded |
button is ambiguous — aria-pressed on a toggle button, aria-expanded on a
disclosure — so toggle uses whichever the element has. When it carries both,
the first candidate wins (aria-pressed for button) without complaint, so
name the one you mean:
- toggle: button "Mute" attribute "aria-pressed"
- toggle: button "Show details" attribute "aria-expanded"
The assertion is changed, not strictly inverted, so the APG tri-state
checkbox cycle (mixed → false → true) passes at every step while a stuck
control still fails.
Tutorial: the disclosure that only opens
Permalink to Tutorial: the disclosure that only opensid: details-disclosure-toggles
title: 'The "Show details" disclosure opens and closes'
type: positive
start_location: 'https://app.example.com/order/42'
expected_result:
'The disclosure button alternates aria-expanded on each activation'
preconditions: []
steps:
- locate: button "Show details"
- verify: button "Show details" attribute "aria-expanded" is "false"
- focus: button "Show details"
- toggle: button "Show details" attribute "aria-expanded"
- verify: visible region "Order details"
- toggle: button "Show details" attribute "aria-expanded"
- verify: hidden region "Order details"
Against the buggy component:
4 toggle: button "Show details" attribute "aria-expanded" pass
5 verify: visible region "Order details" pass
6 toggle: button "Show details" attribute "aria-expanded" FAIL
Expected button "Show details" to invert "aria-expanded" from "true",
still "true" after activating it.
7 verify: hidden region "Order details" FAIL
Step 6 is the finding, stated in exactly the terms a developer needs.
Notice step 2 too: is "false" on aria-expanded — not is_or_absent. As Part
5 covered, a disclosure button with no aria-expanded at all is a defect, and
we want that to fail before we even get to toggling.
Why a separate verb instead of a pattern
Permalink to Why a separate verb instead of a patternYou can write a toggle test as activate plus two verify: attribute steps,
and it works. But the three lines are load-bearing together and nothing enforces
the pairing. Drop either verify during a refactor and the case still passes,
still has "toggle" in its title, and no longer tests toggling.
toggle can't be written wrong that way. The verb carries its own contract.
That's the design principle behind the whole keyword set: each verb names an
outcome the user experiences, and asserts it. select promises "it's on now."
toggle promises "it flipped." focus promises "it has focus." None of them is
a description of a gesture; all of them are claims about the page that the run
either proves or refutes.