Testing Strategies for UI Component Libraries

A comprehensive testing strategy for design system component libraries, covering unit tests, interaction tests, visual regression tests, and accessibility audits in TypeScript.

technical9 min readBy Klivvr Engineering
Share:

A design system component library is consumed by dozens or hundreds of applications. A bug in a shared Button component is not just one bug — it is a bug multiplied across every product that uses it. This makes testing not just important but existentially critical. You need confidence that every component works correctly, looks correct, is accessible, and does not break when dependencies update. This article lays out a comprehensive, multi-layered testing strategy for TypeScript component libraries.

The Testing Pyramid for Component Libraries

The traditional testing pyramid — many unit tests, fewer integration tests, fewest end-to-end tests — applies to component libraries with some adaptations. Component libraries have unique testing needs that shift the pyramid's proportions.

Unit tests verify individual component behavior: rendering, prop handling, event callbacks, and state management. These are fast, focused, and form the bulk of your test suite.

Interaction tests verify user workflows: clicking a dropdown opens a menu, typing in an input filters options, tabbing through a form follows the correct order. These bridge the gap between unit tests and full integration.

Visual regression tests verify that components look correct across browsers, themes, and viewport sizes. No amount of unit testing catches a 1px layout shift that breaks alignment.

Accessibility audits verify ARIA compliance, keyboard navigation, and color contrast. These run automatically on every component in every state.

The ratio for a design system is roughly: 50% unit tests, 25% interaction tests, 15% visual regression, 10% accessibility audits. Every component should have all four types.

Unit Testing with React Testing Library

React Testing Library is the right tool for component testing because it tests from the user's perspective. You query by role, label, and text — the same things a real user perceives — rather than by CSS class or component internals.

// packages/core/src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
 
describe('Button', () => {
  it('renders children as button text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });
 
  it('calls onClick when clicked', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
 
    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
 
  it('does not call onClick when disabled', async () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick} disabled>Click me</Button>);
 
    await userEvent.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });
 
  it('shows a spinner when loading', () => {
    render(<Button isLoading>Click me</Button>);
    expect(screen.getByRole('button')).toHaveAttribute('disabled');
    expect(screen.getByRole('button')).toContainElement(
      document.querySelector('[data-testid="spinner"]')
    );
  });
 
  it('renders as an anchor when as="a" is passed', () => {
    render(<Button as="a" href="/about">About</Button>);
    const link = screen.getByRole('link', { name: 'About' });
    expect(link).toHaveAttribute('href', '/about');
  });
 
  it('applies the correct variant class', () => {
    const { rerender } = render(<Button variant="primary">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('button-primary');
 
    rerender(<Button variant="danger">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('button-danger');
  });
 
  it('forwards ref to the underlying button element', () => {
    const ref = React.createRef<HTMLButtonElement>();
    render(<Button ref={ref}>Test</Button>);
    expect(ref.current).toBeInstanceOf(HTMLButtonElement);
  });
 
  it('spreads additional HTML attributes', () => {
    render(<Button data-testid="custom" aria-describedby="help">Test</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveAttribute('data-testid', 'custom');
    expect(button).toHaveAttribute('aria-describedby', 'help');
  });
});

For components with complex state, test the state transitions explicitly:

// packages/core/src/components/Accordion/Accordion.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion } from './Accordion';
 
const items = [
  { id: '1', title: 'Section 1', content: 'Content 1' },
  { id: '2', title: 'Section 2', content: 'Content 2' },
  { id: '3', title: 'Section 3', content: 'Content 3' },
];
 
describe('Accordion', () => {
  it('renders all section headers', () => {
    render(<Accordion items={items} />);
    expect(screen.getByText('Section 1')).toBeInTheDocument();
    expect(screen.getByText('Section 2')).toBeInTheDocument();
    expect(screen.getByText('Section 3')).toBeInTheDocument();
  });
 
  it('starts with all sections collapsed by default', () => {
    render(<Accordion items={items} />);
    const buttons = screen.getAllByRole('button');
    buttons.forEach((button) => {
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });
  });
 
  it('expands a section when its header is clicked', async () => {
    render(<Accordion items={items} />);
 
    await userEvent.click(screen.getByText('Section 1'));
    expect(screen.getByText('Section 1').closest('button')).toHaveAttribute(
      'aria-expanded',
      'true'
    );
    expect(screen.getByText('Content 1')).toBeVisible();
  });
 
  it('collapses other sections in single mode', async () => {
    render(<Accordion items={items} mode="single" />);
 
    await userEvent.click(screen.getByText('Section 1'));
    expect(screen.getByText('Content 1')).toBeVisible();
 
    await userEvent.click(screen.getByText('Section 2'));
    expect(screen.getByText('Content 2')).toBeVisible();
    // Section 1 should now be collapsed
    expect(screen.getByText('Section 1').closest('button')).toHaveAttribute(
      'aria-expanded',
      'false'
    );
  });
 
  it('allows multiple open sections in multiple mode', async () => {
    render(<Accordion items={items} mode="multiple" />);
 
    await userEvent.click(screen.getByText('Section 1'));
    await userEvent.click(screen.getByText('Section 2'));
 
    expect(screen.getByText('Content 1')).toBeVisible();
    expect(screen.getByText('Content 2')).toBeVisible();
  });
});

Interaction Testing with Storybook

Storybook interaction tests let you write test scenarios that run inside the browser, against your actual rendered components. They combine the authoring convenience of a story with the rigor of a test assertion.

// packages/core/src/components/Combobox/Combobox.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Combobox } from './Combobox';
 
const options = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'angular', label: 'Angular' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
];
 
const meta: Meta<typeof Combobox> = {
  title: 'Components/Combobox',
  component: Combobox,
  args: {
    options,
    label: 'Framework',
    placeholder: 'Select a framework',
  },
};
 
export default meta;
type Story = StoryObj<typeof Combobox>;
 
export const Default: Story = {};
 
export const FilteringBehavior: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const input = canvas.getByRole('combobox');
 
    // Open the dropdown
    await userEvent.click(input);
    await expect(canvas.getByRole('listbox')).toBeInTheDocument();
 
    // Verify all options are shown initially
    const allOptions = canvas.getAllByRole('option');
    await expect(allOptions).toHaveLength(5);
 
    // Type to filter
    await userEvent.type(input, 're');
 
    // Only matching options should be visible
    const filteredOptions = canvas.getAllByRole('option');
    await expect(filteredOptions).toHaveLength(1);
    await expect(filteredOptions[0]).toHaveTextContent('React');
 
    // Clear and try another filter
    await userEvent.clear(input);
    await userEvent.type(input, 'v');
    const vOptions = canvas.getAllByRole('option');
    await expect(vOptions).toHaveLength(1);
    await expect(vOptions[0]).toHaveTextContent('Vue');
  },
};
 
export const KeyboardNavigation: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const input = canvas.getByRole('combobox');
 
    // Open with keyboard
    await userEvent.click(input);
    await userEvent.keyboard('{ArrowDown}');
 
    // First option should be highlighted
    const firstOption = canvas.getAllByRole('option')[0];
    await expect(firstOption).toHaveAttribute('aria-selected', 'true');
 
    // Navigate down
    await userEvent.keyboard('{ArrowDown}');
    const secondOption = canvas.getAllByRole('option')[1];
    await expect(secondOption).toHaveAttribute('aria-selected', 'true');
 
    // Select with Enter
    await userEvent.keyboard('{Enter}');
    await expect(input).toHaveValue('Vue');
 
    // Listbox should close after selection
    await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument();
  },
};

Interaction tests run in CI through Storybook's test runner, providing browser-level confidence without the overhead of full end-to-end tests.

Visual Regression Testing

Visual regression testing captures screenshots of your components and compares them against approved baselines. This catches CSS regressions, layout shifts, and rendering inconsistencies that are invisible to unit tests.

Chromatic (integrated with Storybook) or Playwright's screenshot comparison are the primary tools. Here is a Playwright setup for visual regression:

// tests/visual/button.spec.ts
import { test, expect } from '@playwright/test';
 
const variants = ['primary', 'secondary', 'ghost', 'danger'] as const;
const sizes = ['sm', 'md', 'lg'] as const;
 
test.describe('Button visual regression', () => {
  for (const variant of variants) {
    for (const size of sizes) {
      test(`${variant} ${size}`, async ({ page }) => {
        await page.goto(
          `/storybook/iframe.html?id=components-button--default&args=variant:${variant};size:${size}`
        );
        await page.waitForSelector('[data-testid="button"]');
 
        await expect(page.locator('[data-testid="button"]')).toHaveScreenshot(
          `button-${variant}-${size}.png`
        );
      });
    }
  }
 
  test('loading state', async ({ page }) => {
    await page.goto(
      '/storybook/iframe.html?id=components-button--default&args=isLoading:true'
    );
    await page.waitForSelector('[data-testid="button"]');
    await expect(page.locator('[data-testid="button"]')).toHaveScreenshot(
      'button-loading.png'
    );
  });
 
  test('disabled state', async ({ page }) => {
    await page.goto(
      '/storybook/iframe.html?id=components-button--default&args=disabled:true'
    );
    await page.waitForSelector('[data-testid="button"]');
    await expect(page.locator('[data-testid="button"]')).toHaveScreenshot(
      'button-disabled.png'
    );
  });
});

Run visual tests across multiple themes to catch theme-specific regressions:

// tests/visual/themed.spec.ts
import { test, expect } from '@playwright/test';
 
const themes = ['light', 'dark'] as const;
const components = ['button', 'card', 'input', 'alert'] as const;
 
for (const theme of themes) {
  test.describe(`${theme} theme`, () => {
    test.beforeEach(async ({ page }) => {
      await page.emulateMedia({
        colorScheme: theme === 'dark' ? 'dark' : 'light',
      });
    });
 
    for (const component of components) {
      test(`${component} renders correctly`, async ({ page }) => {
        await page.goto(
          `/storybook/iframe.html?id=components-${component}--default&globals=theme:${theme}`
        );
        await expect(page.locator('#storybook-root')).toHaveScreenshot(
          `${component}-${theme}.png`
        );
      });
    }
  });
}

Accessibility Testing in CI

Every component should pass an automated accessibility audit as part of its test suite. Combine jest-axe for unit-level checks with Storybook's accessibility addon for story-level checks:

// test-utils/a11y.ts
import { render, RenderResult } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import React from 'react';
 
expect.extend(toHaveNoViolations);
 
export async function expectNoA11yViolations(
  ui: React.ReactElement,
  axeOptions?: Parameters<typeof axe>[1]
): Promise<RenderResult> {
  const result = render(ui);
  const violations = await axe(result.container, axeOptions);
  expect(violations).toHaveNoViolations();
  return result;
}
 
// Usage in tests
import { expectNoA11yViolations } from '../../../test-utils/a11y';
import { Button } from './Button';
 
describe('Button a11y', () => {
  it('default state has no violations', async () => {
    await expectNoA11yViolations(<Button>Click</Button>);
  });
 
  it('disabled state has no violations', async () => {
    await expectNoA11yViolations(<Button disabled>Click</Button>);
  });
 
  it('as link has no violations', async () => {
    await expectNoA11yViolations(<Button as="a" href="/test">Link</Button>);
  });
});

For comprehensive coverage, write a script that audits every exported component with its default props:

// scripts/audit-a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import * as Components from '@messier/core';
 
expect.extend(toHaveNoViolations);
 
const componentDefaults: Record<string, Record<string, any>> = {
  Button: { children: 'Button text' },
  Input: { label: 'Input label' },
  Select: { label: 'Select label', options: [{ value: '1', label: 'Option 1' }] },
  Alert: { children: 'Alert message', variant: 'info' },
  Badge: { children: 'Badge text' },
  Card: { children: 'Card content' },
  Checkbox: { label: 'Checkbox label' },
  Modal: { isOpen: true, onClose: () => {}, 'aria-label': 'Test modal', children: 'Content' },
};
 
const skip = ['ThemeProvider', 'useTheme', 'useColorScheme']; // Not renderable components
 
for (const [name, Component] of Object.entries(Components)) {
  if (skip.includes(name) || typeof Component !== 'function') continue;
 
  describe(`${name} accessibility`, () => {
    it('has no axe violations with default props', async () => {
      const props = componentDefaults[name] || { children: 'Test content' };
      const { container } = render(<Component {...props} />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });
}

Organizing and Running the Test Suite

With multiple testing layers, organization and execution speed matter. Structure your test configuration to run different layers appropriately:

// jest.config.ts
import type { Config } from 'jest';
 
const config: Config = {
  projects: [
    {
      displayName: 'unit',
      testMatch: ['<rootDir>/packages/core/src/**/*.test.tsx'],
      testEnvironment: 'jsdom',
      setupFilesAfterSetup: ['<rootDir>/test-utils/setup.ts'],
      transform: {
        '^.+\\.tsx?$': ['@swc/jest'],
      },
    },
    {
      displayName: 'a11y',
      testMatch: ['<rootDir>/scripts/audit-a11y.test.tsx'],
      testEnvironment: 'jsdom',
      setupFilesAfterSetup: ['<rootDir>/test-utils/setup.ts'],
      transform: {
        '^.+\\.tsx?$': ['@swc/jest'],
      },
    },
  ],
};
 
export default config;

In CI, run tests in parallel and fail fast:

# .github/workflows/test.yml
jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx jest --project unit --ci --coverage
 
  a11y-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx jest --project a11y --ci
 
  visual-regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx playwright test tests/visual/

Conclusion

Testing a component library requires more than unit tests. The combination of unit tests for behavior, interaction tests for user workflows, visual regression tests for appearance, and automated accessibility audits creates a safety net that matches the responsibility of a shared library. Each layer catches a different category of bugs, and together they give you the confidence to ship frequently, refactor fearlessly, and maintain the trust of every team that depends on your components.

Related Articles

technical

Versioning and Publishing Component Libraries

A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.

10 min read
business

Measuring the ROI of a Design System

A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.

10 min read
business

Documentation That Developers Actually Read

How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.

12 min read