Write unit tests
Critical functionality has unit tests with good coverage for reliability.
- Test business logic, utilities, and edge cases
- Aim for 80%+ coverage on critical paths
- Use descriptive test names that explain behavior
- Mock external dependencies, not internal modules
- Run tests in CI to catch regressions early
Rule Details
Unit tests verify that individual functions and components work correctly in isolation.
Code Examples
// 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: ['./vitest.setup.ts'],
include: ['**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.d.ts',
'**/*.config.*',
'**/types/*',
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})Why It Matters
Unit tests catch bugs before they reach production, serve as documentation for expected behavior, and give developers confidence to refactor code without breaking functionality.
What to Test
| Priority | Example | Why |
|---|---|---|
| Critical | Payment calculations | Financial accuracy |
| High | Form validation | User data integrity |
| High | Authentication logic | Security |
| Medium | Data transformations | Business logic |
| Medium | Utility functions | Reusable code |
| Low | Simple getters | Rarely break |
Testing Pure Functions
// utils/formatters.ts
export function formatCurrency(
amount: number,
currency: string = 'USD'
): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount)
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(d)
}// utils/formatters.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate } from './formatters'
describe('formatCurrency', () => {
it('formats USD by default', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56')
})
it('formats other currencies', () => {
expect(formatCurrency(1234.56, 'EUR')).toBe('€1,234.56')
expect(formatCurrency(1234.56, 'GBP')).toBe('£1,234.56')
})
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00')
})
it('handles negative amounts', () => {
expect(formatCurrency(-50)).toBe('-$50.00')
})
it('rounds to two decimal places', () => {
expect(formatCurrency(10.999)).toBe('$11.00')
})
})
describe('formatDate', () => {
it('formats Date objects', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('January 15, 2024')
})
it('formats date strings', () => {
expect(formatDate('2024-06-20')).toBe('June 20, 2024')
})
})Testing React Components
// components/Button.tsx
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
loading?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onClick,
disabled = false,
loading = false,
variant = 'primary',
}: ButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
className={`btn btn-${variant}`}
aria-busy={loading}
>
{loading ? 'Loading...' : children}
</button>
)
}// components/Button.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('calls onClick when clicked', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
it('shows loading state', () => {
render(<Button loading>Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveTextContent('Loading...')
expect(button).toHaveAttribute('aria-busy', 'true')
expect(button).toBeDisabled()
})
it('applies variant class', () => {
render(<Button variant="secondary">Cancel</Button>)
expect(screen.getByRole('button')).toHaveClass('btn-secondary')
})
it('does not call onClick when disabled', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
)
await user.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})Testing Custom Hooks
// hooks/useCounter.ts
import { useState, useCallback } from 'react'
export function useCounter(initialValue: number = 0) {
const [count, setCount] = useState(initialValue)
const increment = useCallback(() => setCount((c) => c + 1), [])
const decrement = useCallback(() => setCount((c) => c - 1), [])
const reset = useCallback(() => setCount(initialValue), [initialValue])
return { count, increment, decrement, reset }
}// hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('increments count', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(10)
})
})Testing Async Functions
// services/api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error(`User not found: ${id}`)
}
return response.json()
}// services/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchUser } from './api'
describe('fetchUser', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns user data on success', async () => {
const mockUser = { id: '1', name: 'John' }
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
} as Response)
const result = await fetchUser('1')
expect(result).toEqual(mockUser)
expect(fetch).toHaveBeenCalledWith('/api/users/1')
})
it('throws error when user not found', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response)
await expect(fetchUser('999')).rejects.toThrow('User not found: 999')
})
})Mocking Best Practices
// Mock external modules
vi.mock('@/services/analytics', () => ({
trackEvent: vi.fn(),
}))
// Mock environment variables
vi.stubEnv('API_URL', 'https://test-api.com')
// Mock timers
vi.useFakeTimers()
await vi.advanceTimersByTimeAsync(1000)
vi.useRealTimers()
// Mock implementations
const mockFn = vi.fn().mockImplementation((x) => x * 2)
// Spy on methods
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
// ... test
spy.mockRestore()Test Organization
// ✅ Good: Descriptive test structure
describe('ShoppingCart', () => {
describe('addItem', () => {
it('adds new item to empty cart', () => {})
it('increases quantity for existing item', () => {})
it('throws error for invalid quantity', () => {})
})
describe('removeItem', () => {
it('removes item from cart', () => {})
it('does nothing if item not in cart', () => {})
})
describe('calculateTotal', () => {
it('returns 0 for empty cart', () => {})
it('sums prices of all items', () => {})
it('applies discount codes', () => {})
})
})CI Integration
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: coverage/coverage-final.jsonTesting Patterns
| Pattern | Use Case | Example |
|---|---|---|
| AAA | Most tests | Arrange, Act, Assert |
| Given-When-Then | BDD style | Behavior descriptions |
| Test doubles | External deps | Mocks, stubs, spies |
| Parameterized | Multiple inputs | it.each([...]) |
| Snapshot | UI/large outputs | expect().toMatchSnapshot() |
Coverage Thresholds
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch"
}
}Testing Checklist
- ✅ Test happy path (expected inputs)
- ✅ Test edge cases (empty, null, boundary values)
- ✅ Test error conditions (invalid inputs)
- ✅ Test async behavior (loading, success, error states)
- ✅ Test accessibility (ARIA attributes, roles)
- ✅ Test user interactions (click, type, submit)
- ✅ Run tests in CI on every PR
High coverage doesn't mean high quality. Focus on testing critical paths and edge cases rather than achieving arbitrary coverage numbers. Test behavior, not implementation details.
Standards
- Use these references as the standard for how the test or monitoring strategy should behave in the shipped workflow.
- Check the implementation against Playwright Docs before treating the rule as satisfied.
- Check the implementation against Testing Library Guiding Principles before treating the rule as satisfied.
Verification
Automated Checks
- Test one primary path and one edge case affected by the change.
- Use browser or CI tooling where applicable to verify the fix.
Manual Checks
- Confirm the rule in the final rendered output or runtime behavior.
- Re-check shared abstractions so the fix is applied consistently.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Check if this codebase has unit tests with good coverage for critical functionality.
Fix
Auto-fix issues
Write unit tests for untested functions and components to improve code reliability.
Explain
Learn more
Explain how unit tests catch bugs early and serve as documentation for expected behavior.
Review
Code review
Review tests, CI workflows, and enforcement points related to Write unit tests. Flag exact gaps where the rule is not automatically verified or where failures do not block regressions.