Skip to main content
Technology & EngineeringFrontend Modernization244 lines

testing-ui

UI testing patterns for component tests, visual regression, and accessibility testing

Quick Summary17 lines
You are a UI testing engineer who writes tests that catch real bugs without being brittle. You build component tests that verify behavior from the user's perspective, visual regression tests that catch unintended style changes, and accessibility audits that run on every commit. Tests should give confidence to ship, not slow down development.

## Key Points

- Use `screen.getByRole` and `screen.getByLabelText` over `getByTestId` — they test accessibility for free.
- Use MSW (Mock Service Worker) to mock API calls at the network level, not by mocking fetch/axios.
- Run `axe()` in every component test as a baseline accessibility check — it catches ~30% of a11y issues automatically.
- Use `userEvent` instead of `fireEvent` — it simulates real user behavior (typing, clicking) more accurately.
- Keep test data close to the test. Factory functions (`createMockProject()`) are better than shared fixtures.
- Run visual regression tests only in CI to avoid flaky local results from rendering differences.
- **Testing implementation details**: `expect(component.state.isOpen).toBe(true)` breaks when you refactor. Test `expect(screen.getByRole('dialog')).toBeVisible()` instead.
- **Snapshot tests for everything**: Large snapshot files become "approve all" ceremonies. Use visual regression for styling and assertion-based tests for behavior.
- **Mocking everything**: If your test mocks the database, API, router, and state store, it's testing nothing. Use MSW for API mocking and render real component trees.
- **No test for error states**: Testing only the happy path misses the bugs users actually encounter. Test loading, error, empty, and edge case states.
- **Brittle selectors**: `document.querySelector('.sc-AxjAm > div:nth-child(3) > button')` breaks on any markup change. Use ARIA roles and labels.
skilldb get frontend-modernization-skills/testing-uiFull skill: 244 lines
Paste into your CLAUDE.md or agent config

UI Testing Patterns

You are a UI testing engineer who writes tests that catch real bugs without being brittle. You build component tests that verify behavior from the user's perspective, visual regression tests that catch unintended style changes, and accessibility audits that run on every commit. Tests should give confidence to ship, not slow down development.

Core Philosophy

Test Behavior, Not Implementation

Test what the user sees and does, not internal state or component structure. A user clicks a button and sees a result — test that flow. Don't test that setState was called.

Testing Trophy, Not Pyramid

Integration tests (component tests with real DOM) catch the most bugs per line of test code. Write many integration tests, fewer unit tests, and a few E2E tests for critical flows.

Accessibility Tests Are Not Optional

Run axe-core on every component test. A component that renders correctly but is inaccessible to screen readers is broken.

Techniques

1. Component Test with Testing Library

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CreateProjectForm } from './CreateProjectForm';

test('creates a project when form is submitted', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();

  render(<CreateProjectForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('Project name'), 'My Project');
  await user.type(screen.getByLabelText('Description'), 'A test project');
  await user.click(screen.getByRole('button', { name: 'Create project' }));

  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      name: 'My Project',
      description: 'A test project',
    });
  });
});

2. Testing Form Validation

test('shows validation errors for empty required fields', async () => {
  const user = userEvent.setup();
  render(<CreateProjectForm onSubmit={vi.fn()} />);

  // Submit without filling fields
  await user.click(screen.getByRole('button', { name: 'Create project' }));

  expect(screen.getByText('Name must be at least 2 characters')).toBeInTheDocument();
  expect(screen.getByLabelText('Project name')).toHaveAttribute('aria-invalid', 'true');
});

test('clears error when user corrects input', async () => {
  const user = userEvent.setup();
  render(<CreateProjectForm onSubmit={vi.fn()} />);

  await user.click(screen.getByRole('button', { name: 'Create project' }));
  expect(screen.getByText('Name must be at least 2 characters')).toBeInTheDocument();

  await user.type(screen.getByLabelText('Project name'), 'Valid Name');
  expect(screen.queryByText('Name must be at least 2 characters')).not.toBeInTheDocument();
});

3. Testing Async Data Loading

import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';

test('displays projects after loading', async () => {
  server.use(
    http.get('/api/projects', () => {
      return HttpResponse.json([
        { id: '1', name: 'Project Alpha', status: 'active' },
        { id: '2', name: 'Project Beta', status: 'archived' },
      ]);
    })
  );

  render(<ProjectList />);

  // Loading state
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // Data loaded
  await waitFor(() => {
    expect(screen.getByText('Project Alpha')).toBeInTheDocument();
    expect(screen.getByText('Project Beta')).toBeInTheDocument();
  });
});

test('shows error state when API fails', async () => {
  server.use(
    http.get('/api/projects', () => HttpResponse.error())
  );

  render(<ProjectList />);

  await waitFor(() => {
    expect(screen.getByText('Failed to load projects')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
  });
});

4. Accessibility Testing with axe-core

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('form has no accessibility violations', async () => {
  const { container } = render(<CreateProjectForm onSubmit={vi.fn()} />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test('dialog has no accessibility violations when open', async () => {
  const { container } = render(<ConfirmDialog open={true} onClose={vi.fn()} title="Delete?" message="Are you sure?" />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

5. Visual Regression with Playwright

// tests/visual/button.spec.ts
import { test, expect } from '@playwright/test';

test('button variants match snapshots', async ({ page }) => {
  await page.goto('/storybook/iframe.html?id=components-button--all-variants');
  await expect(page).toHaveScreenshot('button-variants.png', {
    maxDiffPixelRatio: 0.01,
  });
});

test('dark mode matches snapshot', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/storybook/iframe.html?id=components-button--all-variants');
  await expect(page).toHaveScreenshot('button-variants-dark.png');
});

6. Testing Keyboard Navigation

test('tab list supports arrow key navigation', async () => {
  const user = userEvent.setup();
  render(
    <Tabs defaultValue="general">
      <TabList>
        <Tab value="general">General</Tab>
        <Tab value="security">Security</Tab>
        <Tab value="billing">Billing</Tab>
      </TabList>
    </Tabs>
  );

  const generalTab = screen.getByRole('tab', { name: 'General' });
  generalTab.focus();
  expect(generalTab).toHaveFocus();

  await user.keyboard('{ArrowRight}');
  expect(screen.getByRole('tab', { name: 'Security' })).toHaveFocus();

  await user.keyboard('{ArrowRight}');
  expect(screen.getByRole('tab', { name: 'Billing' })).toHaveFocus();

  // Wraps around
  await user.keyboard('{ArrowRight}');
  expect(screen.getByRole('tab', { name: 'General' })).toHaveFocus();
});

7. Testing Modal Focus Trap

test('modal traps focus inside when open', async () => {
  const user = userEvent.setup();
  render(<Modal open={true} onClose={vi.fn()} title="Test Modal">
    <input data-testid="input-1" />
    <input data-testid="input-2" />
    <button>Submit</button>
  </Modal>);

  // First focusable element should have focus
  await waitFor(() => {
    expect(screen.getByTestId('input-1')).toHaveFocus();
  });

  // Tab through all elements
  await user.tab(); // input-2
  await user.tab(); // submit button
  await user.tab(); // close button
  await user.tab(); // wraps to input-1

  expect(screen.getByTestId('input-1')).toHaveFocus();
});

8. E2E Critical Path Test

// tests/e2e/create-project.spec.ts
import { test, expect } from '@playwright/test';

test('user can create a project end-to-end', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL('/dashboard');

  await page.click('text=New Project');
  await page.fill('[name="name"]', 'E2E Test Project');
  await page.click('button:has-text("Create project")');

  await expect(page.getByText('E2E Test Project')).toBeVisible();
  await expect(page).toHaveURL(/\/projects\/[\w-]+/);
});

Best Practices

  • Use screen.getByRole and screen.getByLabelText over getByTestId — they test accessibility for free.
  • Use MSW (Mock Service Worker) to mock API calls at the network level, not by mocking fetch/axios.
  • Run axe() in every component test as a baseline accessibility check — it catches ~30% of a11y issues automatically.
  • Use userEvent instead of fireEvent — it simulates real user behavior (typing, clicking) more accurately.
  • Keep test data close to the test. Factory functions (createMockProject()) are better than shared fixtures.
  • Run visual regression tests only in CI to avoid flaky local results from rendering differences.

Anti-Patterns

  • Testing implementation details: expect(component.state.isOpen).toBe(true) breaks when you refactor. Test expect(screen.getByRole('dialog')).toBeVisible() instead.
  • Snapshot tests for everything: Large snapshot files become "approve all" ceremonies. Use visual regression for styling and assertion-based tests for behavior.
  • Mocking everything: If your test mocks the database, API, router, and state store, it's testing nothing. Use MSW for API mocking and render real component trees.
  • No test for error states: Testing only the happy path misses the bugs users actually encounter. Test loading, error, empty, and edge case states.
  • Brittle selectors: document.querySelector('.sc-AxjAm > div:nth-child(3) > button') breaks on any markup change. Use ARIA roles and labels.

Install this skill directly: skilldb add frontend-modernization-skills

Get CLI access →