skip to content

Why does page.getByRole('button', { name: 'Delete issue' }) find nothing in Playwright when that button is in the DOM?

level: seniorimportance: should knowfreq 57%

answer

  1. Default hides more than you think
  2. Ancestors count, not just the element
  3. One option opts back in
  4. Opting in changes the name too
  5. Matching is not the same as clickable

basics

~10 s

Most often the element is hidden for accessibility purposes. Role queries skip anything with aria-hidden, display none, visibility hidden or the hidden attribute, on itself or an ancestor, unless includeHidden is true.

solid answer

~40 s

Role matching runs over the accessibility view, and by default `includeHidden` is `false`, so an element is skipped when it or an ancestor has `aria-hidden="true"`, is `display: none`, is `visibility: hidden`, or carries the `hidden` attribute. A collapsed row menu or a mounted-but-hidden dialog therefore vanishes from the query even though the node exists. Passing `includeHidden: true` matches hidden candidates too — with two caveats: it usually increases the match count, because apps keep several panels mounted, and it also changes accessible-name computation by letting hidden subtrees contribute text. It does not make the element operable; actions still require visibility. If `includeHidden` does not help, the other suspects are a wrong role, a name that differs from the visible text, or an element inside an iframe.

code

typescript · 17 lines
typescript
import { test, expect } from '@playwright/test';

test('delete lives inside a collapsed row menu', async ({ page }) => {
  await page.goto('/board');

  // Default: hidden candidates are skipped entirely.
  await expect(page.getByRole('button', { name: 'Delete issue' })).toHaveCount(0);

  // Opt in to assert presence without acting on it.
  await expect(
    page.getByRole('button', { name: 'Delete issue', includeHidden: true }),
  ).toHaveCount(1);

  // Open the menu, then use the honest default.
  await page.getByRole('button', { name: 'Issue actions' }).click();
  await page.getByRole('button', { name: 'Delete issue' }).click();
});

go deeper

for a junior

Know that being in the DOM is not enough. Playwright role queries skip elements hidden from assistive technology, so open the menu or expand the panel before locating the control inside it.

for a middle

Explain the four ways an element counts as hidden, that an ancestor is enough to hide a subtree, and what includeHidden changes about both the match set and the name computation.

for a senior

Show a disciplined diagnosis: relax the query one facet at a time and count matches, so the step where the count drops names the cause instead of guessing between role, name and visibility.

for a principal

Own the rule for when includeHidden is acceptable at all. Left unchecked it converts user-facing assertions into DOM-presence assertions, and the suite stops proving that features are reachable.

When a `page.getByRole` locator finds nothing for an element you can see in the DOM inspector, there are only a few possible causes, and one of them is by design: the role engine **ignores elements that are hidden for accessibility purposes**. ## The default: hidden elements are skipped `includeHidden` defaults to `false`. An element is treated as hidden — and therefore not matched — when any of the following holds: - it or an ancestor has `aria-hidden="true"`; - it or an ancestor is `display: none`; - it has `visibility: hidden` (or inherits it); - it carries the HTML `hidden` attribute. That default matches what a screen-reader user experiences, which is why a collapsed row menu, an issue-detail panel behind a closed accordion, and a modal that is still mounted but `aria-hidden` all disappear from `getByRole` results even though the nodes exist. ## Opting in `page.getByRole('button', { name: 'Delete issue', includeHidden: true })` matches hidden candidates as well as visible ones. Two consequences are worth knowing: 1. Turning it on usually **increases** the number of matches, because a single-page app often keeps several copies of a panel mounted; an ambiguous locator is a common side effect of adding the option. 2. `includeHidden` also changes how the accessible name is computed: hidden subtrees are then allowed to contribute text, so an element's name under `includeHidden: true` can differ from its name without it. Matching a hidden element does not make it operable. Clicking still requires the element to become visible, so `includeHidden: true` is mostly useful for asserting that something exists in a hidden state, or for debugging why a locator misses. ## Working through the diagnosis | Symptom | Likely cause | Check | |---|---|---| | Node visible in DevTools, no match | ancestor `aria-hidden` or `display: none` | retry with `includeHidden: true` | | Matches with `includeHidden`, fails to click | element genuinely not visible yet | wait for the panel to open first | | No match either way, name looks right | role is not what you assumed | drop the `name` option and count by role | | No match either way, role is right | accessible name differs from the visible text | drop the role filter and inspect the name | A useful ordering when you are stuck: relax the query one facet at a time. Ask for the role alone and count the matches; then add `includeHidden`; then add the name back. The step where the count drops to zero names the cause. ## Other causes that look like the same failure - **The name is not the text.** An icon-only delete control labelled `aria-label="Remove"` has the name `Remove`, so `{ name: 'Delete issue' }` never matches, hidden or not. - **The role is not what the markup suggests.** A `<div>` styled as a button has no role at all; an `<a>` with no `href` is not a `link`. - **The element is inside an iframe.** A page-level query does not descend into a nested browsing context, so the search root has to be the frame. - **The element has not rendered yet.** A locator is lazy: it resolves when it is used, and Playwright retries until the action or assertion times out. A genuine "not found" here means it never appeared within the budget, and the trace shows the retries. ## Why not just turn it on everywhere Making `includeHidden: true` a habit trades one failure for a worse one. Tests then pass against markup a real user cannot reach: a button in a closed menu, a form in a hidden tab panel, a stale copy of a dialog. The default is the honest one — it asserts what a person can perceive — and every deliberate use of the option is a statement that this particular check is about presence in the accessibility tree rather than about what the user sees. ```ts // Presence in a collapsed panel: assert, do not act await expect( page.getByRole('button', { name: 'Delete issue', includeHidden: true }), ).toHaveCount(1); // Interaction: open the menu first, then use the default await page.getByRole('button', { name: 'Issue actions' }).click(); await page.getByRole('button', { name: 'Delete issue' }).click(); ```

  • Why is making includeHidden: true a default habit a bad idea?
    Because the suite then passes against markup a user cannot reach: a control in a closed menu, a form in a hidden tab panel, a stale copy of a dialog. The default asserts what a person can perceive. Each deliberate use should say why this check is about presence in the tree rather than about the visible page.
  • How does includeHidden change the accessible name, not just the match set?
    With it on, hidden subtrees are allowed to contribute text to the name computation. An element whose label includes a visually hidden span can therefore compute a different name with the option on than with it off, so a name string that worked in one mode may need adjusting in the other.
  • How do you narrow down a miss when includeHidden does not help?
    Relax the query one facet at a time and count. Ask for the role alone; if that is already zero the role assumption is wrong or the element is in an iframe. If the role count is fine, drop the name and inspect what Playwright computed, since an icon-only control is usually named by an aria-label that differs from the tooltip you read.

saying these in an interview costs you the question

  • Thinks getByRole matches anything present in the DOM
  • Only checks the element itself, never its ancestors
  • Sets includeHidden: true everywhere to make tests pass
  • Believes includeHidden makes a hidden element clickable
  • Assumes a no-match always means the app is broken