Chapter 11
Testing accessible components
This chapter covers the testing tools and what to test for each component type.
Tests give you confidence to change things. In a component library, that confidence matters more than usual, because a regression in one component affects every consumer of the library. This chapter covers the testing tools and what to test for each component type.
Install dependencies
This installs the packages required for the test files introduced in Chapter 4. The Stack tests created there can now be run.
vitest was already installed in Chapter 10 as part of the Storybook setup. Install only the remaining dependencies:
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom vitest-axe
Test configuration
There is no vitest.config.ts. Vitest reads its configuration from vite.config.ts, and keeping it there means the test run inherits the same aliases, plugins, and JSX handling as the build. Two configs is two places for @/ to resolve differently.
Add a test block to the vite.config.ts you created in chapter 2:
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
import { playwright } from '@vitest/browser-playwright'
const rootDir = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': resolve(rootDir, 'src'),
},
},
test: {
projects: [
{
extends: true,
test: {
name: 'unit',
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
},
},
{
extends: true,
plugins: [
storybookTest({ configDir: path.join(rootDir, '.storybook') }),
],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
})
Two projects, not one
The unit project is the conventional one: jsdom, your .test.tsx files, fast.
The storybook project is the one that does the work you would otherwise never get around to. It launches headless Chromium and renders every story you wrote in chapter 10, running the a11y addon against each. Seventy-five story files become seventy-five smoke tests plus an accessibility audit, for the cost of the config block above.
That matters more than it sounds. Hand-written unit tests cover the components you remembered to write tests for, and every library has components nobody got to. The story-based project covers everything that has a story, which in practice is everything.
extends: true on each project pulls in the top-level plugins and resolve so both share the React plugin and the @/ alias.
rootDir is derived from import.meta.url because __dirname does not exist in an ES module, and package.json declares "type": "module" from chapter 3 onward. Both the @/ alias and storybookTest, which needs an absolute path to the Storybook config directory, resolve against it.
npm test runs both projects, which means it needs a browser:
npx playwright install chromium
If you skip that, the unit project passes and the storybook project fails with a browser launch error that does not obviously say “run this command.”
Add the scripts:
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:watch": "vitest --watch"
}
}
Setup files and type shims
Create src/test-setup.ts:
import '@testing-library/jest-dom/vitest'
import 'vitest-axe/extend-expect'
import * as axeMatchers from 'vitest-axe/matchers'
import { expect } from 'vitest'
expect.extend(axeMatchers)
The last two lines are the ones people leave out, and leaving them out produces a confusing failure. Under Vitest 4 the extend-expect side-effect import alone does not register the axe matchers, so every expect(await axe(container)).toHaveNoViolations() fails with “toHaveNoViolations is not a function” while the accessibility itself is fine.
Two type declaration files are also required, and both are pure TypeScript plumbing with no runtime effect.
src/vitest-axe.d.ts teaches TypeScript about the matcher you just registered:
import type { AxeMatchers } from 'vitest-axe/matchers'
declare module '@vitest/expect' {
interface Assertion extends AxeMatchers {}
interface AsymmetricMatchersContaining extends AxeMatchers {}
}
vitest.shims.d.ts at the project root does the same for browser mode:
/// <reference types="@vitest/browser-playwright" />
Without these, the tests run green and npx tsc --noEmit fails, which is the worst combination because CI usually runs both.
Finally, add "types": ["vitest/globals"] to compilerOptions in tsconfig.json. That is what lets describe, it, expect, and vi be used without importing them, which the globals: true setting enables at runtime.
The two categories of tests
Layout primitive tests verify that the React component correctly applies the CSS classes and custom properties that the CSS depends on. They don’t test the visual layout itself (that’s the browser’s job).
UI component tests verify behavior: keyboard interaction, ARIA attributes, focus management, and the absence of axe-core violations. They test that the component is usable by keyboard and screen reader users, not just mouse users.
Testing layout primitives
Layout primitive tests confirm:
- The component renders its children.
- The component applies the correct CSS class.
- The component merges a custom
className. - Props set inline custom properties.
- Omitted props don’t set inline styles; the CSS token fallback applies instead.
- The
asprop changes the rendered element. - Boolean props apply modifier classes.
The Stack tests from Chapter 4 demonstrate this pattern, and every layout primitive follows the same structure with its own additions. Sidebar checks that it received exactly two children. Switcher checks the threshold custom property. Do not write a test for Switcher’s limit prop: chapter 5 covered why it is a stub, and a test asserting behavior that was never implemented is worse than no test.
Testing UI components
UI component tests have more to verify. Here’s the standard checklist, followed by an example:
Rendering: The component is findable by role and accessible name.
Keyboard activation: The component responds to the correct keys (Enter, Space, Arrow keys, Escape).
Disabled state: The component is marked disabled and does not respond to interaction. On a native <button> React Aria sets the disabled attribute, so expect(button).toBeDisabled() is the assertion that matches.
Error state: The component has aria-invalid="true" and the error message is linked via aria-describedby.
Focus behavior: Focus moves to the expected element on interaction (for example, into the dialog on open, back to the trigger on close).
Loading state: The component sets aria-busy="true", does not respond to interaction, and renders a visible loading indicator.
Ref forwarding: The ref reaches the underlying DOM element so consumers can control focus programmatically.
axe-core audit: No automated accessibility violations.
// src/components/Input/Input.test.tsx
import { createRef } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'vitest-axe';
import { describe, it, expect } from 'vitest';
import { RudiInput } from './Input'
describe('Input', () => {
it('renders with its label as the accessible name', () => {
render(<RudiInput label="Email" />);
expect(screen.getByRole('textbox', { name: 'Email' })).toBeInTheDocument();
});
it('accepts text input', async () => {
const onChange = vi.fn();
render(<RudiInput label="Email" onChange={onChange} />);
await userEvent.type(screen.getByRole('textbox'), 'hello');
expect(onChange).toHaveBeenLastCalledWith('hello');
});
it('shows the description and links it via aria-describedby', () => {
render(<RudiInput label="Email" description="Your work email" />);
const input = screen.getByRole('textbox');
const description = screen.getByText('Your work email');
expect(input).toHaveAttribute('aria-describedby', expect.stringContaining(description.id));
});
it('shows the error message and sets aria-invalid', () => {
render(<RudiInput label="Email" errorMessage="Required field" />);
const input = screen.getByRole('textbox');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByText('Required field')).toBeInTheDocument();
});
it('hides the description when an error message is present', () => {
render(<RudiInput label="Email" description="Your work email" errorMessage="Required" />);
expect(screen.queryByText('Your work email')).not.toBeInTheDocument();
});
it('marks the input as required', () => {
render(<RudiInput label="Email" isRequired />);
expect(screen.getByRole('textbox')).toHaveAttribute('aria-required', 'true');
});
it('disables the input', () => {
render(<RudiInput label="Email" isDisabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('forwards ref to the underlying input', () => {
const ref = createRef<HTMLInputElement>();
render(<RudiInput label="Email" ref={ref} />);
expect(ref.current).toBe(screen.getByRole('textbox'));
});
it('has no accessibility violations', async () => {
const { container } = render(<RudiInput label="Email" />);
expect(await axe(container)).toHaveNoViolations();
});
it('has no accessibility violations in error state', async () => {
const { container } = render(<RudiInput label="Email" errorMessage="Required" />);
expect(await axe(container)).toHaveNoViolations();
});
});
The Button loading state tests follow the same pattern. They verify the ARIA contract and the absence of interaction side effects:
// src/components/Button/Button.test.tsx (loading state tests)
it('sets aria-busy when loading', () => {
render(<RudiButton isLoading>Save</RudiButton>);
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true');
});
it('does not call onPress while loading', async () => {
const onPress = vi.fn();
render(<RudiButton isLoading onPress={onPress}>Save</RudiButton>);
await userEvent.click(screen.getByRole('button'));
expect(onPress).not.toHaveBeenCalled();
});
it('renders a visible loading indicator', () => {
render(<RudiButton isLoading>Save</RudiButton>);
expect(screen.getByRole('button')).toContainElement(screen.getByRole('img', { hidden: true }));
});
it('has no accessibility violations while loading', async () => {
const { container } = render(<RudiButton isLoading>Save</RudiButton>);
expect(await axe(container)).toHaveNoViolations();
});
Notice the testing approach: assertions lean on accessible queries (getByRole, getByText) and ARIA attributes (aria-invalid, aria-describedby, aria-required) rather than on markup structure. Refactor the component’s internals and these tests keep passing, as long as the behavior and the accessibility contract hold.
That principle has a limit. Some assertions do need to reach for class names, because the class name is the contract between the component and its stylesheet:
expect(container.firstChild).toHaveClass('rudi-stack--split')
Nothing in the accessibility tree distinguishes a Stack with splitAfter from one without it. If splitAfter silently stopped emitting rudi-stack--split, a role-only test suite would stay green while the layout broke for every consumer. The same applies to Button’s icon slots: button.querySelectorAll('.rudi-button__icon') is the only way to assert that iconBefore rendered and that loading state removed it.
The useful rule is narrower than “never query by class.” Query by role for anything a user perceives, and by class only for the contract your own CSS depends on. What to actually avoid is asserting on incidental structure, such as depth of nesting or wrapper elements that exist only for layout.
Running tests
npm test # both projects
npm run test:watch # re-run on change
npm run test:coverage # coverage report
npx vitest run --project unit # jsdom only, no browser
npx vitest run --project storybook # story smoke tests only
npx vitest run --project unit src/components/Button/Button.test.tsx
npx vitest run --project unit -t "sets aria-busy" # single test by name
Reach for --project unit while iterating. A bare npx vitest Input matches files in both projects and boots Chromium to do it, which turns a two-second feedback loop into a fifteen-second one.
Coverage targets
| Category | Target |
|---|---|
| Layout primitives | Every prop, class application, and custom property injection |
| UI components | Every keyboard path, every ARIA attribute, every state |
| axe-core, unit | A toHaveNoViolations assertion in every component’s test file |
| axe-core, stories | Automatic, via the browser project, for anything with a story |
| Visual regression | Not covered; the browser project renders but does not compare |
Full coverage of accessible behavior is worth more than full line coverage. A suite that verifies every keyboard path and ARIA attribute catches the regressions that actually reach consumers.
The test examples in chapter 4 and in this chapter include the axe assertion for Stack, Input, and Button only, for brevity. Apply the same toHaveNoViolations() check to every component you build.
This chapter has been corrected since it was first published: see what changed. Found an error? Report it.