Build a comprehensive testing pyramid for your design system — unit tests for utilities, visual regression for components, and automated accessibility audits, all running in CI.
Build a comprehensive testing pyramid for your design system — unit tests for utilities, visual regression for components, and automated accessibility audits, all running in CI.
A design system needs a different test pyramid than a typical application. At the base: unit tests for pure functions (token utilities, color helpers, class name generators) — these are fast and numerous. In the middle: component integration tests using Testing Library that verify ARIA semantics, keyboard interactions, and state management. At the top: visual regression tests that capture screenshots and compare pixel-by-pixel against approved baselines. Accessibility tests run at all three levels — axe-core in unit tests, keyboard testing in integration tests, and screen reader output in visual tests. The pyramid inversion common in applications (few unit, many E2E) does not apply here: design systems benefit from many fast unit tests because components are pure UI with minimal side effects.
Chromatic provides cloud-based visual regression testing directly integrated with Storybook. Every PR renders all stories in a cloud browser and diffs against the approved baseline:
1# .github/workflows/chromatic.yml
2jobs:
3 chromatic:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 with:
8 fetch-depth: 0 # Required for Chromatic to detect changes
9
10 - run: pnpm install --frozen-lockfile
11
12 - name: Publish to Chromatic
13 uses: chromaui/action@v1
14 with:
15 projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
16 buildScriptName: 'storybook:build'
17 exitOnceUploaded: true
18 onlyChanged: true # Only test stories affected by the PR's changes
19 autoAcceptChanges: 'main' # Auto-accept on main after reviewChromatic's onlyChanged mode runs visual tests only on stories touched by the PR, reducing build time from 20 minutes to under 3 minutes for most PRs. Reviewers see a visual diff directly in GitHub PR checks and accept or deny changes with a click.
Design system utility functions need rigorous unit testing because hundreds of components depend on them:
1// packages/react/src/utils/cn.test.ts
2import { describe, it, expect } from 'vitest';
3import { cn } from './cn'; // class-names utility
4
5describe('cn (className utility)', () => {
6 it('merges Tailwind classes correctly', () => {
7 expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
8 });
9
10 it('handles conditional classes', () => {
11 const isActive = true;
12 expect(cn('base', { active: isActive, disabled: false })).toBe('base active');
13 });
14
15 it('ignores falsy values', () => {
16 expect(cn('base', undefined, null, false, '')).toBe('base');
17 });
18});
19
20// packages/tokens/src/utils/contrast.test.ts
21import { getContrastRatio } from './contrast';
22
23describe('getContrastRatio', () => {
24 it('returns 21:1 for black on white', () => {
25 expect(getContrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0);
26 });
27
28 it('returns 4.5 or higher for WCAG AA compliance', () => {
29 const ratio = getContrastRatio('#1d4ed8', '#ffffff');
30 expect(ratio).toBeGreaterThanOrEqual(4.5);
31 });
32
33 it('throws for invalid hex colors', () => {
34 expect(() => getContrastRatio('#gggggg', '#fff')).toThrow();
35 });
36});Component integration tests verify accessibility behaviors that axe-core cannot detect:
1// packages/react/src/Dialog/Dialog.test.tsx
2import { describe, it, expect, vi } from 'vitest';
3import { render, screen, fireEvent } from '@testing-library/react';
4import userEvent from '@testing-library/user-event';
5import { Dialog } from './Dialog';
6
7describe('Dialog accessibility', () => {
8 it('traps focus within the dialog when open', async () => {
9 const user = userEvent.setup();
10 render(
11 <Dialog open onClose={() => {}}>
12 <Dialog.Title>Confirm delete</Dialog.Title>
13 <button>Cancel</button>
14 <button>Delete</button>
15 </Dialog>
16 );
17
18 // Focus should be on the first focusable element
19 expect(screen.getByText('Cancel')).toHaveFocus();
20
21 // Tab cycles within the dialog
22 await user.tab();
23 expect(screen.getByText('Delete')).toHaveFocus();
24
25 // Tab wraps back to first element (focus trap)
26 await user.tab();
27 expect(screen.getByText('Cancel')).toHaveFocus();
28 });
29
30 it('closes on Escape and returns focus to trigger', async () => {
31 const user = userEvent.setup();
32 const onClose = vi.fn();
33 const { rerender } = render(
34 <><button id="trigger">Open</button>
35 <Dialog open onClose={onClose}>
36 <button>Action</button>
37 </Dialog></>
38 );
39 await user.keyboard('{Escape}');
40 expect(onClose).toHaveBeenCalledOnce();
41 });
42
43 it('has role dialog with aria-labelledby', () => {
44 render(
45 <Dialog open onClose={() => {}}>
46 <Dialog.Title>My Dialog</Dialog.Title>
47 </Dialog>
48 );
49 const dialog = screen.getByRole('dialog');
50 expect(dialog).toHaveAttribute('aria-labelledby');
51 });
52});Prevent test coverage from degrading over time by enforcing thresholds in Vitest configuration:
1// vitest.config.ts
2import { defineConfig } from 'vitest/config';
3
4export default defineConfig({
5 test: {
6 globals: true,
7 environment: 'jsdom',
8 setupFiles: ['./src/test-setup.ts'],
9 coverage: {
10 provider: 'v8',
11 reporter: ['text', 'json', 'lcov'],
12 reportsDirectory: './coverage',
13 thresholds: {
14 // Design system utilities: 95%+ coverage required
15 'src/utils/**': {
16 lines: 95,
17 branches: 90,
18 functions: 95,
19 statements: 95,
20 },
21 // Components: 80%+ for logic, visual regression covers the rest
22 'src/components/**': {
23 lines: 80,
24 branches: 75,
25 functions: 80,
26 statements: 80,
27 },
28 },
29 // Fail CI if overall coverage drops below these levels
30 lines: 85,
31 branches: 80,
32 functions: 85,
33 statements: 85,
34 },
35 },
36});Coverage reports upload to Codecov or Coveralls in CI, where a bot comments on PRs with coverage diff. Any PR that decreases coverage below the threshold fails CI automatically — no exceptions without explicit waiver from the design system team.
Set up Chromatic for your Storybook and configure the GitHub Actions workflow. Write at least one keyboard interaction test for your most complex component using @testing-library/user-event.
Your progress is saved in this browser, on this device only.