Testing Strategy
Testing strategy for the accessible components in APG Patterns Examples.
Design Principle: DAMP (Descriptive And Meaningful Phrases)
In test code, prefer DAMP over DRY.
Why DAMP
The most important thing about a test is that you can tell at a glance what it is testing. We prioritize readability and self-containment over reducing code through abstraction.
| Aspect | DRY | DAMP |
|---|---|---|
| Readability | Intent is hidden behind abstraction | The test speaks for itself |
| Debugging | Must trace shared code | Self-contained in the test |
| Maintainability | A shared change affects everything | Can be changed independently |
| Learning curve | Requires understanding the abstraction | Add new tests by copy-paste |
What to abstract
OK to abstract (How-to):
- Test utilities:
createMockTabs() - Custom matchers:
toHaveNoViolations() - Setup routines: shared environment construction
Do NOT abstract (What-to):
- The body of a test case
- Assertions (write expected values explicitly)
- Test data (define it within the test)
Example
// โ Over-abstracted
testToggleBehavior(ToggleButton, { stateAttr: 'aria-pressed' });
// โ
DAMP: explicit and self-contained
it('changes aria-pressed from false to true', async () => {
render(<ToggleButton>Mute</ToggleButton>);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(button);
expect(button).toHaveAttribute('aria-pressed', 'true');
});
Two Axes of Testing
1. Basic behavior tests
Verify that the component works correctly.
- Rendering
- User interactions (click, input)
- State changes
- Callback invocation
- Passing props through
2. APG conformance tests
Verify conformance to the WAI-ARIA APG specification.
- ARIA attributes (role,
aria-*) - Keyboard interaction
- Focus management
- axe-core automated checks
APG Conformance Test Considerations
A. ARIA attributes
- The correct role is set
- Required
aria-*attributes are present aria-*attributes update on state change- References to related elements (aria-controls, aria-labelledby) are correct
B. Keyboard interaction
- Activation keys (Space, Enter)
- Navigation keys (arrow keys, Home, End)
- Close / cancel (Escape)
- Special operations (Delete, Tab)
C. Focus management
- Roving tabindex (only one item is in the tab sequence with
tabindex="0") - Focus trap (modal-type components)
- Focus restoration (where focus returns after closing)
D. axe-core
- Automated checks for detectable WCAG 2.1 AA violations
- No violations detectable by axe-core
Test Considerations Shared Across Patterns
Even for different patterns, you end up writing tests for the same โperspectivesโ. However, the test code itself is not abstracted โ it is written explicitly for each component.
Example: toggle-type components
ToggleButton, Switch, and Checkbox behave similarly, but their tests are written individually.
| Consideration | ToggleButton | Switch | Checkbox |
|---|---|---|---|
| State attribute | aria-pressed | aria-checked | aria-checked |
| Activation | Space, Enter | Space, Enter | Space |
| State change | true/false | true/false | true/false/mixed |
Example: navigation-type components
Tabs, RadioGroup, and Menu have arrow-key navigation.
| Consideration | Tabs | RadioGroup | Menu |
|---|---|---|---|
| Container role | tablist | radiogroup | menu |
| Child role | tab | radio | menuitem |
| Selection attr | aria-selected | aria-checked | - |
| Arrow keys | โ โ (horizontal) / โ โ (vertical) | โ โ โ โ | โ โ |
| Looping | yes | yes | yes |
File Layout
src/patterns/
โโโ button/
โ โโโ ToggleButton.tsx
โ โโโ ToggleButton.test.tsx
โโโ tabs/
โ โโโ Tabs.tsx
โ โโโ Tabs.test.tsx
โโโ accordion/
โโโ Accordion.tsx
โโโ Accordion.test.tsx
Keep all categories in a single test file. Reading the test file should reveal the componentโs specification.
Structure of a Test File
Organize categories by risk-based priority.
describe('ComponentName', () => {
// ๐ด High Priority: the core of APG conformance
describe('APG: keyboard interaction', () => {
it('...', () => {});
});
describe('APG: ARIA attributes', () => {
it('...', () => {});
});
// ๐ก Medium Priority: accessibility verification
describe('Accessibility', () => {
it('has no axe violations', async () => {});
});
describe('Props', () => {
// Props-specific tests not covered elsewhere
it('...', () => {});
});
// ๐ข Low Priority: extensibility
describe('HTML attribute inheritance', () => {
it('...', () => {});
});
});
Note: Because the โAPG: ARIA attributesโ category already tests state changes (click, initial state), the โbasic behaviorโ category is limited to Props to avoid duplication.
Guide for Adding a New Pattern
-
Check the APG specification
- https://www.w3.org/WAI/ARIA/apg/patterns/
- Required roles and
aria-*attributes - Keyboard interaction spec
- Focus management requirements
-
Create the test file
- Use existing tests as a reference and follow the same structure
- Write specific, descriptive test names
-
Write it DAMP
- Each test is self-contained
- Donโt fear duplication; prioritize clarity
Multi-Framework Testing
Approach
Each framework (React, Vue, Svelte, Astro) has its own independent test file. The test perspectives are shared, but the test code itself follows the DAMP principle and is written explicitly per framework.
src/patterns/button/
โโโ ToggleButton.tsx
โโโ ToggleButton.test.tsx # React unit tests
โโโ ToggleButton.vue
โโโ ToggleButton.test.vue.ts # Vue unit tests
โโโ ToggleButton.svelte
โโโ ToggleButton.test.svelte.ts # Svelte unit tests
โโโ ToggleButton.astro
โโโ ToggleButton.test.astro.ts # Astro unit tests (Container API)
e2e/
โโโ table-visual-spanning.spec.ts # E2E tests (shared across frameworks)
Kinds of tests
| Test kind | Target | Tools | Command |
|---|---|---|---|
| Unit | React/Vue/Svelte | @testing-library + jsdom | npm run test:unit |
| Unit | Astro | Container API + JSDOM | npm run test:astro |
| E2E | All frameworks | Playwright | npm run test:e2e |
Per-framework unit tests
| Framework | Test library | Environment | Command |
|---|---|---|---|
| React | @testing-library/react | Vitest + jsdom | npm run test:react |
| Vue | @testing-library/vue | Vitest + jsdom | npm run test:vue |
| Svelte | @testing-library/svelte | Vitest + jsdom | npm run test:svelte |
| Astro | Container API | Vitest + JSDOM | npm run test:astro |
E2E tests
E2E tests live in the e2e/ directory and use Playwright to test behavior across all frameworks.
Use them to verify visual rendering (such as CSS Grid spanning) and real browser behavior.
// e2e/table-visual-spanning.spec.ts
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
for (const framework of frameworks) {
test.describe(`Table Visual Spanning (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/patterns/table/${framework}/`);
});
// ...
});
}
Two-Layer Test Strategy for Astro Web Component Patterns
When an Astro component uses a Web Component (a class extends HTMLElement inside a <script> block),
the tests must be split into two layers.
Why two layers
An Astro component consists of two parts:
- Template part โ the HTML output rendered on the server side
- Web Component part โ the JavaScript that runs on the client side
The Container API renders template output on the server and does not execute browser-side
Web Component scripts. In addition, browser globals such as HTMLElement are not available
in a plain Node.js environment. Use E2E tests for client-side Web Component behavior.
Test split approach
| Test layer | Target | Tools | What it tests |
|---|---|---|---|
| Unit (Container API) | Template output | Vitest + JSDOM | HTML structure, attributes, CSS classes |
| E2E (Playwright) | Web Component behavior | Playwright | Click, keyboard, events, focus |
What the Container API can test
- Existence and hierarchy of HTML elements
- Initial attribute values (
checked,disabled,aria-*, etc.) - Attributes generated from props
- Application of CSS classes
- Conditional rendering
What should be tested with E2E
- State changes from click and keyboard interaction
- Dispatching of custom events
- States set by JavaScript such as
indeterminate - Focus management and tab navigation
- Toggle behavior when clicking a label
Example: Checkbox
src/patterns/checkbox/
โโโ Checkbox.astro # The component itself
โโโ Checkbox.test.astro.ts # Container API test (template output)
e2e/
โโโ checkbox.spec.ts # E2E test (Web Component behavior)
Container API test (verifying template output):
// Checkbox.test.astro.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import Checkbox from './Checkbox.astro';
describe('Checkbox (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
it('renders input with type="checkbox"', async () => {
const html = await container.renderToString(Checkbox, { props: {} });
const doc = new JSDOM(html).window.document;
expect(doc.querySelector('input[type="checkbox"]')).not.toBeNull();
});
it('renders with checked attribute when initialChecked is true', async () => {
const html = await container.renderToString(Checkbox, {
props: { initialChecked: true }
});
const doc = new JSDOM(html).window.document;
expect(doc.querySelector('input')?.hasAttribute('checked')).toBe(true);
});
});
E2E test (verifying Web Component behavior):
// e2e/checkbox.spec.ts
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
for (const framework of frameworks) {
test.describe(`Checkbox (${framework})`, () => {
// Helper to get checkbox and its visual control
const getCheckbox = (page, id: string) => {
const checkbox = page.locator(`#${id}`);
// The visual control is a sibling of the input
const control = checkbox.locator('~ .apg-checkbox-control');
return { checkbox, control };
};
test('toggles checked state on click', async ({ page }) => {
await page.goto(`patterns/checkbox/${framework}/`);
// Note: The input is visually hidden (1x1px), so we click
// the visual control instead of the input directly
const { checkbox, control } = getCheckbox(page, 'demo-terms');
await expect(checkbox).not.toBeChecked();
await control.click();
await expect(checkbox).toBeChecked();
});
test('clicking label toggles checkbox', async ({ page }) => {
// Label association test - clicks label, not the control
const { checkbox } = getCheckbox(page, 'demo-terms');
const label = page.locator('label').filter({ has: checkbox });
await expect(checkbox).not.toBeChecked();
await label.click();
await expect(checkbox).toBeChecked();
});
test('dispatches checkedchange event', async ({ page }) => {
// Custom event tests are done in E2E (Astro only)
});
});
}
Astro patterns that do not use a Web Component
Patterns that do not use a Web Component and are purely template-based, like Table, can be fully covered by Container API tests alone.
Why independent tests
- Follows the DAMP principle โ each test is self-contained
- Pinpoints framework-specific issues immediately โ e.g. Vueโs
v-bind/ Svelteโs$props() - Can run in parallel โ efficient in CI
- Useful as a learning resource โ demonstrates the testing approach for each framework
Standalone Demo Page (for E2E testing)
For some patterns (Landmarks in particular), the semantics can change when a component is
embedded inside the page layout. For example, a <header> element loses its implicit banner
role when nested inside the pageโs <main>.
For such patterns, we create a standalone demo page. This page:
- Is used in E2E tests to verify exact semantics
- Is also offered as a link for users who want to view only the demo
Directory layout
src/pages/patterns/landmarks/
โโโ react/
โ โโโ index.astro # Normal pattern page (with layout)
โ โโโ demo/
โ โโโ index.astro # Standalone demo page (no layout)
โโโ vue/
โ โโโ index.astro
โ โโโ demo/
โ โโโ index.astro
โโโ svelte/
โ โโโ index.astro
โ โโโ demo/
โ โโโ index.astro
โโโ astro/
โโโ index.astro
โโโ demo/
โโโ index.astro
Implementing the standalone demo page
---
// src/pages/patterns/landmarks/react/demo/index.astro
/**
* Demo-only Page: LandmarkDemo (React)
*
* This page renders the LandmarkDemo component in isolation without
* the site layout. This ensures proper landmark semantics are preserved
* (e.g., <header> retains its implicit banner role).
*
* Used for:
* - E2E testing with correct landmark structure
* - Standalone demo viewing
*/
import '@/styles/global.css';
import LandmarkDemo from '@patterns/landmarks/LandmarkDemo.tsx';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Demo: Landmarks (React)</title>
</head>
<body>
<LandmarkDemo client:load showLabels={true} />
</body>
</html>
Linking from the pattern page to the standalone demo
Add an โOpen demo onlyโ link to the demo section of the pattern page:
<!-- Demo Section -->
<section class="mb-12">
<Heading level={2} class="mb-4 text-xl font-semibold">Demo</Heading>
<p class="text-muted-foreground mb-4">
This demo visualizes the 8 landmark regions with distinct colored borders.
</p>
<div class="border-border bg-background rounded-lg border p-6">
<LandmarkDemo showLabels={true} />
</div>
<p class="text-muted-foreground mt-2 text-sm">
<a href="./demo/" class="text-primary hover:underline">Open demo only โ</a>
</p>
</section>
E2E test path
// e2e/landmarks.spec.ts
for (const framework of frameworks) {
test.describe(`Landmarks (${framework})`, () => {
test.beforeEach(async ({ page }) => {
// Use the standalone demo page
await page.goto(`patterns/landmarks/${framework}/demo/`);
});
// ...
});
}
Patterns that should use this approach
| Pattern | Reason |
|---|---|
| Landmarks | <header>/<footer> lose their role inside the page <main> |
| Dialog | The focus trap may interfere with page elements |
| Others | When the page layout affects the componentโs behavior |
Notes
- Standalone demo pages are excluded from search engines with a
robots: noindex, nofollowmeta tag - The path structure
/patterns/{pattern}/{framework}/demo/is clear and meaningful as a URL - Accessible from the pattern page via the โOpen demo onlyโ link
- Recommended because it does not increase CI time (no separate build required)
Shared Test Considerations
Even across different frameworks, APG-conformant components are tested with the same considerations.
ToggleButton
| Category | Test consideration |
|---|---|
| ๐ด APG: keyboard | Toggle with Space, toggle with Enter, move focus with Tab |
| ๐ด APG: ARIA | role=โbuttonโ, aria-pressed state change, type=โbuttonโ |
| ๐ก Accessibility | No axe violations, accessible name |
| Props | initialPressed, onPressedChange |
| ๐ข HTML attribute inheritance | className merge, data-* inheritance |
Tabs
| Category | Test consideration |
|---|---|
| ๐ด APG: keyboard | Arrow navigation, Home/End, looping, manual activation |
| ๐ด APG: ARIA | role=โtablist/tab/tabpanelโ, aria-selected, aria-controls/labelledby |
| ๐ด Focus management | Roving tabindex, Tab moves to the panel |
| ๐ก Accessibility | No axe violations |
| Props | defaultSelectedId, orientation, activationMode |
| ๐ข HTML attribute inheritance | className applied |
Accordion
| Category | Test consideration |
|---|---|
| ๐ด APG: keyboard | Open/close with Enter/Space (move between headers via Tab order) |
| ๐ด APG: ARIA | aria-expanded, aria-controls/labelledby, conditions for role=โregionโ |
| ๐ด Heading structure | h2-h6 via headingLevel |
| ๐ก Accessibility | No axe violations |
| Props | defaultExpanded, allowMultiple |
| ๐ข HTML attribute inheritance | className applied |
Stabilizing E2E Tests
Because E2E tests run in a real browser, timing-dependent flaky tests are easy to introduce. Knowing the following patterns and countermeasures helps you write stable tests.
Causes of flaky tests and countermeasures
1. Focus race condition
Problem: Using page.keyboard.press() after click() can send the key event to an
unintended element, because focus may momentarily move to another element.
// โ Flaky: focus after click() is unstable
await secondTab.click();
await page.keyboard.press('ArrowLeft'); // focus may not be on secondTab
// โ
Stable: use locator.press()
await secondTab.click();
await secondTab.press('ArrowLeft'); // sends the key to the locator (focuses it first)
Countermeasure:
- Use
locator.press()instead ofpage.keyboard.press() locator.press()focuses the target locator before dispatching the key event, reducing dependence on whichever element currently has focus
2. Calling focus() on a non-selected tab
Problem: In the Roving tabindex pattern, non-selected elements have tabindex="-1".
Calling focus() on these elements may not stay in sync with the componentโs internal state,
so it does not behave as expected.
// โ Unstable: focus() on a tabindex="-1" element
const secondTab = tabs.getByRole('tab').nth(1); // tabindex="-1"
await secondTab.focus(); // behaves unreliably
await page.keyboard.press('ArrowLeft');
// โ
Stable: move via keyboard navigation
const firstTab = tabs.getByRole('tab').first(); // tabindex="0" (selected)
await firstTab.focus();
await expect(firstTab).toBeFocused();
await firstTab.press('ArrowRight'); // move to secondTab with the keyboard
await expect(secondTab).toBeFocused();
await secondTab.press('ArrowLeft'); // test the intended operation
Countermeasure:
- For these tests, focus the active tab (
tabindex="0") first, then navigate to the target tab with the keyboard - Avoid focusing a
tabindex="-1"tab directly unless the test specifically covers programmatic focus
3. Insufficient waiting for hydration
Problem: Framework components (React in particular) do not become interactive until JavaScript hydration completes, even after the HTML has rendered.
// โ Insufficient: only waiting for the element to exist
await getTabs(page).first().waitFor();
// โ
Sufficient: also check attributes that indicate hydration is complete
await getTabs(page).first().waitFor();
const firstTab = getTabButtons(page).first();
// Is the ID set correctly? (an application-specific hydration indicator)
await expect.poll(async () => {
const id = await firstTab.getAttribute('id');
return id && id.length > 1 && !id.startsWith('-');
}).toBe(true);
// Is it in an interactive state?
await expect(firstTab).toHaveAttribute('tabindex', '0');
await expect(firstTab).toHaveAttribute('aria-selected', 'true');
Countermeasure:
- In
beforeEach, wait for attributes that indicate hydration is complete (id,aria-controls, etc.) - Confirm that the initial-state ARIA attributes (
tabindex,aria-selected, etc.) are set
Choosing between unit tests and E2E
The jsdom/happy-dom environment can behave differently from a real browser when it comes to focus.
| Test target | Recommended environment | Reason |
|---|---|---|
| Initial values of ARIA attributes | Unit | Can be verified with DOM operations alone |
| Keyboard navigation | E2E | Focus movement is accurate |
| Focus trap | E2E | Tab key behavior is unstable in jsdom |
| Focus restoration | E2E | Complex focus management is verified in a real browser |
Example: when a focus-trap test is flaky in jsdom
// Remove from unit tests and cover with E2E
// src/patterns/alert-dialog/AlertDialog.test.vue.ts
// Note: the "Tab wraps from last to first" test is guaranteed by E2E
// (e2e/alert-dialog.spec.ts: "Tab wraps from last to first element")
// Removed from unit tests because focus operations are unstable in jsdom
Playwright keyboard interaction best practices
| Method | Use | Stability |
|---|---|---|
locator.focus() | Set focus on a locator | โ
best for tabindex="0" elements |
locator.press(key) | Focus the locator, then send a key | โ stable |
locator.click() | Click a locator | โ ๏ธ watch out for keyboard.press() after focus moves |
page.keyboard.press(key) | Send a key to the currently focused element | โ ๏ธ possible focus race |
Recommended pattern:
// Testing keyboard navigation
test('ArrowRight moves focus to next tab', async ({ page }) => {
const firstTab = tabs.getByRole('tab').first();
const secondTab = tabs.getByRole('tab').nth(1);
// 1. Focus the selected element
await firstTab.focus();
await expect(firstTab).toBeFocused();
// 2. Send the key with locator.press()
await firstTab.press('ArrowRight');
// 3. Verify focus moved
await expect(secondTab).toBeFocused();
});
Tools Used
| Tool | Use |
|---|---|
| Vitest | Test runner |
| @testing-library/react | React component testing |
| @testing-library/vue | Vue component testing |
| @testing-library/svelte | Svelte component testing |
| @testing-library/user-event | User interaction |
| @testing-library/jest-dom | Custom matchers |
| jest-axe | Automated accessibility testing |
| Astro Container API | Astro component testing |
| Playwright | E2E testing (all frameworks) |
| @vitest/coverage-v8 | Coverage measurement |
Setup file
Extend the matchers in src/test/setup.ts:
import '@testing-library/jest-dom/vitest';
import { toHaveNoViolations } from 'jest-axe';
import { expect } from 'vitest';
expect.extend(toHaveNoViolations);
References
- WAI-ARIA APG
- DRY vs DAMP in Unit Tests
- Testing Library
- jest-axe
- Playwright Locators โ the difference between
locator.press()andpage.keyboard.press() - Playwright Best Practices