Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Accessibility Testing with axe and a Keyboard: What Each One Catches

A hands-on introduction to accessibility testing. Scan a page with axe, read a violation properly, see the same bug in the accessibility tree and in your locators, then find out why a keyboard pass misses it entirely. Includes an exercise with an answer key.

QA Vibes EditorialPublished 9 minTested with Playwright 1.63.0, axe-core 4.13.0, @axe-core/playwright 4.13.0Revision history ↓

Key takeaways

  • An axe scan caught a missing form label instantly. A full keyboard pass over the same form found nothing wrong.
  • Read a violation properly: the rule id, the impact, the WCAG criterion, and the element it points at.
  • Tests that find fields by their accessible name fail when that name breaks, so they catch naming bugs for free.
  • A page can pass every automated check and still strand a screen reader user. Ours did, with zero axe violations.
Contents (14 sections)

Introduction

"Is this page accessible?" is not one question, and no single tool answers it. An automated scan checks rules that a machine can decide. A keyboard pass checks whether you can actually operate the page. The accessibility tree shows what a screen reader would be handed. They overlap, but each one sees things the others miss.

This guide runs all of them against the same page, with one deliberate bug, and compares the answers. Every command and output below comes from runs on 2026-09-16 with Playwright 1.63.0 and axe-core 4.13.0.

The page under test

The target is this site's own practice shop, which is free, ad-free, and built for exactly this. Its checkout page asks for a first name, a last name, and a postal code.

The shop has ten variants, each with one seeded bug. Variant v10 breaks the postal code field: the <label> still says "Postal code" and still points at postalCode, but the input's id was changed to postal. Nothing moves, nothing looks different, and the label text is still on screen.

Open the correct shop with ?variant=clean and the broken one with ?variant=v10. Everything below was run against both.

Test only sites you own or have permission to test. That is why this guide uses our own practice shop rather than someone else's.

Set up the scan

Playwright's accessibility testing docs recommend @axe-core/playwright, which injects the axe engine into a page you already drove to the right state:

npm install --save-dev @axe-core/playwright

The scan itself is a normal test:

// tests/a11y.spec.ts
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
 
test("checkout has no serious or critical WCAG 2.1 AA violations", async ({ page }) => {
  await page.goto("/practice/shop/checkout");
  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();
 
  const serious = violations
    .filter((v) => v.impact === "serious" || v.impact === "critical")
    .map((v) => `${v.id}: ${v.help} (${v.nodes.length}× e.g. ${v.nodes[0]?.target.join(" ")})`);
  expect(serious).toEqual([]);
});

Two details matter more than they look.

withTags decides what "accessible" means here. Without it, axe runs every rule it has, including best practices that no standard requires. Naming the WCAG tags you are held to keeps the result honest and the failures actionable.

Filtering by impact is a choice, not a rule. axe labels each violation minor, moderate, serious, or critical. Failing only on serious and critical is a reasonable starting point for an existing product, but it does mean moderate problems pass silently. Decide that deliberately.

Scan the correct shop first

Run the scan against the clean shop before touching the buggy one, so you know what a pass looks like:

{"variant":"clean","axeVersion":"4.13.0","violations":[]}

Zero violations. It is worth being precise about what that does and does not mean. It means no rule that axe can decide automatically was broken on this page in this state. It does not mean the page is accessible. Playwright's own documentation says automated tests "can detect some common accessibility problems", and that many problems "can only be discovered through manual testing". We come back to that at the end, with a measured example from this very page.

Scan the broken variant

The same scan against v10:

rule id:  label
impact:   critical
help:     Form elements must have labels
tags:     wcag2a, wcag412
target:   #postal
 
Fix any of the following:
  Element does not have an implicit (wrapped) <label>
  Element does not have an explicit <label>
  aria-label attribute does not exist or is empty
  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
  Element has no title attribute
  Element has no placeholder attribute
  Element's default semantics were not overridden with role="none" or role="presentation"

Read a violation in four parts:

  1. The rule id (label) is the stable name. Look it up: Deque's page for this rule is titled "Form elements must have labels".
  2. The impact (critical) is axe's severity for the rule, not a measurement of your users' pain.
  3. The WCAG tag (wcag412) points at the success criterion, here 4.1.2 Name, Role, Value. That is the sentence you quote in a bug report.
  4. The target (#postal) is the element. This is the part people skip, and it is the part that tells you where to look.

The failure summary is a list of every way the element could have got a name, each of which failed. That is the fix list: give it a real <label for="postal">, or an aria-label, or wrap it.

The same bug in the accessibility tree

A scan tells you a rule failed. The accessibility tree shows you what a screen reader is actually handed. Playwright can print it:

const form = page.locator("form").first();
console.log(await form.ariaSnapshot());

The correct shop:

- text: Postal code
- textbox "Postal code"

Variant v10:

- text: Postal code
- textbox

The words "Postal code" are still on the page, as plain text. The textbox next to them has no name at all. A sighted user sees a labelled field; a screen reader announces an unlabelled edit box. That one line is the clearest possible explanation of the bug, and it belongs in the bug report.

Your locators are already an accessibility check

If your tests find fields the way a person describes them, they fail when the description breaks. Counting matches on both variants:

clean:  First name 1, Last name 1, Postal code 1
v10:    First name 1, Last name 1, Postal code 0

A test written like this already catches the bug, with no accessibility tooling at all:

test("checkout asks for a postal code", async ({ page }) => {
  await page.goto("/practice/shop/checkout");
  await expect(page.getByLabel("Postal code")).toBeVisible();
});

Whereas a test written against page.locator("#postal") or a test id would keep passing, because those do not care whether the field has a name. This is the cheapest accessibility win available: prefer getByLabel and getByRole in ordinary functional tests, and naming bugs start failing your suite for free.

Now try the keyboard

Keyboard access is the check people reach for first, and here it is instructive. Tabbing from the top of the checkout page, on the correct shop:

Practice Shop link → Cart link → Log out button → First name → Last name → Postal code → Back to cart link → Continue button

On v10, the order is identical. All three inputs are reachable. Each one draws the same focus outline, solid 2px rgb(110, 29, 41), on both variants.

So a keyboard pass over v10 finds nothing. The field is reachable, focusable, visibly focused, and typing into it works. Nothing about moving through the form reveals that it has no name, because a name is not something you can see or feel with a keyboard.

That is the lesson worth keeping: the technique that finds a bug depends on the kind of bug it is. A missing name is invisible to the keyboard and obvious to a scan. A keyboard trap, or a focus order that jumps around the page, is invisible to a scan and obvious the moment you press Tab. Neither replaces the other.

What passed every check and was still wrong

Go back to the correct shop, the one with zero axe violations, and submit the form empty:

- textbox "First name"
- alert: "Error: First name is required"

The error is exposed as an alert, so it is announced. Good. Now look at the field it is about:

firstName input, after a failed submit:
  aria-invalid:      null
  aria-describedby:  null
  aria-required:     null
error element id:    null

Nothing marks the field as invalid. Nothing connects the message to the field, and the error element has no id for anything to point at. Focus stays on the Continue button. A screen reader user hears "Error: First name is required", then has to go find which box that was about, and nothing tells them they have arrived at it.

axe reported zero violations on this page while all of that was true. Not because axe is bad, but because "the error is not associated with its field" needs judgement about which field the message refers to, and that is not a decision a scanner can make reliably. This is what the documentation means by problems that only manual testing finds.

Exercise: find the bug three ways

On the practice lab, switch the shop to v10 and reach the checkout details page.

  1. Find the bug with an axe scan. Write down the rule id, the impact, the WCAG tag, and the target.
  2. Find it again without any accessibility tool, using only a locator that describes the field the way a person would.
  3. Try to find it with the keyboard alone. Tab through the form, note the order, and write down what you can and cannot tell.
  4. Then go back to the correct shop and find one problem that the scan does not report at all.

Answer key

1. The scan. Rule label, impact critical, tag wcag412 (WCAG 4.1.2 Name, Role, Value), target #postal. The failure summary lists every naming route that was missing: no wrapped label, no explicit label, no aria-label, no aria-labelledby, no title, no placeholder.

2. Without accessibility tooling. page.getByLabel("Postal code") matches once on the correct shop and zero times on v10. Any test that locates the field by its label or role fails; a test using #postal or a test id does not notice.

3. The keyboard. Nothing to find. Tab order, reachability, and the focus outline are identical on both variants. This is the expected answer, not a trick: the bug is a missing accessible name, and the keyboard never asks for one.

4. On the correct shop. Several possible answers, all measured above: a failed submit leaves the field without aria-invalid, the error is not linked to the field by aria-describedby, the error element has no id, and focus is not moved to the first invalid field. The page still passes the axe scan with zero violations.

What to do with this

  • Put an axe scan in CI on the handful of pages that matter, scoped with withTags to the standard you are held to, and treat new serious or critical violations as failures.
  • Prefer getByLabel and getByRole in ordinary tests. It costs nothing and turns naming regressions into test failures.
  • Keep a short manual pass for the things scans cannot decide: tab order, focus after an action, error association, and whether the flow is usable at all.
  • When you excluded something to get the suite green, write down why. exclude() skips every rule on those elements, not just the noisy one, so an exclusion hides more than it looks like it does. disableRules() is narrower when one rule is the problem.

Conclusion

The bug in this guide was one changed id. A scan named it in milliseconds and told you the exact element and criterion. A locator that described the field like a person would failed on it without any tooling. A careful keyboard pass missed it completely, and a page with zero violations still left a screen reader user hunting for the field an error was about. Use the tools together, and know which question each one answers.

Sources and further reading

Tools mentioned

axe-coreAccessibilityOpen source
PlaywrightUI AutomationOpen source

Links go to each tool’s official site. How we choose and link tools

Revision history

First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Read your own scan

Accessibility report summary

Paste an axe report and see which findings EN 301 549 requires you to fix.