---
name: testing
description: "Testing strategies and patterns for TypeScript/React/Next.js. Use when: writing unit tests, integration tests, e2e tests, setting up Vitest/Jest/Playwright, testing React components, testing API routes, mocking dependencies, or establishing testing patterns."
---
# Testing
## When to Use
- Writing unit, integration, or e2e tests
- Setting up test infrastructure (Vitest, Jest, Playwright)
- Testing React components, hooks, and forms
- Testing Next.js API routes and Server Components
- Mocking dependencies, APIs, or databases
- Establishing testing patterns for a project
## Testing Strategy
### What to Test (Priority Order)
1. **Business logic** — Pure functions, calculations, transformations
2. **Integration points** — API handlers, database queries, external services
3. **User interactions** — Forms, navigation, critical user flows
4. **Edge cases** — Null values, empty arrays, boundary conditions, error states
5. **Regression** — Bugs that were fixed (prevent them from coming back)
### What NOT to Test
- Implementation details (internal state, private methods)
- Third-party library internals
- Simple getters/setters with no logic
- Constant values or static configuration
- CSS or visual styling (use visual regression tools instead)
## Vitest (Preferred Test Runner)
### Setup
```bash
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
```
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
});
```
```typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
```
### Unit Tests
```typescript
// utils/format-currency.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
// utils/format-currency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './format-currency';
describe('formatCurrency', () => {
it('formats USD by default', () => {
expect(formatCurrency(1234.5)).toBe('$1,234.50');
});
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('handles negative amounts', () => {
expect(formatCurrency(-50)).toBe('-$50.00');
});
it('supports other currencies', () => {
expect(formatCurrency(1000, 'EUR')).toBe('€1,000.00');
});
});
```
### React Component Tests
```typescript
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchFilter } from './search-filter';
describe('SearchFilter', () => {
it('renders with initial query', () => {
render();
expect(screen.getByRole('textbox')).toHaveValue('hello');
});
it('calls onSearch when form is submitted', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render();
await user.type(screen.getByRole('textbox'), 'test query');
await user.click(screen.getByRole('button', { name: /search/i }));
expect(onSearch).toHaveBeenCalledWith('test query');
});
it('shows error for empty search', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole('button', { name: /search/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Please enter a search term');
});
});
```
### Hook Tests
```typescript
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './use-counter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments', () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
});
```
## Mocking
### Mock Functions
```typescript
// Mock a module
vi.mock('@/lib/db', () => ({
db: {
user: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
// Mock fetch
vi.stubGlobal('fetch', vi.fn());
// Mock implementation for a specific test
it('handles API error', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Not found' }), { status: 404 }),
);
const result = await fetchUser('invalid-id');
expect(result.ok).toBe(false);
});
```
### MSW (Mock Service Worker) for API Mocking
```typescript
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '3', ...body }, { status: 201 });
}),
];
// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts
import { server } from './server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```
## Playwright (E2E Testing)
### Setup
```bash
pnpm add -D @playwright/test
pnpm exec playwright install
```
```typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'pnpm dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
```
### E2E Test Example
```typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid credentials');
await expect(page).toHaveURL('/login');
});
});
```
## Testing Conventions
### File Naming
- Unit/integration: `.test.ts` or `.test.tsx` (co-located)
- E2E: `e2e/.spec.ts`
### Test Structure (AAA Pattern)
```typescript
it('describes the expected behavior', () => {
// Arrange — set up preconditions
const input = { name: 'Alice', age: 30 };
// Act — perform the action
const result = formatUser(input);
// Assert — verify the outcome
expect(result).toBe('Alice (30)');
});
```
### Test Naming
- Describe what the function/component does, not implementation
- Use natural language: `"shows error when email is invalid"`
- Group with `describe` blocks by function or feature
## Package.json Scripts
```jsonc
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
```