PracticeQAAutomation logoPracticeQAAutomation

Accessibility Bug Hunt

A newsletter widget with 15 planted accessibility problems — missing labels, low contrast, keyboard traps and more — plus a fixed version to compare. Practice axe-core scans and keyboard tests in Playwright and Cypress; every recipe is run against this page in our test suite.

Show

Practice area

A small newsletter widget with 15 accessibility problems planted in it. Find them with axe, your keyboard and a screen reader.

Join our newsletter

Offer ends Sunday — 20% off every course.

Email address
Subscribe

To read the terms, click here.
Popular topics
  • Playwright
  • Cypress
  • Selenium

The 15 problems

Try to find them yourself first. axe finds 11 automatically; the other 4 need a keyboard or a screen reader — which is why automated scans are only the start.

Show code for

1. Scan the broken version with axe

Run axe-core on just the practice area and list what it finds. It catches 11 of the 15 problems.

Playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';   // npm i -D @axe-core/playwright

test('axe finds the planted problems', async ({ page }) => {
  await page.goto('/misc/accessibility');
  const results = await new AxeBuilder({ page })
    .include('[data-testid="a11y-area"]')        // scan only the practice area
    .analyze();
  for (const v of results.violations) console.log(`${v.id} (${v.impact}): ${v.help}`);
  const ids = results.violations.map((v) => v.id);
  expect(ids).toEqual(expect.arrayContaining(['image-alt', 'label', 'color-contrast', 'button-name']));
});

2. The fixed version passes

Switch to the fixed version and assert there are no violations — the check you'd keep in CI.

Playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('the fixed version has no axe violations', async ({ page }) => {
  await page.goto('/misc/accessibility');
  await page.getByRole('button', { name: 'Fixed version' }).click();
  const results = await new AxeBuilder({ page }).include('[data-testid="a11y-area"]').analyze();
  expect(results.violations).toEqual([]);
});

3. Keyboard test — what axe can't see

Tab from the email field. In the fixed version focus lands on Subscribe and Enter submits; in the broken version Subscribe is a <div> that Tab skips.

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

test('Subscribe works with the keyboard (fixed version)', async ({ page }) => {
  await page.goto('/misc/accessibility');
  await page.getByRole('button', { name: 'Fixed version' }).click();
  const area = page.getByTestId('a11y-area');
  await area.getByLabel('Email address').fill('asha@example.com');
  await page.keyboard.press('Tab');                         // → Topic
  await page.keyboard.press('Tab');                         // → Subscribe
  await expect(area.getByRole('button', { name: 'Subscribe' })).toBeFocused();
  await page.keyboard.press('Enter');
  await expect(area.getByRole('status')).toHaveText('Subscribed asha@example.com');
});

4. Dialog focus: in, trapped, Escape, back

A good dialog moves focus inside, closes with Escape and returns focus to the button that opened it.

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

test('the offer dialog manages focus (fixed version)', async ({ page }) => {
  await page.goto('/misc/accessibility');
  await page.getByRole('button', { name: 'Fixed version' }).click();
  const opener = page.getByRole('button', { name: 'View offer' });
  await opener.click();
  const dialog = page.getByRole('dialog', { name: 'Festive offer' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('button', { name: 'Close' })).toBeFocused();  // focus moved in
  await page.keyboard.press('Tab');
  await expect(dialog.getByRole('link', { name: 'See the offer terms' })).toBeFocused();
  await page.keyboard.press('Tab');                                           // wraps around
  await expect(dialog.getByRole('button', { name: 'Close' })).toBeFocused();
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();                                         // focus returned
});

5. Errors must be text, not just colour

Submit a bad email. The fixed version marks the field invalid and links a readable message to it.

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

test('an invalid email gives a readable error (fixed version)', async ({ page }) => {
  await page.goto('/misc/accessibility');
  await page.getByRole('button', { name: 'Fixed version' }).click();
  const email = page.getByTestId('a11y-area').getByLabel('Email address');
  await email.fill('not-an-email');
  await page.getByTestId('a11y-area').getByRole('button', { name: 'Subscribe' }).click();
  await expect(email).toHaveAttribute('aria-invalid', 'true');
  await expect(email).toHaveAccessibleDescription('Enter a valid email address, like name@example.com.');
});