Skip to main content

Accessibility Testing Milestones & Mini-Projects

In short: six projects โ€” five on the practice lab (broken.html and fixed.html) and a final audit of a real site. Tasks show the expected result; answer keys are folded away. The accessibility-tree snapshots and test results below were captured from real runs.

How to use this page

Serve the lab with npx http-server site -p 8080 and open both pages. First read the milestone in the Roadmap. Write your own findings before opening an answer key โ€” then count what you missed and why.

Contentsโ€‹


Milestone 1: Inspecting two pagesโ€‹

Practises: semantics, roles and names, the accessibility tree.

#TaskExpected result
1Open broken.html, put the mouse away, try to sign upYou can't reach "Sign up"
2DevTools โ†’ Elements โ†’ Accessibility pane: inspect "Sign up" on both pagesBroken: no button role. Fixed: role button, name "Sign up"
3Turn on the full accessibility tree view (DevTools โ†’ Accessibility โ†’ "Enable full-page accessibility tree") and compare the pagesSee answer key
4List 5 differences between the two trees5 differences
Answer key

Accessibility trees as Playwright reports them (page.locator('body').ariaSnapshot()):

# broken.html
- img
- text: Create your account
- paragraph: All fields are required.
- textbox "Email"
- textbox "Password"
- combobox:
- option "Free" [selected]
- option "Pro"
- paragraph
- text: Sign up
- link "Click here":
- /url: /terms
# fixed.html
- banner:
- img "Example Shop"
- main:
- heading "Create your account" [level=1]
- paragraph: All fields are required.
- text: Email
- textbox "Email"
- text: Password
- textbox "Password"
- text: Plan
- combobox "Plan":
- option "Free" [selected]
- option "Pro"
- button "Sign up"
- status
- link "Terms of service":
- /url: /terms

Differences: the image has no name vs "Example Shop"; "Create your account" is plain text vs a level-1 heading; the combobox has no name vs "Plan"; "Sign up" is plain text vs a button; no landmarks vs banner and main; no status region; "Click here" vs "Terms of service".

Note: the broken textboxes still get names ("Email") โ€” browsers fall back to the placeholder. That's why tools often don't flag placeholder-only fields, even though WCAG wants a visible, persistent label.

Watch out for: judging by what you see. Both pages look almost the same; the accessibility tree is what assistive technology gets.

Try it: ariaSnapshot() output can be saved and compared in tests with toMatchAriaSnapshot โ€” which change on the page would make it fail?


Milestone 2: Find the 14 issuesโ€‹

Practises: a full manual check of one page against WCAG 2.2 AA.

Use the manual check routine on broken.html. For each issue write: element, WCAG criterion, who is affected, fix. Don't open fixed.html's source until you're done.

#TaskExpected result
1Page-level checks (title, language, landmarks, headings)4 issues
2Images and colour3 issues
3Keyboard and focus2 issues
4Form fields, errors and messages4 issues
5Links1 issue
Answer key โ€” 14 issues
#IssueWCAGWho is affectedFix
1Title is "Page"2.4.2 Page Titled (A)Screen-reader users (first thing announced), tab switchers"Create your account โ€” Example Shop"
2No lang on <html>3.1.1 Language of Page (A)Screen readers may use the wrong voice/pronunciation<html lang="en">
3No landmarks (header, main)1.3.1 / 2.4.1 (A), best practiceUsers who jump by landmark<header>, <main>
4Heading is a styled <div>1.3.1 Info and Relationships (A)Users who navigate by headings<h1>
5Logo image has no alt1.1.1 Non-text Content (A)Screen-reader usersalt="Example Shop"
6Hint text contrast 2.32:11.4.3 Contrast (AA)Low-vision users#595959 (7:1)
7Button text contrast 3.34:11.4.3 Contrast (AA)Low-vision usersDarker blue #1a5fb4
8"Sign up" is a <div> โ€” not focusable, no role, no key support2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A)Keyboard, screen-reader, switch users โ€” blocker<button type="submit">
9Focus outline removed2.4.7 Focus Visible (AA)Keyboard users:focus-visible outline
10Inputs have placeholder but no label3.3.2 Labels or Instructions (A), 1.3.1 (A)Everyone once typing starts; cognitive, low-vision users<label for>
11Plan <select> has no name at all4.1.2 Name, Role, Value (A)Screen-reader users<label for="plan">
12No autocomplete on email/password1.3.5 Identify Input Purpose (AA)Motor and memory impairments; password managersautocomplete="email" / "new-password"
13Wrong email shown only by a red border1.4.1 Use of Color (A), 3.3.1 Error Identification (A)Colour-blind and screen-reader usersError in words, aria-describedby, aria-invalid, focus
14"Account created" appears silently; "Click here" link4.1.3 Status Messages (AA); 2.4.4 Link Purpose (A)Screen-reader usersrole="status"; "Terms of service"

(Issue 14 has two parts โ€” count them together or separately; either way, 12+ found is excellent, 8โ€“11 good, under 8: do the routine again, slower, with the keyboard.)

Watch out for: stopping at what's visible. Issues 2, 11, 12 and 14 can't be seen on screen at all.

Try it: fix broken.html yourself, then compare your version with fixed.html.


Milestone 3: Automated scansโ€‹

Practises: axe with Playwright, tags, reading results, knowing the limits.

#TaskExpected result
1Playwright + @axe-core/playwright project; webServer starts the labnpx playwright test runs
2Scan broken.html with WCAG A/AA tags; print each violation4 rule types
3Scan fixed.html; assert no violationsPasses
4Scan broken.html with all rules (no tags)3 more (best practice)
5Compare with your 14 issues: which did axe find?See answer key
Answer key
playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://localhost:8080' },
webServer: { command: 'npx http-server site -p 8080 -s', url: 'http://localhost:8080/fixed.html', reuseExistingServer: true },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
tests/axe.spec.ts
// tests/axe.spec.ts โ€” automated WCAG checks on the broken and the fixed page
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const WCAG = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];

test('broken page: axe lists the violations', async ({ page }) => {
await page.goto('/broken.html');
const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze();

for (const v of violations) console.log(`${v.impact?.padEnd(8)} ${v.id} โ€” ${v.nodes.length} element(s)`);
expect(violations.length).toBeGreaterThan(0);
});

test('fixed page: no WCAG A/AA violations', async ({ page }) => {
await page.goto('/fixed.html');
const { violations } = await new AxeBuilder({ page }).withTags(WCAG).analyze();

expect(violations).toEqual([]);
});

Task 2 output:

serious  color-contrast โ€” 2 element(s)
serious html-has-lang โ€” 1 element(s)
critical image-alt โ€” 1 element(s)
critical select-name โ€” 1 element(s)

Task 4 adds: landmark-one-main, page-has-heading-one, region (5 elements) โ€” and one incomplete ("needs review") result: bypass.

Task 5: WCAG rules found issues 2, 5, 6, 7, 11; best-practice rules point at 3 and 4. Not found by axe: 1, 8, 9, 10, 12, 13, 14 โ€” half the issues, including the blocker (#8).

Watch out for: expect(violations.length).toBe(0) on a page with no interactive states tested โ€” a clean scan of the first render says nothing about the error state or an open menu.

Try it: type a wrong email on fixed.html, submit, then scan again. Still clean?


Milestone 4: A suite that locks in fixesโ€‹

Practises: keyboard order, focus, accessible names and descriptions, status messages โ€” as automated tests.

#TaskExpected result
1Test: Tab order on fixed.html is email โ†’ password โ†’ plan โ†’ Sign up โ†’ TermsPasses
2Test: on broken.html, Tab never reaches "Sign up"Passes (documents the bug)
3Test: the focused email field has a visible solid outlinePasses
4Test: wrong email โ†’ aria-invalid, focus on the field, description = the error textPasses
5Test: success message has role="status" and the right textPasses
6Test: getByLabel('Email') finds nothing on broken.htmlPasses

Check: npx playwright test โ€” 8 passed (with the two axe tests).

Solution
tests/keyboard.spec.ts
// tests/keyboard.spec.ts โ€” what axe can't check: can a keyboard user actually do it?
import { test, expect, type Page } from '@playwright/test';

async function tabOrder(page: Page, presses: number) {
const visited: string[] = [];
for (let i = 0; i < presses; i++) {
await page.keyboard.press('Tab');
visited.push(await page.evaluate(() => {
const el = document.activeElement!;
return el.id || el.textContent?.trim() || el.tagName; // a readable name for each stop
}));
}
return visited;
}

test('fixed page: Tab reaches every control in visual order', async ({ page }) => {
await page.goto('/fixed.html');
expect(await tabOrder(page, 5)).toEqual(['email', 'password', 'plan', 'Sign up', 'Terms of service']);
});

test('broken page: the "Sign up" button is never reached by Tab', async ({ page }) => {
await page.goto('/broken.html');
const visited = await tabOrder(page, 5);
expect(visited).not.toContain('submit'); // a <div> with onclick isn't focusable
});

test('fixed page: focus is visible', async ({ page }) => {
await page.goto('/fixed.html');
await page.keyboard.press('Tab');
const outline = await page.locator('#email').evaluate((el) => getComputedStyle(el).outlineStyle);
expect(outline).toBe('solid');
});

test('fixed page: a wrong email is announced, linked to the field, and focused', async ({ page }) => {
await page.goto('/fixed.html');
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Sign up' }).press('Enter');

const email = page.getByLabel('Email');
await expect(email).toHaveAttribute('aria-invalid', 'true');
await expect(email).toBeFocused();
await expect(email).toHaveAccessibleDescription('Enter an email address like name@example.com');
});

test('fixed page: success message is a live status', async ({ page }) => {
await page.goto('/fixed.html');
await page.getByLabel('Email').fill('asha@example.com');
await page.getByLabel('Password').fill('correct horse');
await page.getByRole('button', { name: 'Sign up' }).click();

await expect(page.getByRole('status')).toHaveText('Account created'); // screen readers announce role=status
});

test('broken page: fields have no accessible name', async ({ page }) => {
await page.goto('/broken.html');
await expect(page.getByLabel('Email')).toHaveCount(0); // placeholder is not a label for getByLabel
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveCount(1); // โ€ฆbut browsers still fall back to it
});

Watch out for: checking focus visibility by screenshot only. Computed outline-style (or a visual comparison of the focused state) is a more precise check.

Try it: add a test that Esc closes a dialog and returns focus to the button that opened it (add a small <dialog> to fixed.html first).


Milestone 5: Listening and resizingโ€‹

Practises: screen readers, zoom, reflow, text spacing, WCAG 2.2 criteria.

#TaskExpected result
1With a screen reader, complete sign-up on broken.html, then fixed.htmlBroken: you can't submit; fixed: you can, and you hear "Account created"
2On fixed.html, submit a wrong email with the screen reader onYou hear the field is invalid and the error text
3Jump by headings and landmarks on both pagesBroken: nothing to jump to; fixed: 1 heading, banner, main
4Zoom fixed.html to 200%, then view at 320 px widthNothing cut off, no sideways scrolling
5Pick a public site you use; check 2.4.11 Focus Not Obscured with a sticky header or cookie bannerNote whether focused items hide behind it
6Same site: check 2.5.8 Target Size on small iconsNote any targets under 24 ร— 24 px without spacing
Answer key (what you should hear, roughly)

Wording differs by screen reader and version. With VoiceOver + Safari on fixed.html:

  • Email field: "Email, edit text, required".
  • After a wrong email: "invalid data" and the description "Enter an email address like name@example.com".
  • After success: "Account created", without focus moving.

On broken.html: the email field is announced from its placeholder, the plan select has no name ("pop-up button" with no label), "Sign up" is read as plain text and can't be activated from the keyboard, and nothing announces the success message.

Watch out for: navigating only with Tab in a screen reader. Screen-reader users mostly read with the virtual cursor (arrows / swipes) and jump by headings โ€” use those too.

Try it: do task 1 on your phone with TalkBack or VoiceOver. What's different?


Milestone 6: A real auditโ€‹

Practises: scoping, a full audit, severity, reporting, a CI recommendation.

Pick a public site whose owner you won't harass with results (a site you build, your company's site with permission, or a well-known demo). Audit it against WCAG 2.2 AA.

#DeliverableExpected result
1Scope: 5 pages/templates + 1 journey; states to checkWritten scope
2Automated scan of every page and stateViolations exported
3Manual routine + keyboard + one screen reader on the journeyIssues logged in the template
4Severity for each issue; group by componentTop barriers clear
5Report: summary, scorecard, issues, roadmap2โ€“4 pages
6CI recommendation: which checks to automate first5 lines
What a strong report summary looks like
Scope:     WCAG 2.2 AA; home, search, product, sign-up, checkout; menu open, errors shown.
Tools: axe 4.13 via Playwright; NVDA + Chrome; VoiceOver + Safari (iOS).
Summary: 23 issues (3 blocker, 7 serious, 9 moderate, 4 minor) in 6 components.
Blockers: 1) Checkout "Pay" is a <div> โ€” not keyboard operable (2.1.1, 4.1.2)
2) Cookie banner traps focus and can't be closed with Esc (2.1.2)
3) Card-number field has no label (3.3.2, 4.1.2)
Roadmap: Week 1 โ€” the 3 blockers (shared Button and Input components)
Weeks 2โ€“4 โ€” contrast tokens, focus styles, error messaging pattern
Then โ€” landmarks, headings, link text; re-test and publish an accessibility statement
CI: axe on the 5 templates (WCAG tags) in every PR; keyboard tests for checkout and sign-up.

The numbers above are an example; yours come from your audit. What matters: blockers first, each tied to a criterion, fixes at component level, and a re-test plan.

Watch out for: reporting 200 rows of "color-contrast" from a scanner. Group them ("the grey hint text token fails on 40 pages") โ€” one fix, one line.

Try it: write the one-paragraph version of your summary for a manager.


Final projectโ€‹

Build (or take) a small multi-page app โ€” sign-up, a list with filters, a dialog, and a form with errors โ€” make it accessible, and prove it.

Done when:

  • Every page passes axe (WCAG 2.2 A/AA tags) in all tested states
  • Keyboard tests for every journey: order, focus visible, no traps, Esc closes dialogs
  • Accessible-name and description assertions for every form field and icon button
  • Journeys completed with one desktop and one mobile screen reader โ€” notes saved
  • 200% zoom and 320 px reflow checked
  • An accessibility statement page with known issues and contact details
  • CI runs the axe and keyboard tests on every pull request
  • Public repo โ€” proof you can deliver the accessibility testing service