PRACTICAL GUIDE / ARIA pattern testing
Your ARIA widget has the right role and still fails
Learn to verify keyboard behavior, focus movement, names, and state in custom widgets, with evidence that separates markup defects from timing bugs.
In this guide6 sections
- Why plausible markup still produces a broken widget
- What the browser and the pattern promise
- Three failures worth reproducing before you automate broadly
- How to tell semantics, focus, and timing failures apart
- How to fix an existing suite without teaching it the bug
- When a browser-level pattern test is the wrong tool
What you will learn
- Why plausible markup still produces a broken widget
- What the browser and the pattern promise
- Three failures worth reproducing before you automate broadly
- How to tell semantics, focus, and timing failures apart
The account panel opens with a mouse, then does nothing when a keyboard user presses Space. DevTools shows role="button" and aria-expanded="false", so the markup looks plausible until the panel is visibly open and the exposed state still says closed. That is not a cosmetic mismatch. It is one control telling two different stories to two different users.
Why plausible markup still produces a broken widget
ARIA describes semantics. It does not install behavior. Adding role="button" to a div can change how accessibility software identifies the element, but the browser does not turn that div into a native button. It does not automatically place the div in the tab order, give it button activation behavior, synchronize a pressed or expanded state, or decide where focus belongs after the action. Every one of those responsibilities remains in application code.
That distinction explains a common review failure. A developer opens the Elements panel, sees the expected role and an accessible name, and closes the ticket. A tester clicks the control and sees the expected panel. Both checks pass because each observes only one half of the contract. A keyboard user reaches neither result, or an assistive-technology user receives a state that no longer matches the visible page.
Start with the native HTML decision. If the action behaves like a button, use a button. If it navigates to a location, use a link with an href. Native controls bring browser behavior, platform semantics, focusability, and established accessibility mappings. Custom code is justified for patterns such as tabs, tree views, grids, and some composite widgets, but the justification should be the interaction model, not the visual design.
The WAI-ARIA 1.2 specification defines roles, states, and properties. The Authoring Practices Guide turns those primitives into design patterns with expected keyboard interaction. They serve different purposes. A valid attribute value can still be the wrong state for the current screen, and a legitimate role can still be the wrong pattern for the product.
Treat the pattern as a state machine. A collapsed disclosure has a button, a name, an expanded state of false, and hidden controlled content. Activation changes both what is visible and what is exposed. A modal starts with an invoker on the page, moves focus inside when opened, contains its tab sequence while active, closes on Escape under the APG pattern, and normally returns focus to a logical location. A tab set has one selected tab and one corresponding panel, plus a documented activation model.
Static scans are useful at the edges of this problem. They can flag an invalid ARIA attribute, some missing names, and certain prohibited role combinations. They cannot know that the invoice panel is visibly open while aria-expanded remains false. They also cannot decide whether a tab set uses automatic or manual activation, because both can be valid when implemented consistently. That answer comes from the chosen product contract and an interaction test.
A reliable oracle therefore combines four observations: what the user can see, which element owns focus, what role and name the browser computes, and which state the component exposes. Leaving out any one of them creates a test that can pass while the widget is still broken.
What the browser and the pattern promise
Three WCAG 2.2 criteria often meet in these failures. Success Criterion 2.1.1 Keyboard is Level A and requires functionality to be operable through a keyboard interface, apart from its stated exception for input that depends on the path of movement. Success Criterion 2.4.3 Focus Order is Level A and covers an order that preserves meaning and operability when navigation sequences affect either. Success Criterion 4.1.2 Name, Role, Value is Level A and requires user interface components to expose their name and role, allow user-settable states and values to be set programmatically, and notify user agents of changes. The WCAG 2.2 quick reference is the right place to confirm the number and level before a defect cites one.
Those criteria state outcomes, not a universal keystroke table. Keyboard behavior comes from the control or pattern. A native button supports Enter and Space activation. A disclosure uses a button, so the same activation behavior applies while aria-expanded reports whether its content is open. Arrow keys are not a mandatory disclosure command merely because another composite widget uses them.
Modal dialogs have a different contract. The APG modal dialog pattern says focus moves to an element inside when the dialog opens. Tab and Shift+Tab remain within its tab sequence, wrapping at the ends, and Escape closes it. The container has role dialog, aria-modal="true", and an accessible name supplied by aria-labelledby or aria-label. Initial focus is contextual. A short confirmation may focus a suitable button, while a long structured dialog may initially focus a static heading with tabindex="-1" so its content is not announced as one undifferentiated string.
That contextual note matters in test review. A generic rule saying every dialog must focus the first interactive element will reject valid designs and can produce a worse reading experience. The test should name the target chosen for this dialog and why. For a destructive confirmation, a team might put initial focus on Cancel. For a form, it might choose the first field with an error. The automation verifies the recorded choice rather than inventing one.
Tabs bring another fork. Under the APG tabs pattern, Tab enters the tab list on the active tab. Left and Right Arrow move among tabs in a horizontal list. A design may activate a tab automatically when it receives focus, or it may use manual activation in which Space or Enter selects the focused tab. Automatic activation is recommended only when panels appear without noticeable latency. A test that presses Right Arrow and always expects selection to remain unchanged silently assumes the manual model.
Names deserve the same precision. A locator that finds a button by accessible name is useful evidence that the browser can identify it, but it does not prove the label is good in context. Five buttons named More may all be technically named and still be impossible to distinguish. The expected name should come from the task, such as More options for invoice 4831, and the test should include repeated controls to expose accidental ambiguity.
State assertions should be paired with visible consequences. When aria-expanded becomes true, the intended panel should be visible. When aria-selected moves to a new tab, the corresponding panel should appear and the old panel should no longer be presented as active. When a modal closes, it should be hidden and focus should land where the workflow requires. Paired assertions catch both stale metadata and metadata that changes without the interface following it.
Three failures worth reproducing before you automate broadly
The first worked example is a disclosure whose author used a div because the design system wanted a custom icon. The div received role="button", tabindex="0", and an Enter handler. Mouse and Enter tests passed. Space scrolled the page. A second defect appeared after an animation refactor: the panel became visible, but aria-expanded was updated only in one event path.
The smaller fix is to use a real button and let its click event represent pointer and keyboard activation. One function then synchronizes the exposed state and the hidden property. The function below is complete browser-side TypeScript. It assumes the button starts with a valid aria-expanded value and references the panel through aria-controls. It deliberately does not add a keydown handler because the native button already owns activation behavior.
export function wireDisclosure(button: HTMLButtonElement): void {
const panelId = button.getAttribute('aria-controls');
if (!panelId) {
throw new Error('Disclosure button needs aria-controls');
}
const panel = document.getElementById(panelId);
if (!panel) {
throw new Error('Disclosure panel was not found: ' + panelId);
}
const render = (expanded: boolean): void => {
button.setAttribute('aria-expanded', String(expanded));
panel.hidden = !expanded;
};
render(button.getAttribute('aria-expanded') === 'true');
button.addEventListener('click', () => {
render(button.getAttribute('aria-expanded') !== 'true');
});
}What change makes this fail? Removing aria-controls throws during setup. Removing the panel produces a different setup error. Failing to update aria-expanded is caught by the browser test. Failing to change hidden is caught separately. The oracles are not tautologies because they observe application output after an input; none merely checks a hard-coded fixture against itself.
Run the behavior through the keyboard, not by dispatching a synthetic click from test code. The following test assumes a product route with a Billing details disclosure and a named region. The exact URL and copy are product data, while the APIs and assertions are standard Playwright. Notice that Space opens the panel and Enter closes it. That sequence proves both activation paths reach the same state transition.
import { expect, test } from '@playwright/test';
test('billing disclosure keeps visible and exposed state together', async ({ page }) => {
await page.goto('/settings/billing');
const toggle = page.getByRole('button', { name: 'Billing details' });
const panel = page.getByRole('region', { name: 'Billing details' });
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(panel).toBeHidden();
await toggle.focus();
await toggle.press('Space');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(panel).toBeVisible();
await toggle.press('Enter');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(panel).toBeHidden();
});The trade-off is maintenance of a user-facing name. If product copy changes from Billing details to Payment details, the locator fails even though the control may still work. That failure can be valuable because names are part of the user contract. If editorial changes are intentionally frequent and not behaviorally important, locate a stable component boundary first, then use role and name within it rather than weakening the test to a CSS class.
The second worked example is a delete-project dialog. Its markup has role dialog and aria-modal="true", but opening it leaves focus on the page behind the overlay. Mouse testing looks fine. Keyboard users continue through hidden navigation, and Escape does nothing because the dialog never became the active interaction context.
For this product, the agreed focus path is specific: Cancel receives initial focus, Shift+Tab wraps to Delete project, Escape closes, and focus returns to the button that opened the dialog. That is narrower than a generic modal rule and therefore testable. If the design changes the initial target after a usability review, the requirement and test should change together.
import { expect, test } from '@playwright/test';
test('delete confirmation contains focus and returns it to the invoker', async ({ page }) => {
await page.goto('/projects/atlas/settings');
const invoker = page.getByRole('button', { name: 'Delete Atlas project' });
await invoker.click();
const dialog = page.getByRole('dialog', { name: 'Delete Atlas project' });
const cancel = dialog.getByRole('button', { name: 'Cancel' });
const confirm = dialog.getByRole('button', { name: 'Delete project' });
await expect(dialog).toBeVisible();
await expect(cancel).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(confirm).toBeFocused();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(invoker).toBeFocused();
});This case can distinguish a focus-placement bug from a focus-trap bug. If Cancel is not focused immediately, the initial placement failed. If initial focus is correct but Shift+Tab leaves the dialog, containment failed. If both pass but focus disappears after Escape, restoration failed. One long assertion saying the modal works would provide none of that diagnostic separation.
The third example is a manual-activation tab set used for audit logs. A refactor updates aria-selected on ArrowRight but forgets to reveal the new panel, creating a semantic-only selection. Another version reveals the panel on focus, effectively changing the widget to automatic activation while the help text still tells users to press Enter. Both can look like a wrong-panel bug in a screenshot.
Record the chosen activation model before testing it. In this case ArrowRight moves focus but does not select. Enter then selects the focused tab, reveals its panel, and hides the previous panel. The test checks the intermediate state because that is where manual and automatic activation differ.
import { expect, test } from '@playwright/test';
test('audit tabs use the documented manual activation model', async ({ page }) => {
await page.goto('/audit');
const eventsTab = page.getByRole('tab', { name: 'Events' });
const exportsTab = page.getByRole('tab', { name: 'Exports' });
const eventsPanel = page.getByRole('tabpanel', { name: 'Events' });
const exportsPanel = page.getByRole('tabpanel', { name: 'Exports' });
await eventsTab.focus();
await eventsTab.press('ArrowRight');
await expect(exportsTab).toBeFocused();
await expect(exportsTab).toHaveAttribute('aria-selected', 'false');
await expect(eventsPanel).toBeVisible();
await exportsTab.press('Enter');
await expect(exportsTab).toHaveAttribute('aria-selected', 'true');
await expect(exportsPanel).toBeVisible();
await expect(eventsPanel).toBeHidden();
});Manual activation costs an extra key press. Automatic activation removes that press but requires panels to load fast enough that moving focus does not create disruptive delay. The right choice depends on the interface. The defect is not choosing one over the other; it is implementing an inconsistent mixture or testing a choice nobody made.
How to tell semantics, focus, and timing failures apart
Begin from the first mismatched transition. A screenshot taken after the final step often hides the cause because focus is invisible and state may have changed again. Preserve the trace on the first attempt, then inspect the action immediately before the assertion. Check the DOM snapshot, the target resolved by the role locator, the active element, the state attribute, and the controlled content at that point.
When getByRole cannot find a control, split the diagnosis. Inspect whether the element exists in the DOM. If it exists, check its computed role and accessible name through the browser's accessibility tooling. A missing match may be a semantic defect, a naming defect, an element hidden from the accessibility tree, or simply a test using stale copy. Do not respond by switching immediately to a data-testid. That can make the test pass while discarding the user-facing contract it was meant to protect.
When the role locator succeeds but locator.press() has no effect, inspect the resolved element, its focus state, and the event path. locator.press() focuses the target and dispatches keyboard input. It performs no pointer hit testing, so an overlay covering the element is not relevant to this action, and the method has no force option. A generic element with only a click handler can still ignore Enter or Space, while a node replaced during hydration can change which event path receives the input. The trace action details and DOM snapshot tell these cases apart.
Focus failures need an explicit active-element record. Playwright's toBeFocused assertion gives a clear expected target, but a trace also lets you inspect whether the expected node was removed and recreated. If a framework rerenders the invoker on close, application code may retain a reference to a detached node and call focus on something no longer in the document. The symptom resembles a missing focus call, while the fix is to locate the current node or preserve it across the state change.
Timing bugs usually leave a different trail. The first DOM snapshot may show aria-expanded updated before an animation reveals the panel, followed by the correct final state. A web-first assertion waits for its condition, so it may pass if the eventual state is the contract. If the requirement says the two changes must be atomic enough to avoid an inconsistent interactive period, observe the component event or remove the unnecessary animation dependency rather than using a zero-timeout assertion that will vary by machine.
Run a focused case with a retained trace and no retries while investigating:
npx playwright test tests/a11y/disclosure.spec.ts --project=chromium --workers=1 --retries=0 --trace=retain-on-failureThe useful evidence is not merely that the command failed. Record the browser, route, widget name, input key, focused element before and after, exposed state before and after, visible result, and trace attachment. For a tabs defect, add the activation model. For a dialog, add the intended initial and return targets. This information lets another engineer reproduce the contract without guessing what the author meant by keyboard navigation is broken.
A near-miss deserves special attention: an element may be unreachable by Tab but still pass a test that calls locator.focus(). Programmatic focus proves that the node can receive focus under that API call, not that a user reaches it in page order. Add a traversal test for the critical path, or start from the preceding control and press Tab. Conversely, a full-page Tab tour is expensive and brittle, so keep it for navigation boundaries and high-risk composites rather than repeating it for every button.
Another near-miss is a hidden panel that remains in the accessibility tree or tab order. Visual assertions alone miss it. Role locators exclude hidden elements by default according to their accessibility rules, which is useful, but a focusable descendant can expose an implementation mistake. After collapse, move through the surrounding tab sequence and verify focus skips the panel. Do not add includeHidden merely to make an assertion easier unless the test is intentionally examining hidden structure.
How to fix an existing suite without teaching it the bug
Inventory custom widgets before writing a broad rule. Group them by actual pattern: disclosures, dialogs, tabs, menus, comboboxes, trees, and application-specific composites. A component named Dropdown may implement a disclosure in one place and a menu in another. Classifying by component name alone spreads the wrong keyboard expectations.
Choose one representative instance per implementation, not per page. Write a compact contract test at the component or browser level, then add journey tests only where focus restoration, dynamic naming, or surrounding page state changes the behavior. This keeps failures local. It also prevents dozens of pages from asserting the same low-level arrow-key loop while missing the one checkout dialog whose invoker disappears after completion.
Roll out in three passes. First, capture current behavior and review it against the relevant APG pattern without changing the gate. Mark each mismatch as a product defect, a test defect, or an intentional documented deviation that still meets user needs. Second, fix the shared component and add one regression for each independent failure mode. Third, enable the gate for new uses of that component and migrate older instances in manageable groups.
Do not baseline broken accessibility snapshots merely to get a green build. A snapshot is useful when its scope is small enough for a reviewer to understand why each role and name belongs. Large page snapshots create noisy updates, and reviewers learn to approve diffs without following the focus or behavior. Keep explicit interaction assertions beside snapshot coverage because a static tree cannot prove what Enter, Space, ArrowRight, Tab, or Escape does.
CI should retain first-failure evidence. Retries can be appropriate for unrelated environmental instability, but an accessibility interaction test that passes only on retry still found a problem. A clean configuration for this small group uses no retries and keeps trace and screenshot artifacts only when the test fails:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/a11y',
retries: 0,
reporter: [['line'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure'
}
});This evidence has a storage and execution cost. Keyboard flows take more actions than attribute checks, traces consume artifact space, and accessible-name locators intentionally fail when meaningful copy changes. Pay that cost for shared widgets and critical journeys. Use smaller component tests for exhaustive state combinations, then reserve cross-page traversal and manual assistive-technology sessions for risks a headless browser cannot settle.
Manual review should use supported browser and assistive-technology combinations chosen by the team. It should not become an unbounded instruction to test everything with every screen reader. Select the combinations that represent supported users, record the task and expected announcement or interaction, and preserve defects as focused regressions where automation can observe the underlying contract.
When a browser-level pattern test is the wrong tool
Do not build a custom ARIA widget solely to make the application resemble an APG example. The APG examples explain patterns; they are not a component library, and production requirements may be better served by native HTML. If a button, details element, select, or ordinary group of links handles the task, reducing custom interaction code is a stronger fix than expanding the automation suite.
Avoid a full browser test for a pure mapping function that converts component state into attributes. A component test can exercise every state faster and show a narrower failure. Keep one browser case to prove the rendered integration, especially where focus and browser behavior matter, then let lower-level tests cover combinatorial inputs.
Do not use role locators as a claim of WCAG conformance. Playwright explicitly notes that role selectors do not replace accessibility audits and conformance tests. They are excellent for testing the same role and name a user relies on, but they do not judge every success criterion, announcement, reading order, contrast issue, or assistive-technology interaction.
Skip generic key matrices that are detached from a pattern. Pressing every key on every control creates volume without an oracle. Enter and Space matter for buttons. Arrow keys have pattern-specific meanings. Escape is expected for the APG modal dialog, while its behavior elsewhere depends on the component. A small, justified matrix catches more real defects than a universal list.
Do not assert an exact focus target when the requirement intentionally allows several good choices. First prove the invariant that focus moves inside the dialog and remains there. Add an exact target only when product behavior selects one. Overly narrow automation can lock in an arbitrary implementation and fight later accessibility improvements.
Finally, do not hide a failure with a fixed delay. Waiting half a second may make a panel animation finish on one machine, but it neither proves keyboard support nor synchronizes visible and exposed state. Wait for the observable state the user needs, capture the first failing transition, and fix the ownership boundary. The goal is not a test that eventually turns green. It is a widget whose semantics, keyboard behavior, focus, and visible result agree at every meaningful step.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 02Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 03Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 04Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How do I know which ARIA pattern a custom widget should follow?
Match the interaction the product offers to a pattern in the WAI-ARIA Authoring Practices Guide. Then test that pattern's role, accessible name, states, keyboard commands, and focus path as one contract.
Does the correct role prove that a widget is accessible?
No. A role describes what a control is, but it does not add keyboard handling, focus management, or state synchronization. A control can expose role button and still be unusable without a mouse.
Should a modal dialog always focus its first button?
Focus placement depends on the dialog's content and task. The APG requires focus to move inside, while the product team should choose and document the most useful initial target for that specific dialog.
Can Playwright replace a screen reader check?
Browser automation catches stable contracts such as roles, names, states, visibility, and focus movement. It cannot establish the quality of every browser and assistive-technology combination, so critical journeys still need focused manual coverage.
Why does a custom control work with Enter but fail with Space?
Native buttons already implement both activation keys, while a generic element only has the handlers its author wrote. Replacing the element with a button is usually safer than recreating button behavior in JavaScript.
RELATED GUIDES
Continue the learning route
GUIDE 01
How to Test ARIA Labels
Learn how to test ARIA labels: accessible names, aria-label vs aria-labelledby, roles, states, screen reader checks, and common ARIA mistakes.
GUIDE 02
Playwright ARIA Snapshots for Testing Accessible Structure
Use Playwright ARIA snapshots to verify roles, names, states, and hierarchy with scoped templates, deliberate strictness, reviewable updates, and layered a11y tests.
GUIDE 03
Playwright testInfo errorContext ARIA Snapshot Debugging
Learn Playwright testInfo errorContext aria snapshot with working code, failure cases, debugging steps, and CI evidence for reliable QA automation.
GUIDE 04
Page Object Model: A Pattern for Maintainable Tests
Learn the page object model for maintainable UI automation tests, with Playwright examples, best practices, and common POM mistakes to avoid.