Resource library

QA How-To

Detect Missing Accessible Names with Playwright

Follow this playwright detect missing accessible names tutorial to build runnable tests, diagnose unnamed controls, and prevent accessibility regressions.

19 min read | 2,751 words

TL;DR

Use `getByRole()` with an expected `name` for critical controls, then run a page-level audit that finds visible interactive elements whose computed accessible name is empty. Verify failures with Playwright's accessibility-aware locators, fix the markup at its semantic source, and retain the test in CI.

Key Takeaways

  • Use role locators with a name to verify important controls through the accessibility model.
  • Add a DOM audit to discover unnamed interactive elements that known-control assertions might miss.
  • Treat visible text, associated labels, aria-label, and aria-labelledby as naming inputs, not interchangeable fixes.
  • Use Playwright aria snapshots for readable semantic contracts on stable components.
  • Run a focused name test on every pull request and broader scans on representative pages.
  • Keep an allowlist narrow, documented, and temporary so it cannot hide new defects.
  • Combine automated checks with manual screen reader review for high-risk workflows.

A playwright detect missing accessible names tutorial should give you more than an axe invocation. You need a test that discovers unnamed controls, a focused assertion that proves the intended name, and failure output that directs a developer to the broken element.

You will build that workflow with current Playwright APIs and a small browser-side audit. For the wider strategy around scans, keyboard behavior, CI, and manual review, use the Playwright accessibility automation complete guide.

The result catches icon buttons, unlabeled inputs, links without usable content, and custom interactive elements. It also avoids a common false confidence: a control can be visible and clickable while still having no name exposed to assistive technology.

TL;DR

const save = page.getByRole('button', { name: 'Save profile' });
await expect(save).toBeVisible();

const unnamed = await page.locator(
  'button, a[href], input:not([type=hidden]), select, textarea, [role=button]'
).evaluateAll((elements) =>
  elements
    .filter((element) => (element as HTMLElement).innerText.trim() === '')
    .map((element) => element.outerHTML)
);
expect(unnamed).toEqual([]);

The short audit above is only an illustration because innerText is not the accessible-name algorithm. The complete helper in Step 4 uses the browser's Accessibility Object Model when available and a standards-aware fallback for common HTML controls. Critical controls still get explicit role-and-name assertions.

Technique Best use What it proves Limitation
getByRole(role, { name }) Known critical controls A user-facing role and name can locate the control Does not discover unknown controls
toMatchAriaSnapshot() Stable component semantics The expected accessibility tree is present Snapshot scope must stay focused
Custom page audit Broad discovery Candidate interactive elements have nonempty computed names Requires careful scope and fallback logic
axe scan Rules-based coverage Supported accessibility rules pass One scan cannot prove product intent
Manual screen reader check High-risk journeys Names are understandable in context Slower and not suited to every commit

What You Will Build

By the end, you will have:

  • A Playwright TypeScript project serving a realistic profile form.
  • A fixture containing correctly named controls and deliberate naming defects.
  • Explicit assertions for the names of business-critical controls.
  • A reusable audit that reports unnamed interactive elements with useful selectors.
  • An ARIA snapshot for a stable form region.
  • A fixed fixture, a passing regression test, and a CI command.

The tutorial separates discovery from intent. Discovery asks whether any relevant control has an empty name. Intent asks whether each important control has the correct, understandable name. You need both questions because a nonempty name such as x may satisfy a mechanical audit but still be useless.

Prerequisites

Use Node.js 20 or newer and the current stable @playwright/test package. The APIs shown here are current for 2026 and avoid removed accessibility-tree methods. Create a project with TypeScript:

npm init playwright@latest
npm install --save-dev vite
npx playwright install chromium

If Playwright already exists in your repository, update it deliberately and review the release notes before merging the lockfile change:

npm install --save-dev @playwright/test@latest
npx playwright install chromium
npx playwright --version

You should see a version printed without a missing-browser error. You need permission to add playwright.config.ts, tests/fixtures/index.html, and test files in the sample project. No third-party accessibility package is required for the core tutorial.

Step 1: Configure the Playwright Detect Missing Accessible Names Tutorial

Create playwright.config.ts. Vite serves the fixture over HTTP, and the list plus HTML reporters keep local and CI failures readable.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'http://127.0.0.1:4173',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npx vite tests/fixtures --host 127.0.0.1 --port 4173',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

A real origin avoids special local-file behavior. Chromium is enough to develop the rule, while a production suite can add Firefox and WebKit once the test is stable. Tracing on the first retry captures the DOM and action history without generating a trace for every successful run.

Verify the step: run npx playwright test --list. Playwright should load the configuration and show the Chromium project. An empty test list is expected because no spec exists yet.

Step 2: Create a Fixture with Accessible Name Defects

Create tests/fixtures/index.html. The search input and icon-only delete button are deliberately unnamed. The Save button, Cancel link, and email input demonstrate three valid naming paths.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Profile fixture</title>
  </head>
  <body>
    <main>
      <h1>Edit profile</h1>
      <form aria-labelledby="profile-heading">
        <h2 id="profile-heading">Profile details</h2>

        <input id="search" type="search" placeholder="Search settings" />

        <label for="email">Email address</label>
        <input id="email" name="email" type="email" />

        <button type="button" data-testid="delete-avatar">
          <svg aria-hidden="true" viewBox="0 0 24 24">
            <path d="M6 7h12l-1 14H7L6 7zm3-4h6l1 2H8l1-2z" />
          </svg>
        </button>

        <button type="submit">Save profile</button>
        <a href="/profile">Cancel editing</a>
      </form>
    </main>
  </body>
</html>

A placeholder is not a dependable accessible name and disappears visually when a user types. The SVG is hidden correctly because it is decorative, but that leaves the parent button without naming content. The defect belongs on the button, so the eventual fix will name the button rather than expose the path data.

Verify the step: run npx vite tests/fixtures --host 127.0.0.1 --port 4173 and open the URL. You should see a profile form with a search box, email field, trash icon button, Save button, and Cancel link. Stop Vite afterward because the Playwright configuration starts it automatically.

Step 3: Assert Names on Critical Controls

Create tests/accessible-names.spec.ts. Start with the controls whose meaning is a product requirement. Role locators use the same accessible-name computation that Playwright uses to match elements.

import { expect, test } from '@playwright/test';

test.describe('profile accessible names', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('exposes intended names for critical actions', async ({ page }) => {
    await expect(
      page.getByRole('button', { name: 'Save profile', exact: true })
    ).toBeVisible();

    await expect(
      page.getByRole('link', { name: 'Cancel editing', exact: true })
    ).toBeVisible();

    await expect(page.getByLabel('Email address')).toBeVisible();

    await expect(
      page.getByRole('button', { name: 'Delete profile photo', exact: true })
    ).toBeVisible();
  });
});

The first three assertions pass. The delete assertion fails because the button has no accessible name. This is a valuable failure: it expresses the intended user experience, not an implementation attribute such as data-testid. Use exact: true when wording is a stable requirement and partial matches could select a different control.

Do not test aria-label directly unless your requirement specifically concerns that attribute. A visible label, aria-labelledby, native text content, and other mechanisms can all contribute valid names. Test the resulting role and name whenever possible.

Verify the step: run npx playwright test tests/accessible-names.spec.ts. Expect one failed test. The call log should say that the button named Delete profile photo was not found, proving the deliberate defect is visible to the test.

Step 4: Discover Unnamed Interactive Elements

An explicit assertion cannot find a control you forgot existed. Add this reusable helper above the tests. It calls getComputedAccessibleNode() when the browser exposes that Accessibility Object Model function, then falls back to common HTML naming sources. The fallback is intentionally conservative and returns evidence for review.

import { expect, test, type Page } from '@playwright/test';

type UnnamedControl = { selector: string; html: string };

async function findUnnamedControls(page: Page): Promise<UnnamedControl[]> {
  return page.locator(
    'button, a[href], input:not([type=hidden]), select, textarea, ' +
    '[role=button], [role=link], [role=checkbox], [role=radio], [role=switch]'
  ).evaluateAll((elements) => {
    const isVisible = (element: Element) => {
      const style = getComputedStyle(element);
      const rect = element.getBoundingClientRect();
      return style.visibility !== 'hidden' && style.display !== 'none' &&
        rect.width > 0 && rect.height > 0;
    };

    const fallbackName = (element: Element): string => {
      const labelledBy = element.getAttribute('aria-labelledby');
      if (labelledBy) {
        return labelledBy.split(/\s+/)
          .map((id) => document.getElementById(id)?.textContent ?? '')
          .join(' ').trim();
      }
      const ariaLabel = element.getAttribute('aria-label')?.trim();
      if (ariaLabel) return ariaLabel;
      if (element instanceof HTMLInputElement) {
        const labels = Array.from(element.labels ?? []);
        const labelText = labels.map((label) => label.textContent ?? '').join(' ').trim();
        if (labelText) return labelText;
        if (['button', 'submit', 'reset'].includes(element.type)) return element.value.trim();
        if (element.type === 'image') return element.alt.trim();
      }
      if (element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
        return Array.from(element.labels ?? [])
          .map((label) => label.textContent ?? '').join(' ').trim();
      }
      return (element.textContent ?? '').trim();
    };

    return elements.flatMap((element, index) => {
      if (!isVisible(element) || element.getAttribute('aria-hidden') === 'true') return [];
      const aom = (window as Window & {
        getComputedAccessibleNode?: (target: Element) => { name?: string };
      }).getComputedAccessibleNode?.(element);
      const name = aom?.name?.trim() || fallbackName(element);
      if (name) return [];
      const id = element.id ? `#${CSS.escape(element.id)}` : '';
      return [{
        selector: `${element.tagName.toLowerCase()}${id}:nth-candidate(${index + 1})`,
        html: element.outerHTML.slice(0, 300),
      }];
    });
  });
}

This is not a replacement implementation of the full Accessible Name and Description Computation standard. It is a focused safety net for common controls. The returned HTML makes failures actionable, and the synthetic candidate index distinguishes repeated unnamed elements without pretending to be a valid CSS selector. For complete rule coverage, pair it with the Playwright axe scan specific components examples.

Verify the step: TypeScript should compile when you run npx playwright test --list. If your configured DOM library does not recognize an experimental AOM type, the local window intersection shown above keeps the optional API typed without changing global declarations.

Step 5: Turn Discovery into a Clear Test Failure

Add a second test inside the existing describe block. Attach the report before asserting so the HTML report and trace retain useful evidence.

test('has no visible unnamed interactive controls', async ({ page }, testInfo) => {
  const unnamed = await findUnnamedControls(page);

  await testInfo.attach('unnamed-controls.json', {
    body: JSON.stringify(unnamed, null, 2),
    contentType: 'application/json',
  });

  expect(unnamed,
    `Found unnamed controls:\n${JSON.stringify(unnamed, null, 2)}`
  ).toEqual([]);
});

Use one assertion for the collection rather than failing inside evaluateAll(). Node-side assertions produce normal Playwright reporting, and the attachment remains available even when multiple candidates exist. The test only inspects visible candidates because hidden templates and inactive UI can otherwise create noisy results. Test newly opened dialogs or menus after triggering them.

A broad selector is useful, but it must reflect your component system. Add custom roles your application uses, such as role=menuitem, only when they are genuinely interactive and within scope. Better still, replace custom clickable containers with native controls.

Verify the step: run npx playwright test tests/accessible-names.spec.ts. Expect both tests to fail. The discovery failure should list the search input and delete button, including truncated outerHTML. Open npx playwright show-report and confirm the JSON attachment contains both candidates.

Step 6: Add an ARIA Snapshot for Component Semantics

Playwright ARIA snapshots provide a readable semantic contract for a bounded region. Add this test to verify the form's heading, textbox names, and actions together.

test('matches the profile form accessibility structure', async ({ page }) => {
  await expect(page.getByRole('form', { name: 'Profile details' }))
    .toMatchAriaSnapshot(`
      - heading "Profile details" [level=2]
      - searchbox "Search settings"
      - textbox "Email address"
      - button "Delete profile photo"
      - button "Save profile"
      - link "Cancel editing"
    `);
});

This test initially fails for the same naming defects. Keep snapshots focused on stable components, not an entire frequently changing page. Review changes as semantic API changes. Never update a snapshot automatically merely to make CI green.

ARIA snapshots complement discovery. They verify exact expected structure, while the audit detects unexpected unnamed candidates. If your team needs behavioral coverage after these controls receive focus, follow the Playwright keyboard focus order tutorial.

Verify the step: run the spec with --grep "accessibility structure". The diff should show missing names for the searchbox and delete button rather than a generic pixel mismatch.

Step 7: Fix the Markup and Prove the Regression Test

Update the two defective controls in index.html. Give the search input a visible label. Give the icon button an aria-label because its design has no visible text.

<label for="search">Search settings</label>
<input id="search" type="search" />

<button
  type="button"
  aria-label="Delete profile photo"
  data-testid="delete-avatar"
>
  <svg aria-hidden="true" viewBox="0 0 24 24">
    <path d="M6 7h12l-1 14H7L6 7zm3-4h6l1 2H8l1-2z" />
  </svg>
</button>

Prefer visible text because it benefits more users and stays aligned with the interface. An icon-only control is a reasonable use for aria-label, provided localization and product copy use the same source of truth. Do not place both a visible label and a conflicting aria-label on the same control. The ARIA name can override visible wording and create a mismatch for speech-input users.

Also judge the name in context. Delete may be clear beside a photo, but Delete profile photo is safer in a menu, screen reader control list, or repeated card layout. Accessible names should identify the action and target without adding unnecessary instructions.

Verify the step: run npx playwright test tests/accessible-names.spec.ts. All three tests should pass. Temporarily remove either label and confirm the corresponding test fails, then restore it. This mutation check proves the test detects the defect it claims to protect.

Step 8: Run the Check in CI Without Hiding Defects

Add a focused package script:

{
  "scripts": {
    "test:a11y-names": "playwright test tests/accessible-names.spec.ts"
  }
}

Then call it in your CI workflow after dependencies and Chromium are installed:

- name: Install dependencies
  run: npm ci
- name: Install Playwright browser
  run: npx playwright install --with-deps chromium
- name: Test accessible names
  run: npm run test:a11y-names

Run known critical assertions on every pull request. Run broader discovery tests after the page reaches a stable state, and exercise menus, dialogs, and authenticated routes through separate tests. Do not add fixed waits to quiet rendering races. Wait for a user-visible landmark, heading, or loaded state before auditing.

If a legitimate exception exists, allowlist a precise element with a ticket, owner, and removal date. Avoid global filters such as ignoring every empty button. An unnamed control is rarely an acceptable permanent state.

Verify the step: run npm run test:a11y-names locally with a clean install if practical. The command should exit with code 0 and show three passing tests. In CI, retain the HTML report or trace only on failure according to your artifact policy.

Troubleshooting

Problem: getByRole() finds more than one element -> Use a stable accessible name, scope the locator to a region, or fix duplicate ambiguous controls. Do not reach immediately for nth() because identical unnamed actions may be the defect.

Problem: The audit says an input is unnamed even though it has a placeholder -> Add a real associated label, aria-label, or aria-labelledby. Placeholder text is a hint, not a robust labeling strategy, and the helper intentionally does not accept it as the fallback name.

Problem: A control is reported before client rendering finishes -> Wait for an application-ready signal that a user can observe, such as a main heading or loaded result. Avoid waitForTimeout(), which makes the test slower without proving readiness.

Problem: The fallback misses a complex SVG or shadow DOM name -> Add an explicit role-and-name assertion for the component and use a standards-based scanner. Keep the helper conservative instead of expanding it into an incomplete accessibility engine.

Problem: toMatchAriaSnapshot() changes after a harmless layout edit -> Scope the snapshot to the smallest semantic region and inspect the diff. Layout-only changes should not normally alter the accessibility tree, but heading, role, name, checked state, or disabled state changes deserve review.

Problem: A hidden dialog creates false failures -> The helper excludes elements without rendered dimensions, but application hiding patterns vary. Audit the active state separately after opening the dialog, and ensure inactive content is actually hidden from both interaction and the accessibility tree.

Best Practices

  • Assert exact intended names for destructive, financial, navigation, and submission actions.
  • Use native HTML and associated visible labels before adding ARIA.
  • Scope discovery to visible, enabled experiences at a known application state.
  • Preserve offending HTML in the report so developers can act without reproducing locally.
  • Treat snapshot updates as code review events, not routine regeneration.
  • Test dynamic content after opening it, including menus, drawers, dialogs, and validation summaries.
  • Keep accessible names concise, unique in context, localizable, and aligned with visible wording.
  • Combine name checks with keyboard, focus, live region, contrast, and manual assistive technology testing.

Do not assert only the presence of an aria-label attribute. That pattern rejects valid visible labels and can accept aria-label="" or meaningless text. Do not add title to every unnamed control as a blanket repair. Fix the semantic relationship that should provide the name.

Where To Go Next

Put these tests inside the layered plan in the complete Playwright accessibility automation guide. Use explicit semantic assertions for critical behavior, targeted scans for components, page-level discovery for regressions, and manual review for experience quality.

Continue with these practical guides:

Finally, test the critical path with a real screen reader. Automation can prove that a computed name exists and matches your contract. A human review tells you whether that name is understandable, timely, and efficient in the full task.

Interview Questions and Answers

Q: How does Playwright help detect missing accessible names?

Use role locators with a required name for known controls, ARIA snapshots for bounded semantic structures, and a page audit or axe scan for discovery. Role-and-name assertions are especially strong because they express the experience the control must expose.

Q: Why is checking aria-label alone insufficient?

Accessible names can come from visible content, associated labels, aria-labelledby, native attributes, and other sources. Checking one attribute rejects valid markup and may accept a value that conflicts with visible text. Test the computed outcome and the intended wording.

Q: What is the difference between an accessible name and description?

The name identifies the control, such as Delete profile photo. A description provides supplemental information, often through aria-describedby. A description does not normally repair a missing name because the two serve different purposes.

Q: When would you use an ARIA snapshot?

Use it for a stable, bounded component whose roles, names, and states form an important contract. Keep its scope small, inspect diffs, and pair it with direct assertions when a single action has strict wording requirements.

Q: Can a nonempty accessible name still be defective?

Yes. A name can be vague, duplicated, misleading, untranslated, or inconsistent with visible text. Automated discovery catches emptiness, while explicit product assertions and manual review judge quality and context.

Q: How do you prevent accessible-name audits from becoming flaky?

Navigate to a defined state, wait for a user-visible readiness signal, and audit only rendered interactive elements. Test dynamic surfaces after opening them and avoid fixed delays. Preserve trace and candidate HTML for diagnosis.

Common Mistakes

The first mistake is locating every important action by test ID. Test IDs are useful when no user-facing contract exists, but they can let an unnamed icon button pass unnoticed. Prefer role-and-name locators for accessible interactions.

The second mistake is using visible text extraction as if it were the accessible-name algorithm. Text is one naming source, but labels and ARIA relationships also matter. Use Playwright's accessibility-aware locators for exact expectations and treat any custom fallback as a discovery aid.

The third mistake is applying aria-label everywhere. Native labels and visible button text are easier to understand, translate, and maintain. Add ARIA only when HTML semantics cannot express the needed relationship cleanly.

The fourth mistake is scanning only initial page load. Many unnamed controls appear in menus, modals, validation states, virtualized rows, or responsive navigation. Create state-specific tests that reveal those controls before auditing.

Conclusion

A reliable playwright detect missing accessible names tutorial combines three layers: role-and-name assertions for product intent, broad discovery for unknown controls, and focused ARIA snapshots for stable component semantics. Run the layers against a defined UI state and report the offending markup.

Fix names at their semantic source, prefer visible labels and native HTML, and keep the checks in CI. Then add keyboard-order tests, live-region validation, standards-based scans, and manual screen reader review to protect the complete accessible experience.

Interview Questions and Answers

How would you design a Playwright test for missing accessible names?

I would explicitly locate critical controls by role and intended name, then add a discovery audit for visible interactive candidates. I would attach offending markup before asserting an empty result. For stable components, I would add a focused ARIA snapshot and keep all checks in CI.

Why prefer getByRole with a name over getByTestId?

A role-and-name locator expresses how assistive technology identifies the control and often catches semantic regressions. A test ID only proves that a private attribute exists. I still use test IDs when there is no suitable user-facing contract, but not to bypass a missing name.

What sources can contribute to an accessible name?

Sources include associated HTML labels, element text, `aria-labelledby`, `aria-label`, and native attributes such as an image's alt text in relevant contexts. Precedence depends on the element and the accessible-name computation. I test the computed outcome rather than assuming one source always wins.

Why is a custom DOM helper not a full accessible-name implementation?

The standard has detailed precedence, recursion, hidden-content, host-language, and embedded-control rules. A small helper can cover common controls and return useful candidates, but it should remain a discovery safety net. I pair it with accessibility-aware locators and a standards-based scanner.

How would you reduce false positives in an accessible-name audit?

I would audit a known rendered state, exclude content truly hidden from users, and scope candidates to interactive roles used by the application. Dynamic surfaces get separate tests after activation. Any allowlist entry must be precise, documented, owned, and temporary.

Can automation determine whether an accessible name is good?

Automation can verify presence, exact expected wording, uniqueness in a known context, and consistency across regression runs. It cannot fully judge clarity or efficiency across an entire screen reader journey. I combine explicit product assertions with manual assistive technology testing.

Frequently Asked Questions

How do I test an accessible name in Playwright?

Locate the element by role and expected name, then assert that it is visible, enabled, or otherwise ready. For example, use `page.getByRole('button', { name: 'Save profile', exact: true })`. This tests the computed user-facing result instead of one implementation attribute.

Can Playwright find every element with a missing accessible name?

Playwright provides accessibility-aware locators and ARIA snapshots, but broad discovery requires a scoped audit or a rules engine such as axe. No automated technique judges every name's quality, so manually review high-risk workflows too.

Does placeholder text count as an accessible name?

Do not depend on a placeholder as the label for a form control. Use an associated visible label when possible, or `aria-label` or `aria-labelledby` when the design requires another approach.

Should I assert the aria-label attribute in Playwright?

Usually, assert the resulting role and accessible name instead. An attribute assertion is appropriate only when the attribute itself is the implementation contract, because valid names can come from several other sources.

How should an icon-only button be named?

Give the button a concise name that describes its action and target, commonly with `aria-label` when no visible text exists. Keep the SVG decorative with `aria-hidden="true"` so path or title content does not create an accidental name.

What is Playwright toMatchAriaSnapshot used for?

It compares a locator's accessibility representation with a readable expected template of roles, names, and states. Use it for bounded, stable components and review semantic diffs carefully.

Related Guides