Part 2: Understanding the Step DSL
Permalink to Part 2: Understanding the Step DSLEvery step follows this pattern:
keyword: role_token "accessible_name" [modifiers]
- keyword — The action to perform (
locate,focus,enter,select,activate,verify,audit) - role_token — How to find the element (
button,link,field,heading, etc.) - "accessible_name" — The element's accessible name (always in double quotes)
- modifiers — Optional parameters specific to the keyword
The Six Core Keywords
Permalink to The Six Core Keywords1. locate — "Can the user find this?"
Permalink to 1. locate — "Can the user find this?"Asserts that an element exists and is visible in the accessibility tree.
- locate: button "Submit"
- locate: heading "Create Account" level 2
- locate: field "Email" within region "Sign Up"
- locate: text "Welcome back"
- locate: image "Company Logo"
- locate: navigation "Main Menu"
What it generates:
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
When to use it: Before interacting with any element. This is your first check — can an assistive technology user discover this element?
2. focus — "Can the user reach this with a keyboard?"
Permalink to 2. focus — "Can the user reach this with a keyboard?"Moves keyboard focus to an element and asserts that focus was actually received.
- focus: field "Email"
- focus: button "Submit"
- focus: link "Sign In"
What it generates:
const el = page.getByLabel('Email');
await el.focus();
await expect(el).toBeFocused();
When to use it: Before entering data or activating controls. This catches elements that look interactive but can't be reached via keyboard.
3. enter — "Type text into a field"
Permalink to 3. enter — "Type text into a field"Fills a text input or textarea with a value.
- enter: field "Email" value "[email protected]"
- enter: field "Search" value "accessibility" type_slowly true
- enter: field "Bio" value "{{ bio_text }}"
Modifiers:
| Modifier | Values | Default | Purpose |
|---|---|---|---|
value |
"text" |
required | The text to enter |
type_slowly |
true/false |
false |
Simulates keystroke-by-keystroke typing. Use for autosuggest, input masking. |
clear_first |
true/false |
true |
Whether to clear existing content first. |
Boolean modifiers take a literal true or false. A typo is a parse error, not
a silent false:
- enter: field "Search" value "x" type_slowly tru # error: type_slowly requires "true" or "false"
4. select — "Choose an option"
Permalink to 4. select — "Choose an option"Works with checkboxes, radio buttons, and dropdown selects.
# Checkboxes
- select: checkbox "I agree to the Terms"
- select: checkbox "Subscribe to newsletter"
# Radio buttons
- select: radio "Express Shipping"
- select: radio "Pay by Credit Card"
# Dropdown selects
- select: select "Country" option "United States"
- select: select "State" option "{{ user_state }}"
The tool automatically uses .check() for checkboxes/radios and
.selectOption() for dropdowns.
select does not flip anything. It means set to on, and it is idempotent
— if the box is already checked, it returns and does nothing. So this does
not test that the checkbox toggles:
# Wrong — the second select is a no-op, and the last line can never pass
- select: checkbox "I agree"
- verify: checkbox "I agree" attribute "aria-checked" is "true"
- select: checkbox "I agree"
- verify: checkbox "I agree" attribute "aria-checked" is "false"
Use toggle for that (below). deselect is the mirror image — set to off,
also idempotent.
4a. toggle — "Does this control actually flip?"
Permalink to 4a. toggle — "Does this control actually flip?"Activates a control and asserts its state changed. This is the verb for checkboxes, switches, toggle buttons and disclosures:
- toggle: checkbox "Subscribe" # was false -> must now be true
- toggle: checkbox "Subscribe" # was true -> must now be false
It catches a real and common component bug: a handler written setChecked(true)
instead of setChecked(!checked), so the control only ever turns on. select
agrees with that broken component and a working one alike, because it is
idempotent by definition.
You can write the same test as activate plus two verifys, and that works.
But the pairing is load-bearing and nothing enforces it — drop either verify
and the case still passes, still reads as a toggle test, and no longer tests
toggling. toggle cannot be written that way by accident.
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 actually has. When it
carries both, say which you mean:
- toggle: button "Mute" attribute "aria-pressed"
- toggle: button "Show details" attribute "aria-expanded"
Tri-state checkboxes work: toggle asserts the value changed, not that it
strictly inverted, so the APG's mixed -> false -> true cycle passes at every
step while a stuck control still fails.
5. activate — "Click or press this"
Permalink to 5. activate — "Click or press this"Triggers a button or link.
- activate: button "Submit"
- activate: link "Dashboard"
- activate: button "Submit" via keyboard
- activate: link "Report" new_tab true
- activate: button "Delete" force true
Modifiers:
| Modifier | Values | Purpose |
|---|---|---|
via |
keyboard |
Uses Enter/Space keypress instead of mouse click |
new_tab |
true/false |
Expects the action to open a new tab |
force |
true/false |
Clicks even when the element is disabled |
via keyboard is important for accessibility testing. Some controls respond
to click but not keyboard activation — that's a bug this modifier catches.
keyboard is the only argument via takes, and a typo is a parse error rather
than a silent fallback to clicking:
- activate: button "Submit" via keybord # error: via requires "keyboard"
force true is for negative cases. A control with aria-disabled="true"
fails Playwright's enabled check, so a plain activate times out even on a page
behaving correctly. force true drives the click anyway, so the case can assert
that nothing happened — pair it with a verify of the state you expect to be
unchanged. It can't be combined with via keyboard (keypresses are never
blocked by disabled state, so there is nothing to force).
6. verify — "Assert a condition"
Permalink to 6. verify — "Assert a condition"The most versatile keyword, with many sub-types:
Page-level checks:
- verify: url "/dashboard"
- verify: url "{{ dashboard_url }}"
- verify: title "Dashboard — My App"
Content checks:
- verify: text "Registration successful"
- verify: heading "Welcome" level 1
Element state checks:
- verify: visible button "Submit"
- verify: hidden dialog "Confirmation"
- verify: enabled button "Next"
- verify: disabled button "Submit"
- verify: checked checkbox "Remember Me"
- verify: focus field "Email"
Form validation checks (crucial for accessibility):
# Checks role="alert" contains this text
- verify: alert "Please correct the errors below"
# Checks aria-invalid="true" AND aria-describedby/aria-errormessage
- verify: field_error "Password"
# Checks the current value of a field
- verify: field "Email" has_value "[email protected]"
Attribute checks — eight predicate forms, not just equality:
# Literal equality
- verify: field "Email" attribute "aria-required" is "true"
- verify: button "Menu" attribute "aria-expanded" is "true"
# Attribute is present and non-empty (any value)
- verify: combobox "Country" attribute "aria-activedescendant" present
# Attribute is not on the element at all. Not the complement of `present`:
# an empty value (alt="") is neither — assert that one with is ""
- verify: button "Save" attribute "title" absent
# Attribute equals the value, OR is omitted entirely
- verify: field "Email" attribute "aria-invalid" is_or_absent "false"
# Value starts with a literal prefix
- verify: button "Open menu" attribute "aria-controls" starts_with "menu-"
# Value matches a regex
- verify:
listbox "Items" attribute "aria-activedescendant" matches "option-\\d+"
# Every id token in the value resolves to an existing element on the page
- verify: dialog "Confirm" attribute "aria-describedby" references_existing_id
# Numeric value within range read from two other attribute names
- verify:
slider "Volume" attribute "aria-valuenow" within_range_of "aria-valuemin"
"aria-valuemax"
When to reach for is_or_absent. For a lot of ARIA state attributes, the
spec default is a real value and leaving the attribute off is the conformant
way to express it. A healthy field that has never been submitted simply has no
aria-invalid. So the obvious assertion fires on exactly the markup it was
written to bless:
# Fails on a perfectly conformant field: getAttribute returns null, not "false"
- verify: field "Email" attribute "aria-invalid" is "false"
# Passes on both encodings, and still fails on aria-invalid="true"
- verify: field "Email" attribute "aria-invalid" is_or_absent "false"
That applies to aria-invalid, aria-disabled, aria-required,
aria-readonly and aria-busy.
It does not apply to aria-expanded, aria-pressed, aria-checked,
aria-selected or aria-hidden. Those default to undefined, so a missing
attribute is genuinely different from "false" and is usually a real bug — a
disclosure button with no aria-expanded tells a screen reader nothing. is
deliberately does not resolve ARIA defaults, so those assertions keep failing
when the attribute is absent. Opt into the lenient reading one assertion at a
time, only where the default is real.
absent is useful on its own too — asserting aria-hidden is not set on a
focusable element, or that a title-tooltip crutch was removed.
The references_existing_id form is the right shape for ARIA relationship
attributes — aria-labelledby, aria-describedby, aria-errormessage,
aria-controls — because the meaningful check is that the reference resolves,
not that it holds a specific id literal.
Count checks — the accessible name is optional:
- verify: count link "Remove" is 3
- verify: count main is 1 # exactly one main landmark
- verify: count role "tablist" is 1 # exactly one tablist on the page
Live region checks:
# Matches every ARIA live region on the page — an element with role="status",
# "alert", "log", "marquee" or "timer", or any aria-live other than "off"
- verify: live_region "3 results found"
# Restrict to one role when the case is about announcement behaviour
- verify: live_region role "alert" "Payment failed"
The unqualified form searches every live region, so a page with a
save-status region and an error alert can be asserted against either. Add
role "..." when the case specifically means the assertive one —
role="status" and role="alert" differ in politeness, and a suite testing
what a screen reader announces has a real reason to tell them apart.
One catch with the qualifier: a region that is a live region because it carries
aria-live and nothing else has no role to name, so adding role "..." skips
exactly those. Use the unqualified form when the page announces that way.
When no region carries the text, the failure lists every region it did find, so you can see whether the text is missing or simply somewhere else.
Download checks:
- verify: download "report.pdf"