WCAG 2.1 AA Overview and Accessibility Testing Strategy — testing web accessibility for WCAG 2.1 AA compliance.
Web accessibility ensures that people with disabilities can use your application. In the UK and EU, accessibility is a legal requirement under the Equality Act and European Accessibility Act. For developers, it is also a quality signal: accessible applications are better structured, more SEO-friendly, and usable in constrained environments (keyboard-only, screen reader, low bandwidth).
WCAG 2.1 (Web Content Accessibility Guidelines) defines four principles — Perceivable, Operable, Understandable, and Robust — organized into levels A, AA, and AAA. Level AA is the legal standard. This lesson maps WCAG success criteria to testable code patterns and shows how to build an automated accessibility testing pipeline.
Perceivable: Content must be presentable in ways users can perceive.
Operable: Users must be able to navigate the interface.
Understandable: Content must be understandable.
Robust: Content must be parseable by assistive technologies.
1// Checklist-driven approach:
2const WCAG_AA_TESTS = [
3 { criterion: '1.1.1', test: 'All img elements have non-empty alt attributes' },
4 { criterion: '1.4.3', test: 'Text/background contrast ratio >= 4.5:1' },
5 { criterion: '2.1.1', test: 'All interactive elements reachable by Tab key' },
6 { criterion: '2.4.7', test: 'Focus indicator visible on all focusable elements' },
7 { criterion: '4.1.2', test: 'Form inputs have associated label elements' },
8] as const;Automated tools catch 30-40% of WCAG issues. The remaining 60-70% require manual testing.
Automated tools find (use these in CI):
Manual testing finds:
1# Automated tools in the testing stack:
2# Unit: jest-axe (component-level axe-core)
3# Integration: axe-playwright (page-level, real browser)
4# CI: pa11y-ci (batch scan of all pages)
5# Manual: NVDA + Firefox, VoiceOver + Safari
6
7# Priority order:
8# 1. Automated: catches structural issues instantly
9# 2. Keyboard: navigate entire flow without mouse
10# 3. Screen reader: listen to how content is announced
11# 4. Zoom: test at 200% zoom, 400% for WCAG 1.4.10All functionality must be operable by keyboard alone. Test the full Tab order of every page and every interactive component.
1// Playwright keyboard navigation test
2import { test, expect } from '@playwright/test';
3
4test('login form is fully keyboard accessible', async ({ page }) => {
5 await page.goto('/login');
6
7 // Tab to email field
8 await page.keyboard.press('Tab');
9 const emailFocused = await page.evaluate(() =>
10 document.activeElement?.getAttribute('name') === 'email'
11 );
12 expect(emailFocused).toBe(true);
13
14 // Fill email
15 await page.keyboard.type('[email protected]');
16
17 // Tab to password
18 await page.keyboard.press('Tab');
19 await page.keyboard.type('Password123!');
20
21 // Tab to submit button
22 await page.keyboard.press('Tab');
23 const submitFocused = await page.evaluate(() =>
24 document.activeElement?.getAttribute('type') === 'submit'
25 );
26 expect(submitFocused).toBe(true);
27
28 // Submit with Enter
29 await page.keyboard.press('Enter');
30 await expect(page).toHaveURL(/dashboard/);
31});
32
33test('modal traps focus correctly', async ({ page }) => {
34 await page.goto('/');
35 await page.click('[data-testid="open-modal"]');
36 await expect(page.locator('[role="dialog"]')).toBeVisible();
37
38 // Tab through all focusable elements in modal
39 const initialFocus = await page.evaluate(() => document.activeElement?.tagName);
40
41 // Shift+Tab from first element should wrap to last
42 await page.keyboard.press('Shift+Tab');
43 const lastFocus = await page.evaluate(() => document.activeElement?.tagName);
44
45 // Focus should stay within modal
46 const isInModal = await page.evaluate(() => {
47 const modal = document.querySelector('[role="dialog"]');
48 return modal?.contains(document.activeElement);
49 });
50 expect(isInModal).toBe(true);
51
52 // Escape closes modal
53 await page.keyboard.press('Escape');
54 await expect(page.locator('[role="dialog"]')).not.toBeVisible();
55});Color contrast must be 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold) at WCAG AA.
1// Playwright contrast check using browser APIs
2test('primary text meets 4.5:1 contrast ratio', async ({ page }) => {
3 await page.goto('/');
4
5 const contrastRatios = await page.evaluate(() => {
6 function getLuminance(r: number, g: number, b: number): number {
7 const a = [r, g, b].map(v => {
8 const sRGB = v / 255;
9 return sRGB <= 0.03928 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
10 });
11 return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
12 }
13
14 function getContrastRatio(color1: number[], color2: number[]): number {
15 const l1 = getLuminance(color1[0], color1[1], color1[2]);
16 const l2 = getLuminance(color2[0], color2[1], color2[2]);
17 const lighter = Math.max(l1, l2);
18 const darker = Math.min(l1, l2);
19 return (lighter + 0.05) / (darker + 0.05);
20 }
21
22 const paragraphs = Array.from(document.querySelectorAll('p, span, a, button'));
23 return paragraphs.slice(0, 10).map(el => {
24 const style = window.getComputedStyle(el);
25 const color = style.color.match(/\d+/g)?.map(Number) || [0, 0, 0];
26 const bg = style.backgroundColor.match(/\d+/g)?.map(Number) || [255, 255, 255];
27 return {
28 text: el.textContent?.substring(0, 20),
29 ratio: getContrastRatio(color, bg),
30 };
31 });
32 });
33
34 contrastRatios.forEach(({ text, ratio }) => {
35 expect(ratio).toBeGreaterThanOrEqual(4.5);
36 });
37});ARIA (Accessible Rich Internet Applications) provides semantic meaning to dynamic content. Test that ARIA is used correctly.
1import { render, screen } from '@testing-library/react';
2import { axe, toHaveNoViolations } from 'jest-axe';
3import userEvent from '@testing-library/user-event';
4
5expect.extend(toHaveNoViolations);
6
7describe('Navigation component accessibility', () => {
8 it('has no axe violations', async () => {
9 const { container } = render(<Navigation />);
10 const results = await axe(container);
11 expect(results).toHaveNoViolations();
12 });
13
14 it('nav landmark has accessible name', () => {
15 render(<Navigation />);
16 expect(screen.getByRole('navigation', { name: /main navigation/i })).toBeInTheDocument();
17 });
18
19 it('current page link has aria-current', () => {
20 render(<Navigation currentPath="/courses" />);
21 const coursesLink = screen.getByRole('link', { name: /courses/i });
22 expect(coursesLink).toHaveAttribute('aria-current', 'page');
23 });
24
25 it('mobile menu button has aria-expanded', async () => {
26 render(<Navigation />);
27 const menuButton = screen.getByRole('button', { name: /open menu/i });
28 expect(menuButton).toHaveAttribute('aria-expanded', 'false');
29 await userEvent.click(menuButton);
30 expect(menuButton).toHaveAttribute('aria-expanded', 'true');
31 });
32});A working implementation demonstrating core patterns and best practices.
1// tests/a11y/accessibility-audit.spec.ts
2import { test, expect } from '@playwright/test';
3import AxeBuilder from '@axe-core/playwright';
4
5const PAGES_TO_TEST = [
6 { url: '/', name: 'Homepage' },
7 { url: '/courses', name: 'Courses list' },
8 { url: '/login', name: 'Login' },
9 { url: '/register', name: 'Register' },
10];
11
12test.describe('WCAG 2.1 AA Automated Audit', () => {
13 for (const { url, name } of PAGES_TO_TEST) {
14 test(`${name} has no critical accessibility violations`, async ({ page }) => {
15 await page.goto(url);
16
17 // Wait for page to fully render
18 await page.waitForLoadState('networkidle');
19
20 const results = await new AxeBuilder({ page })
21 .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
22 .exclude('[aria-hidden="true"]') // exclude decorative elements
23 .analyze();
24
25 // Critical violations block (must fix)
26 const critical = results.violations.filter(v =>
27 v.impact === 'critical' || v.impact === 'serious'
28 );
29
30 if (critical.length > 0) {
31 console.error(name + ' CRITICAL violations:');
32 critical.forEach(v => {
33 console.error(' [' + v.impact + '] ' + v.description);
34 v.nodes.slice(0, 3).forEach(n => console.error(' → ' + n.html.substring(0, 80)));
35 });
36 }
37
38 expect(critical, name + ' must have 0 critical/serious violations').toHaveLength(0);
39
40 // Log moderate/minor for visibility (don't fail)
41 const moderate = results.violations.filter(v =>
42 v.impact === 'moderate' || v.impact === 'minor'
43 );
44 if (moderate.length > 0) {
45 console.warn(name + ': ' + moderate.length + ' moderate/minor violations (fix before release)');
46 }
47 });
48 }
49
50 test('login page keyboard flow is complete', async ({ page }) => {
51 await page.goto('/login');
52 await page.keyboard.press('Tab');
53 const emailName = await page.evaluate(() => document.activeElement?.getAttribute('name'));
54 expect(emailName).toBe('email');
55 await page.keyboard.type('[email protected]');
56 await page.keyboard.press('Tab');
57 await page.keyboard.type('Pass123!');
58 await page.keyboard.press('Tab');
59 const submitType = await page.evaluate(() => document.activeElement?.getAttribute('type'));
60 expect(submitType).toBe('submit');
61 });
62
63 test('all images have alt text', async ({ page }) => {
64 await page.goto('/courses');
65 const imagesWithoutAlt = await page.evaluate(() =>
66 Array.from(document.querySelectorAll('img'))
67 .filter(img => !img.alt && !img.getAttribute('aria-hidden'))
68 .map(img => img.src)
69 );
70 expect(imagesWithoutAlt, 'All images must have alt text').toHaveLength(0);
71 });
72
73 test('all form inputs have labels', async ({ page }) => {
74 await page.goto('/register');
75 const inputsWithoutLabel = await page.evaluate(() => {
76 const inputs = Array.from(document.querySelectorAll('input:not([type="hidden"])'));
77 return inputs.filter(input => {
78 const id = input.id;
79 const ariaLabel = input.getAttribute('aria-label');
80 const ariaLabelledby = input.getAttribute('aria-labelledby');
81 const label = id ? document.querySelector(`label[for="${id}"]`) : null;
82 return !label && !ariaLabel && !ariaLabelledby;
83 }).map(i => i.name || i.type);
84 });
85 expect(inputsWithoutLabel, 'All inputs must have labels').toHaveLength(0);
86 });
87});This test suite uses @axe-core/playwright to run WCAG 2.1 AA automated checks against 4 key pages (homepage, courses list, login, register). Critical and serious violations fail the test immediately; moderate/minor violations log warnings. Separate tests verify keyboard flow completeness, all images have alt text, and all form inputs have accessible labels. Run: npx playwright test tests/a11y/
Continue building comprehensive test coverage with the patterns established in this lesson.
Your progress is saved in this browser, on this device only.