Files
gh-buzzdan-ai-coding-rules-…/skills/testing/reference.md
2025-11-29 18:02:45 +08:00

25 KiB

Testing Reference (Jest + React Testing Library)

Core Philosophy

"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds

Test user behavior, not implementation details

Implementation detail: Internal state, methods, component lifecycle User behavior: What users see, click, type, and read

Query Priority

Always use queries in this priority order:

1. Accessible queries (Preferred)

getByRole - Most preferred, tests accessibility

// Buttons
screen.getByRole('button', { name: /submit/i })
screen.getByRole('button', { name: /cancel/i })

// Links
screen.getByRole('link', { name: /home/i })

// Inputs
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('textbox', { name: /password/i })

// Checkboxes
screen.getByRole('checkbox', { name: /remember me/i })

// Headings
screen.getByRole('heading', { name: /welcome/i, level: 1 })

// Lists
screen.getByRole('list')
screen.getAllByRole('listitem')

// Dialogs/Modals
screen.getByRole('dialog')
screen.getByRole('alertdialog')

getByLabelText - Great for forms

screen.getByLabelText(/email address/i)
screen.getByLabelText(/password/i)

// With exact match
screen.getByLabelText('Email Address', { exact: false })

2. Semantic queries

getByPlaceholderText

screen.getByPlaceholderText(/enter your email/i)

getByText

screen.getByText(/welcome back/i)
screen.getByText(/loading/i)

// Partial match
screen.getByText(/welcome/i) // Matches "Welcome back!"

getByDisplayValue - For form fields with values

screen.getByDisplayValue(/john@example.com/i)

3. Test IDs (Last resort)

Only use when accessibility queries don't work:

screen.getByTestId('custom-complex-component')

Query Variants

get* - Element must exist

// Throws if not found
const button = screen.getByRole('button')

query* - Element may not exist

// Returns null if not found
const button = screen.queryByRole('button')
expect(button).not.toBeInTheDocument()

find* - Element appears asynchronously

// Returns promise, waits up to 1000ms by default
const button = await screen.findByRole('button')

getAll*, queryAll*, findAll* - Multiple elements

const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(5)

User Interactions (user-event)

Always use @testing-library/user-event over fireEvent:

import userEvent from '@testing-library/user-event'

// Setup user (required in v14+)
const user = userEvent.setup()

// Click
await user.click(element)
await user.dblClick(element)
await user.tripleClick(element)

// Type
await user.type(input, 'Hello World')
await user.clear(input)

// Keyboard
await user.keyboard('{Enter}')
await user.keyboard('{Escape}')
await user.tab()

// Hover
await user.hover(element)
await user.unhover(element)

// Select
await user.selectOptions(select, 'option1')

// Upload file
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
await user.upload(input, file)

// Copy/paste
await user.copy()
await user.paste('clipboard content')

Async Testing

waitFor - Wait for condition

import { waitFor } from '@testing-library/react'

// Wait for element to appear
await waitFor(() => {
  expect(screen.getByText(/success/i)).toBeInTheDocument()
})

// Wait for element to disappear
await waitFor(() => {
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})

// Custom timeout (default: 1000ms)
await waitFor(
  () => {
    expect(screen.getByText(/loaded/i)).toBeInTheDocument()
  },
  { timeout: 3000 }
)

// Custom interval (default: 50ms)
await waitFor(
  () => {
    expect(screen.getByText(/loaded/i)).toBeInTheDocument()
  },
  { interval: 100 }
)

findBy - Shorthand for waitFor + getBy

// Instead of:
await waitFor(() => {
  expect(screen.getByText(/loaded/i)).toBeInTheDocument()
})

// Use:
await screen.findByText(/loaded/i)

// They're equivalent, but findBy is more concise

DON'T use arbitrary timeouts

// ❌ Bad: Arbitrary wait
await new Promise(resolve => setTimeout(resolve, 1000))

// ✅ Good: Wait for specific condition
await waitFor(() => {
  expect(screen.getByText(/loaded/i)).toBeInTheDocument()
})

MSW (Mock Service Worker)

Setup

// src/test/mocks/handlers.ts
import { rest } from 'msw'

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: '1', name: 'Alice', email: 'alice@example.com' },
        { id: '2', name: 'Bob', email: 'bob@example.com' }
      ])
    )
  }),

  rest.post('/api/users', (req, res, ctx) => {
    const newUser = req.body
    return res(
      ctx.status(201),
      ctx.json({ id: '3', ...newUser })
    )
  }),

  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params
    return res(
      ctx.status(200),
      ctx.json({ id, name: 'Alice', email: 'alice@example.com' })
    )
  })
]

// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// src/test/setup.ts (imported in Jest config)
import '@testing-library/jest-dom'
import { server } from './mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

Override handlers in tests

import { rest } from 'msw'
import { server } from '@/test/mocks/server'

test('handles server error', async () => {
  // Override for this test only
  server.use(
    rest.get('/api/users', (req, res, ctx) => {
      return res(
        ctx.status(500),
        ctx.json({ message: 'Internal server error' })
      )
    })
  )

  render(<UserList />)

  expect(await screen.findByText(/error loading users/i)).toBeInTheDocument()
})

Testing Patterns

Pattern: Form Submission

test('submits form with valid data', async () => {
  const user = userEvent.setup()
  const onSubmit = jest.fn()

  render(<ContactForm onSubmit={onSubmit} />)

  // Fill form
  await user.type(screen.getByLabelText(/name/i), 'John Doe')
  await user.type(screen.getByLabelText(/email/i), 'john@example.com')
  await user.type(screen.getByLabelText(/message/i), 'Hello world')

  // Submit
  await user.click(screen.getByRole('button', { name: /submit/i }))

  // Assert
  expect(onSubmit).toHaveBeenCalledWith({
    name: 'John Doe',
    email: 'john@example.com',
    message: 'Hello world'
  })
})

test('shows validation errors for invalid email', async () => {
  const user = userEvent.setup()

  render(<ContactForm onSubmit={jest.fn()} />)

  await user.type(screen.getByLabelText(/email/i), 'invalid-email')
  await user.click(screen.getByRole('button', { name: /submit/i }))

  expect(screen.getByText(/invalid email address/i)).toBeInTheDocument()
})

Pattern: Data Fetching

test('loads and displays user data', async () => {
  render(<UserProfile userId='123' />)

  // Loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument()

  // Wait for data to load
  expect(await screen.findByText(/alice/i)).toBeInTheDocument()

  // Loading indicator gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})

test('handles loading error', async () => {
  server.use(
    rest.get('/api/users/:id', (req, res, ctx) => {
      return res(ctx.status(404), ctx.json({ message: 'User not found' }))
    })
  )

  render(<UserProfile userId='999' />)

  expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
})

Pattern: Component with Context

// Test helper
function renderWithProviders(
  ui: React.ReactElement,
  {
    initialAuth = null,
    theme = 'light'
  } = {}
) {
  return render(
    <AuthProvider initialUser={initialAuth}>
      <ThemeProvider initialTheme={theme}>
        {ui}
      </ThemeProvider>
    </AuthProvider>
  )
}

test('shows user menu when authenticated', () => {
  const user = { id: '1', name: 'Alice', email: 'alice@example.com' }

  renderWithProviders(<Navigation />, { initialAuth: user })

  expect(screen.getByText(/alice/i)).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument()
})

test('shows login button when not authenticated', () => {
  renderWithProviders(<Navigation />)

  expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
  expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument()
})

Pattern: Custom Hooks

import { renderHook, waitFor } from '@testing-library/react'

test('useDebounce delays value update', async () => {
  jest.useFakeTimers()

  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'initial', delay: 500 } }
  )

  expect(result.current).toBe('initial')

  // Update value
  rerender({ value: 'updated', delay: 500 })

  // Still old value
  expect(result.current).toBe('initial')

  // Fast-forward time
  jest.advanceTimersByTime(500)

  // Now updated
  await waitFor(() => {
    expect(result.current).toBe('updated')
  })

  jest.useRealTimers()
})

Pattern: Testing Hooks with Dependencies

function renderHookWithProviders<T>(
  hook: () => T,
  {
    initialAuth = null
  } = {}
) {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <AuthProvider initialUser={initialAuth}>
      {children}
    </AuthProvider>
  )

  return renderHook(hook, { wrapper })
}

test('useAuth returns current user', () => {
  const user = { id: '1', name: 'Alice' }

  const { result } = renderHookWithProviders(
    () => useAuth(),
    { initialAuth: user }
  )

  expect(result.current.user).toEqual(user)
  expect(result.current.isAuthenticated).toBe(true)
})

Pattern: Modal/Dialog

test('opens and closes modal', async () => {
  const user = userEvent.setup()

  render(<ModalExample />)

  // Modal not visible initially
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

  // Open modal
  await user.click(screen.getByRole('button', { name: /open modal/i }))

  // Modal visible
  const modal = screen.getByRole('dialog')
  expect(modal).toBeInTheDocument()
  expect(within(modal).getByText(/modal content/i)).toBeInTheDocument()

  // Close modal (via close button)
  await user.click(within(modal).getByRole('button', { name: /close/i }))

  // Modal hidden
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

test('closes modal on escape key', async () => {
  const user = userEvent.setup()

  render(<ModalExample />)

  await user.click(screen.getByRole('button', { name: /open modal/i }))
  expect(screen.getByRole('dialog')).toBeInTheDocument()

  // Press escape
  await user.keyboard('{Escape}')

  expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

Pattern: Lists and Filtering

test('filters users by search term', async () => {
  const user = userEvent.setup()

  render(<UserList />)

  // Wait for users to load
  await waitFor(() => {
    expect(screen.getAllByRole('listitem')).toHaveLength(10)
  })

  // Type in search
  await user.type(screen.getByRole('textbox', { name: /search/i }), 'alice')

  // Wait for filtered results
  await waitFor(() => {
    const items = screen.getAllByRole('listitem')
    expect(items).toHaveLength(1)
    expect(within(items[0]).getByText(/alice/i)).toBeInTheDocument()
  })
})

Pattern: Navigation (React Router)

import { MemoryRouter, Route, Routes } from 'react-router-dom'

function renderWithRouter(
  ui: React.ReactElement,
  { initialEntries = ['/'] } = {}
) {
  return render(
    <MemoryRouter initialEntries={initialEntries}>
      <Routes>
        <Route path='/' element={<HomePage />} />
        <Route path='/users/:id' element={ui} />
        <Route path='/login' element={<LoginPage />} />
      </Routes>
    </MemoryRouter>
  )
}

test('navigates to user profile on click', async () => {
  const user = userEvent.setup()

  renderWithRouter(<UserList />)

  // Click user link
  await user.click(screen.getByRole('link', { name: /alice/i }))

  // Assert navigation occurred (check URL-dependent content)
  expect(await screen.findByRole('heading', { name: /alice's profile/i })).toBeInTheDocument()
})

Pattern: Error Boundaries

test('error boundary catches component errors', () => {
  // Suppress console.error for this test
  const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})

  const ThrowError = () => {
    throw new Error('Test error')
  }

  render(
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <ThrowError />
    </ErrorBoundary>
  )

  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()

  consoleSpy.mockRestore()
})

Coverage Targets

The architecture principle: Push logic into leaf types for maximum testability. Strive to have most of your business logic in small, focused, dependency-free leaf components/hooks/functions.

Leaf Components/Hooks/Functions

Definition: Pure, self-contained units with no external dependencies (no API calls, database access, file system). They contain core business logic and can freely compose other leaf types.

Key Characteristics:

  • Can depend on other leaf types - Domain types composing domain types (e.g., Order uses Money, ShippingAddress)
  • Cannot depend on external systems - No API calls, database access, file system operations
  • Deterministic - Same input always gives same output
  • No side effects - Pure functions or immutable objects
  • Testable without mocks - Just instantiate and test

Examples:

  • Branded types: Email, UserId, Price with validation
  • Domain models: Order, Money, ShippingAddress (can use other leaf types)
  • Validation functions: validateEmail(), parseDate()
  • Custom hooks (pure): useValidation(), useDebounce(), useLocalStorage()
  • Utility functions: formatCurrency(), parseAddress()
  • Presentational components: Button, Card, Input (UI-only, no business logic)

Coverage Target: 100% unit test coverage

Why:

  • Leaf types contain core business logic and must be bulletproof
  • No external dependencies means easy to test in isolation (no mocks needed)
  • High confidence in these building blocks enables safe composition
  • Most bugs happen in complex logic - leaf types isolate that complexity

How to test:

  • Test only the public API (exports)
  • Use real implementations, not mocks
  • Cover happy path, edge cases, and error cases
  • For hooks: Test with @testing-library/react-hooks or render in test component
  • For components: Use React Testing Library with user-centric tests

Example - Leaf Hook:

// useDebounce.ts - Pure hook with no dependencies
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay)
    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// useDebounce.test.ts - 100% coverage
describe('useDebounce', () => {
  it('returns initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('initial', 500))
    expect(result.current).toBe('initial')
  })

  it('updates value after delay', async () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    )

    rerender({ value: 'updated', delay: 500 })
    expect(result.current).toBe('initial') // Still old value

    await waitFor(() => {
      expect(result.current).toBe('updated') // Now updated
    }, { timeout: 600 })
  })

  it('cancels pending update on unmount', () => {
    const { result, unmount } = renderHook(() => useDebounce('value', 500))
    unmount()
    // No error should be thrown
  })
})

Example - Leaf Types Composing Other Leaf Types:

// All leaf types - can freely compose each other
class Money {
  constructor(private readonly cents: number) {}

  add(other: Money): Money {
    return new Money(this.cents + other.cents)
  }

  multiply(factor: number): Money {
    return new Money(Math.round(this.cents * factor))
  }
}

class ShippingAddress {
  constructor(
    readonly street: string,
    readonly city: string
  ) {}

  isRemoteLocation(): boolean {
    return this.city === 'Remote'
  }
}

class Order {
  constructor(
    private items: CartItem[],
    private shipping: ShippingAddress  // ✅ Leaf using another leaf
  ) {}

  calculateTotal(): Money {  // ✅ Returns a leaf type
    const subtotal = this.items.reduce(
      (sum, item) => sum.add(Money.dollars(item.price)),
      Money.dollars(0)
    )

    const shippingCost = this.shipping.isRemoteLocation()
      ? Money.dollars(15)
      : Money.dollars(5)

    return subtotal.add(shippingCost)
  }
}

// Testing is trivial - no mocks needed!
describe('Order', () => {
  it('calculates total with shipping', () => {
    const order = new Order(
      [{ price: 100 }],
      new ShippingAddress('123 Main', 'Springfield')
    )
    expect(order.calculateTotal()).toEqual(Money.dollars(105))
  })

  it('adds remote shipping for remote locations', () => {
    const order = new Order(
      [{ price: 100 }],
      new ShippingAddress('456 Main', 'Remote')
    )
    expect(order.calculateTotal()).toEqual(Money.dollars(115))
  })
})

Example - NOT Leaf Types (External Dependencies):

// NOT a leaf - depends on external API
class OrderService {
  constructor(private api: ApiClient) {}  // ❌ External dependency

  async fetchOrder(id: string): Promise<Order> {
    return this.api.get(`/orders/${id}`)  // ❌ API call
  }

  async saveOrder(order: Order): Promise<void> {
    await this.api.post('/orders', order)  // ❌ API call
  }
}

// NOT a leaf - depends on database
class OrderRepository {
  constructor(private db: Database) {}  // ❌ External dependency

  async findById(id: string): Promise<Order> {
    return this.db.query('SELECT * FROM orders WHERE id = ?', [id])  // ❌ DB access
  }
}

// NOT a leaf - custom hook that fetches data
function useOrder(orderId: string) {
  const [order, setOrder] = useState<Order | null>(null)

  useEffect(() => {
    fetch(`/api/orders/${orderId}`)  // ❌ API call
      .then(res => res.json())
      .then(setOrder)
  }, [orderId])

  return order
}

// Testing these requires mocks or test servers (integration tests)

Key Insight: Complex domain models like Order, Money, ShippingAddress are ALL leaf types because they're pure logic. They can compose each other freely. The boundary is external systems, not other application types.

Orchestrating Components/Functions

Definition: Coordinate multiple leaf types, hooks, or services. They compose smaller pieces but contain minimal logic themselves.

Examples:

  • Feature components: UserProfile, LoginForm, CheckoutFlow
  • Page components: HomePage, DashboardPage
  • Context providers: AuthProvider, ThemeProvider
  • Container components: Connect data (hooks/APIs) to presentation
  • Coordinator functions: processCheckout() that calls multiple services

Coverage Target: Integration test coverage

Why:

  • Test seams and interactions between leaf types
  • Verify correct composition and data flow
  • Can have some overlap with leaf type coverage
  • Focus on behavior from user perspective

How to test:

  • Test entire feature flows
  • Use MSW (Mock Service Worker) for API calls
  • Test user interactions with userEvent
  • Verify multiple components working together
  • Test loading/error/success states

Example - Orchestrating Component:

// LoginForm.tsx - Orchestrates multiple pieces
export function LoginForm() {
  const { login } = useAuth()  // Leaf hook
  const { validateEmail } = useValidation()  // Leaf hook
  const navigate = useNavigate()

  const handleSubmit = async (email: string, password: string) => {
    if (!validateEmail(email)) return
    await login(email, password)
    navigate('/dashboard')
  }

  return <LoginFormView onSubmit={handleSubmit} />
}

// LoginForm.test.tsx - Integration test (not 100% coverage required)
test('successful login navigates to dashboard', async () => {
  const user = userEvent.setup()

  // Mock API
  server.use(
    http.post('/api/login', () => {
      return HttpResponse.json({ token: 'abc123' })
    })
  )

  render(<LoginForm />)

  await user.type(screen.getByLabelText(/email/i), 'user@example.com')
  await user.type(screen.getByLabelText(/password/i), 'password123')
  await user.click(screen.getByRole('button', { name: /sign in/i }))

  // Verify navigation happened
  await waitFor(() => {
    expect(window.location.pathname).toBe('/dashboard')
  })
})

Architectural Benefits

When you follow this pattern:

  1. Easier testing: Leaf types are trivial to test (100% coverage achievable)
  2. Better composition: Small, focused pieces are easier to combine
  3. Easier refactoring: Changes isolated to leaf types
  4. Lower cognitive load: Each piece has single responsibility
  5. Reusability: Leaf types naturally reusable across features

Anti-pattern: Putting complex business logic directly in orchestrating components makes testing hard and coupling high. Always extract to leaf types.

Coverage Strategy Summary

Type Coverage Target Test Approach Example
Leaf 100% unit tests Isolated, no mocks useDebounce(), Email type, Button
Orchestrating Integration tests User flows, MSW LoginForm, UserProfile, AuthProvider

Jest Matchers (jest-dom)

Common assertions from @testing-library/jest-dom:

// Presence
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()

// Visibility
expect(element).toBeVisible()
expect(element).not.toBeVisible()

// Enabled/Disabled
expect(button).toBeEnabled()
expect(button).toBeDisabled()

// Form values
expect(input).toHaveValue('text')
expect(checkbox).toBeChecked()
expect(checkbox).not.toBeChecked()

// Text content
expect(element).toHaveTextContent('Hello')
expect(element).toHaveTextContent(/hello/i)

// Attributes
expect(element).toHaveAttribute('href', '/home')
expect(element).toHaveClass('active')
expect(element).toHaveStyle({ color: 'red' })

// Focus
expect(input).toHaveFocus()

// Accessibility
expect(button).toHaveAccessibleName('Submit')
expect(button).toHaveAccessibleDescription('Submit the form')

Coverage Configuration

jest.config.js:

module.exports = {
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
    '!src/test/**',
    '!src/index.tsx'
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    },
    // Pure components/hooks: 100%
    './src/shared/components/**/*.{ts,tsx}': {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100
    },
    './src/shared/hooks/**/*.{ts,tsx}': {
      branches: 100,
      functions: 100,
      lines: 100,
      statements: 100
    }
  }
}

Best Practices Summary

DO

  • Test user behavior, not implementation
  • Use accessible queries (getByRole, getByLabelText)
  • Use user-event for interactions
  • Use MSW for API mocking
  • Wait for conditions with waitFor/findBy
  • Colocate tests with components
  • Write descriptive test names
  • Test error scenarios
  • Test loading states
  • Test accessibility

DON'T

  • Test implementation details (state, lifecycle)
  • Use shallow rendering
  • Use arbitrary timeouts (setTimeout)
  • Test private methods
  • Mock everything (prefer real implementations)
  • Use getByTestId unless necessary
  • Rely on snapshots for critical logic
  • Write tests that depend on each other
  • Ignore console errors/warnings

Common Pitfalls

1. Testing implementation details

// ❌ Bad: Testing internal state
expect(component.state.isOpen).toBe(true)

// ✅ Good: Testing user-visible behavior
expect(screen.getByRole('dialog')).toBeInTheDocument()

2. Not cleaning up

// ❌ Bad: No cleanup between tests
afterEach(() => {
  // Test state leaks to next test
})

// ✅ Good: Proper cleanup
afterEach(() => {
  server.resetHandlers()
  jest.clearAllMocks()
})

3. Arbitrary waits

// ❌ Bad: Arbitrary timeout
await new Promise(r => setTimeout(r, 1000))

// ✅ Good: Wait for specific condition
await waitFor(() => expect(screen.getByText(/loaded/i)).toBeInTheDocument())

4. Overusing mocks

// ❌ Bad: Mock everything
jest.mock('./useAuth', () => ({ useAuth: () => mockAuth }))
jest.mock('./api', () => ({ fetchUser: mockFetch }))

// ✅ Good: Use real implementations with MSW
// useAuth uses real context
// API calls intercepted by MSW

Testing Checklist

Before committing tests:

  • Tests use accessible queries (getByRole, getByLabelText)
  • User interactions use user-event, not fireEvent
  • Async operations use waitFor/findBy, not setTimeout
  • API calls mocked with MSW, not jest.mock
  • Tests are independent (no shared state)
  • Error scenarios covered
  • Loading states covered
  • Tests read like user stories
  • No implementation details tested
  • Coverage meets targets