locate, focus, activate: The Three Questions Every Interactive Element Must Answer
Part 3 of "Testing the Way Users Actually Experience the Web" — a tutorial
series on @afixt/usecase-runner.
Look at any use case written with usecase-runner and you'll see the same three lines over and over:
- locate: button "Submit"
- focus: button "Submit"
- activate: button "Submit"
New users ask the obvious question: why three steps? Playwright's click() will
find the element and click it in one call. Aren't locate and focus
redundant?
No — because each verb asks a different question, and each question is one a real user has to get a "yes" to before they can do anything.
Question 1: Can the user find it? (locate)
Permalink to Question 1: Can the user find it? (locate)- locate: button "Submit"
generates
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
This asks: is there an element in the accessibility tree with role button and
accessible name "Submit", and is it visible? A screen-reader user pulling up a
list of buttons, or a voice-control user saying "click Submit", needs exactly
this to be true. It fails on <div onclick>, on an icon button with no label,
on a button whose visible text is inside an aria-hidden span, and on a button
that's rendered but display: none.
Question 2: Can the user reach it? (focus)
Permalink to Question 2: Can the user reach it? (focus)- focus: button "Submit"
generates
const el = page.getByRole('button', { name: 'Submit' });
await el.focus();
await expect(el).toBeFocused();
The second line is the one that matters. Calling .focus() is a request.
toBeFocused() checks whether the request was honoured. An element covered by
an overlay, an element inside an inert subtree, a custom control that steals
focus back to a hidden input — all of those accept the .focus() call and then
aren't focused.
A keyboard user can't click. If they can't get focus onto the control, the
control does not exist for them. locate passing and focus failing is one of
the most common findings this tool produces, and it's one that visual QA and
CSS-selector tests are structurally blind to.
What focus does not tell you. It establishes programmatic focus, which
is not the same as being reachable by keyboard. tabindex="-1" is the clearest
case: it makes an element focusable by script and removes it from the tab order,
so .focus() succeeds, toBeFocused() passes, and a workflow written as a
sequence of focus steps goes green against a page nobody can tab through.
Reachability is a separate assertion — drive the keyboard and observe where focus lands:
- focus: field "Email"
- keyboard: Tab
- verify: focus field "Password"
The manual rubric's gain focus on verb is the programmatic step. When you mean
tab to, write the keyboard / verify: focus pair.
Question 3: Can the user operate it? (activate)
Permalink to Question 3: Can the user operate it? (activate)- activate: button "Submit"
generates a click(). But you can — and for critical controls, should — ask the
keyboard version of the question:
- activate: button "Submit" via keyboard
which presses Enter on the element instead. A
<div role="button" tabindex="0"> with only a click handler will pass locate,
pass focus, and fail here. The role says "button"; the behaviour says "not
really".
via keyboard sends Enter and only Enter. Space is the other half of the button
contract and the half custom controls miss more often — a <div role="button">
wired to keydown for Enter but not Space is a real and common defect. Nothing
in activate covers it; assert it explicitly:
- focus: button "Submit"
- keyboard: ' '
- verify: url "/thanks"
Tutorial: a nav menu, and the case that misses it
Permalink to Tutorial: a nav menu, and the case that misses itHere's a site navigation that looks fine and works with a mouse:
<nav aria-label="Main">
<ul>
<li><a href="/products" tabindex="-1" class="nav-link">Products</a></li>
<li><a href="/pricing" tabindex="-1" class="nav-link">Pricing</a></li>
<li><a href="/docs" tabindex="-1" class="nav-link">Docs</a></li>
</ul>
</nav>
(Someone added tabindex="-1" to "fix" a focus-ring complaint from design. This
happens more than you'd think.)
The obvious case looks like this:
id: nav-pricing
title: 'Reach the Pricing page from main navigation'
type: positive
start_location: 'https://app.example.com'
expected_result: 'The Pricing page loads'
preconditions: []
steps:
- locate: navigation "Main"
- locate: link "Pricing" within navigation "Main"
- focus: link "Pricing" within navigation "Main"
- activate: link "Pricing" within navigation "Main" via keyboard
- verify: url "/pricing"
- verify: heading "Pricing" level 1
It passes. All six steps, against markup no keyboard user can traverse.
That is worth sitting with, because it is the failure this whole series is
about. focus asks the page to focus the link and the page obliges —
tabindex="-1" removes an element from the tab order and leaves it
script-focusable. activate ... via keyboard then presses Enter on a link that
is genuinely focused, and the navigation happens. Every assertion in the case is
true. The case is still wrong, because none of them is the assertion the title
implies: reach the Pricing page.
Write the reaching instead:
steps:
- locate: navigation "Main"
- locate: link "Pricing" within navigation "Main"
- focus: link "Products" within navigation "Main"
- keyboard: Tab
- verify: focus link "Pricing" within navigation "Main"
- activate: link "Pricing" within navigation "Main" via keyboard
- verify: url "/pricing"
- verify: heading "Pricing" level 1
1 Locate navigation "Main" pass
2 Locate link "Pricing" within navigation "Main" pass
3 Focus link "Products" within navigation "Main" pass
4 Keyboard Tab pass
5 Verify focus link "Pricing" within navigation "Main" FAIL
6 Activate link "Pricing" ... via keyboard pass
7 Verify URL "/pricing" pass
8 Verify heading "Pricing" level 1 pass
Step 5 is the finding, and it is the only step that could have been. Tab from
"Products" and focus leaves the nav entirely, because none of these links is in
the tab order. Remove the tabindex="-1" and step 5 passes with the rest.
The lesson generalises past this one attribute: focus is the "can it be
focused" question, not the "can it be reached" question. Reaching is
keyboard plus verify: focus, and a case whose title says reach needs the
pair. A sequence of focus steps describes a user who can teleport.
Two smaller things worth noticing. within navigation "Main" scopes the lookup
to a landmark — useful when "Pricing" appears in both the header and the footer,
and also a free assertion that the landmark exists. And
verify: heading "Pricing" level 1 at the end checks the destination page has
a proper heading, so the success condition is also an accessibility condition.
When to use the full trio
Permalink to When to use the full trioUse all three on anything the user interacts with: links, buttons, fields (where
enter, select, or toggle plays the "operate" role), tabs, menu items. You
don't need them for things the user only reads — a verify: heading or
locate: text is complete on its own.
It's more lines. It's also the difference between a test that says "the page contains a Submit button" and a test that says "a person using only a keyboard can submit this form." Only the second one is worth running.