Testing Without a Mouse: Interaction Profiles
Part 7 of "Testing the Way Users Actually Experience the Web" — a tutorial
series on @afixt/usecase-runner.
Playwright can hover. It can hover over anything, at any time, with perfect precision. That's a problem.
A user navigating by keyboard, by switch access, or by voice control cannot hover. A mega-menu that opens on pointer-over, a tooltip that only appears on mouse-enter, a carousel that pauses on hover and otherwise auto-advances past the button you're trying to reach — these are all flows that Playwright completes happily and a large class of real users cannot complete at all.
A test that hovers is making a claim about the page that's only true for people with a pointing device. Interaction profiles make that claim explicit and let you refuse it.
The verbs that need a mouse
Permalink to The verbs that need a mouseusecase-runner has exactly two pointer-only verbs:
- hover: button "Save"
- hover_out: button "Save"
They're mouse-only on purpose — there is no keyboard equivalent of hovering, and
pretending otherwise (say, by mapping hover to focus) would hide the exact
defect these verbs exist to surface. Use them when the thing under test
genuinely gates on pointer hover: tooltip exposure, hover-to-pause patterns,
WCAG 1.4.13 content-on-hover behaviour.
Everything else in the DSL — focus, activate ... via keyboard, keyboard:,
type: — is keyboard-driven or keyboard-capable.
The profile
Permalink to The profilenpx usecase-runner run ./usecases --profile no-pointer
Under no-pointer, any hover or hover_out step fails before it touches
the page, with a distinct failure reason:
2 hover: button "Products" FAIL failure_reason: profile_forbidden
Step uses "hover", which the "no-pointer" interaction profile forbids.
The flow cannot be completed without a pointer.
profile_forbidden is separate from an ordinary assertion failure in the
report, so you can tell "this flow requires a pointer" from "this assertion
didn't hold." The enforced profile is also recorded on the result
(UseCaseResult.profile), so a report always says which constraints actually
ran — never the ones that were merely requested. That rule was borrowed from the
screen-reader driver (sr_says records whether it used a virtual or real screen
reader) and it applies here for the same reason: a result that doesn't say how
it was produced can't be trusted.
The name is precise. no-pointer is not keyboard-only: it forbids pointer
verbs, it doesn't drive the page by keyboard for you. Writing the keyboard path
is still your job — which is the point.
Tutorial: the mega-menu
Permalink to Tutorial: the mega-menu<nav aria-label="Main">
<button class="menu-trigger">Products</button>
<ul class="mega-menu">
<!-- shown via .menu-trigger:hover + .mega-menu -->
<li><a href="/products/widgets">Widgets</a></li>
<li><a href="/products/gadgets">Gadgets</a></li>
</ul>
</nav>
A use case written by someone testing with a mouse:
id: nav-to-widgets
title: 'Reach the Widgets product page via the Products menu'
type: positive
start_location: 'https://app.example.com'
expected_result: 'The Widgets page loads'
preconditions: []
steps:
- locate: button "Products" within navigation "Main"
- hover: button "Products" within navigation "Main"
- locate: link "Widgets" within navigation "Main"
- activate: link "Widgets" within navigation "Main"
- verify: url "/products/widgets"
Run unrestricted: five passes. Run with --profile no-pointer: step 2 fails
with profile_forbidden, and the rest cascade. The report is now telling the
truth — this flow cannot be completed without a mouse.
Writing the flow a keyboard user would take
Permalink to Writing the flow a keyboard user would takeRewrite the case to describe what should work for everyone, and the page's defects show up as ordinary assertion failures:
steps:
- locate: button "Products" within navigation "Main"
- focus: button "Products" within navigation "Main"
- verify: button "Products" attribute "aria-expanded" is "false"
- activate: button "Products" within navigation "Main" via keyboard
- verify: button "Products" attribute "aria-expanded" is "true"
- locate: link "Widgets" within navigation "Main"
- focus: link "Widgets" within navigation "Main"
- activate: link "Widgets" within navigation "Main" via keyboard
- verify: url "/products/widgets"
Against the hover-only markup, step 3 fails (no aria-expanded — absence is a
defect, see Part 5), step 5 fails (activating the button does nothing), and step
7 fails (the links are display: none until hover, so they can't be focused).
Three findings, each with a clear fix: add aria-expanded, open the menu on
click/Enter, show it on :focus-within as well as :hover.
This case passes under both profiles once the page is fixed. That's the target state: a use case that describes a universally completable flow, and a profile that guarantees no one slipped a pointer dependency back in.
Keeping the hover test and the keyboard test
Permalink to Keeping the hover test and the keyboard testSometimes the hover behaviour is legitimately the thing under test — a tooltip that must appear on hover per 1.4.13, say. Keep that case, and pair it with a focus-driven companion:
# tooltip-on-hover.uc.yaml — runs only in unrestricted mode
- hover: button "Help"
- verify: visible tooltip "Opens the help centre"
- hover_out: button "Help"
- verify: hidden tooltip "Opens the help centre"
# tooltip-on-focus.uc.yaml — must pass under no-pointer too
- focus: button "Help"
- verify: visible tooltip "Opens the help centre"
- keyboard: 'Escape'
- verify: hidden tooltip "Opens the help centre"
Run the whole suite under no-pointer in CI and you'll get one expected
profile_forbidden on the hover case — which is the suite documenting, in a
machine-checkable way, exactly which flows depend on a pointer and that there is
a keyboard path for each.
Both modes, same answer
Permalink to Both modes, same answer--profile works on generate as well as run. The generated .spec.ts
contains a step that raises the identical message, read from the same source
file as the direct runner, so the two execution modes can never disagree about
whether a flow is pointer-dependent. Mode equivalence is a normative requirement
of the spec (§8.3), so a profile is only enforced if both sides honour it.
Next: One Flow, Many Outcomes: Extension Cases and Negative Testing