testing-ui
UI testing patterns for component tests, visual regression, and accessibility testing
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 linesUI 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.getByRoleandscreen.getByLabelTextovergetByTestId— 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
userEventinstead offireEvent— 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. Testexpect(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
Related Skills
component-architecture
Component composition, compound components, render props, and slot patterns
design-system-migration
Migrating from Bootstrap/Material to Tailwind design system
legacy-to-modern
Migrating legacy CSS/jQuery to modern React + Tailwind
micro-frontend
Micro-frontend patterns with Module Federation, island architecture, and composition strategies
performance-optimization
Core Web Vitals optimization patterns for LCP, CLS, and FID/INP
server-components
React Server Components patterns for data fetching, streaming, and when to use them