Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

Write unit tests

Critical functionality has unit tests with good coverage for reliability.

Utilities
Quick take
Typical fix time 30 min
  • 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
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.

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

PriorityExampleWhy
CriticalPayment calculationsFinancial accuracy
HighForm validationUser data integrity
HighAuthentication logicSecurity
MediumData transformationsBusiness logic
MediumUtility functionsReusable code
LowSimple gettersRarely 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.json

Testing Patterns

PatternUse CaseExample
AAAMost testsArrange, Act, Assert
Given-When-ThenBDD styleBehavior descriptions
Test doublesExternal depsMocks, stubs, spies
ParameterizedMultiple inputsit.each([...])
SnapshotUI/large outputsexpect().toMatchSnapshot()

Coverage Thresholds

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:watch": "vitest --watch"
  }
}

Testing Checklist

  1. ✅ Test happy path (expected inputs)
  2. ✅ Test edge cases (empty, null, boundary values)
  3. ✅ Test error conditions (invalid inputs)
  4. ✅ Test async behavior (loading, success, error states)
  5. ✅ Test accessibility (ARIA attributes, roles)
  6. ✅ Test user interactions (click, type, submit)
  7. ✅ Run tests in CI on every PR
Don't Chase 100% Coverage

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.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Playwright
playwright.devTool

Rules that often go hand-in-hand with this one.

Follow mocking best practices

Use mocks strategically to isolate units under test without over-mocking.

Testing
Include accessibility testing

Automate accessibility testing with tools like axe-core, jest-axe, or Playwright's accessibility testing.

Testing
Use mutation testing to measure how well tests detect bugs

Run Stryker mutation testing on critical business logic to verify that your test suite will actually catch real bugs, not just achieve line coverage.

Testing

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385