UCDL Accessibility Use Case Definition Language
Contents

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.

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

usecase-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"

- 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

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

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

Next: Testing Without a Mouse: Interaction Profiles