Frontend Testing Patterns
Vitest + Testing Library
// Button.test.tsx
import { render, screen, userEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const
render(<Button
await user.click(screen.getByRole('button', { name: 'Submit' }));
expect(onClick).toHaveBeenCalledOnce();
});
it('is disabled when loading', () => {
render(<Button loading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
MSW API Mocking
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () =>
HttpResponse.json([{ id: 1, name: 'Alice' }])
),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 2, ...body }, { status: 201 });
}),
http.get('/api/error', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 })
),
];
// mocks/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import { server } from './mocks/setup';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Async State Testing
it('displays users after loading', async () => {
render(<UserList />);
expect(screen.getByRole('status')).toBeInTheDocument(); // spinner
await screen.findByText('Alice'); // waits for async
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('shows error on failure', async () => {
server.use(http.get('/api/users', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
));
render(<UserList />);
await screen.findByRole('alert');
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
Playwright E2E
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/shop');
});
test('completes purchase', async ({ page }) => {
await page.getByRole('button', { name: 'Add to cart' }).first().click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByText('1 item')).toBeVisible();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
});
// playwright.config.ts
export default {
use: { baseURL: 'http://localhost:3000', screenshot: 'only-on-failure' },
webServer: { command: 'npm run dev', url: 'http://localhost:3000' },
};
Accessibility Testing
import { axe } from 'jest-axe';
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Playwright a11y
import { checkA11y } from 'axe-playwright';
test('home page is accessible', async ({ page }) => {
await page.goto('/');
await checkA11y(page, undefined, { detailedReport: true });
});
Visual Regression (Playwright)
test('hero section matches snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page.locator('.hero')).toHaveScreenshot('hero.png', {
maxDiffPixelRatio: 0.01,
});
});
Custom Render with Providers
// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function AllProviders({ children }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: AllProviders, ...options });
}