Skip to content

Testing Strategy

DoCurious uses Vitest for unit/integration tests, React Testing Library for component rendering, Playwright for end-to-end tests, and Storybook for visual testing. A GitHub Actions CI pipeline runs the full suite on every push and PR.

STATUS: BUILT

~100+ frontend test files covering stores, adapters, pages, UI components, hooks, and API layer. Backend has 25 test files covering all 20 API modules.

Test Suite Overview

LayerLocationCountWhat's Tested
Zustand storessrc/store/__tests__/~25State logic, actions, selectors
Adapterssrc/adapters/__tests__/~6SQL→FE type mapping, status maps, ID promotion
Pagessrc/pages/__tests__/~20Page rendering, content display
UI primitivessrc/components/ui/__tests__/~15Props, accessibility, variants
Domain componentssrc/components/*/__tests__/~15Gamification, filters, layout, challenges
Hookssrc/hooks/__tests__/~3Feature flags, location
API layersrc/api/__tests__/~2HTTP client, real API integration

Tools

ToolPurpose
Vitest 4.0.18Test runner — provides describe/it/expect globals
jsdom 28.0.0DOM simulation for rendering React components in Node.js
@testing-library/react 16.3.2render(), screen, fireEvent, waitFor
@testing-library/jest-dom 6.9.1DOM matchers: toBeInTheDocument(), toHaveTextContent()
@testing-library/user-event 14.6.1Simulates real user interactions (click, type, tab)
PlaywrightEnd-to-end browser tests (Chromium)
V8 coverageBuilt-in code coverage via Vitest
Storybook 10.2.8 + a11y addonVisual testing + accessibility audits

Running Tests

bash
# Unit/integration — watch mode (re-runs on file changes)
npm test

# Unit/integration — single run (CI mode)
npm run test:run

# With coverage report (outputs to coverage/)
npm run test:coverage

# E2E — headless Chromium
npm run test:e2e

# E2E — with Playwright interactive UI
npm run test:e2e:ui

# E2E — headed browser (visible)
npm run test:e2e:headed

# Everything — Vitest + Playwright back-to-back
npm run test:all

Vitest Configuration

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,         // No need to import describe/it/expect
    environment: 'jsdom',  // DOM simulation
    setupFiles: './src/test/setup.ts',
    css: true,             // Process CSS imports
    exclude: ['server/**', 'node_modules/**', 'e2e/**'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'text-summary', 'lcov', 'html'],
      reportsDirectory: './coverage',
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.stories.tsx',
        'src/**/*.test.{ts,tsx}',
        'src/test/**',
        'src/types/**',
        'src/api/mockDb.ts',
        'src/api/seed.ts',
        'src/components/debug/**',
        'src/i18n/**',
        'src/vite-env.d.ts',
      ],
      thresholds: {
        statements: 17,
        branches: 16.5,
        functions: 13.5,
        lines: 17.5,
      },
    },
  },
})

Coverage Thresholds

The build fails if coverage drops below these minimums:

MetricMinimum
Statements17%
Branches16.5%
Functions13.5%
Lines17.5%

These thresholds should be ratcheted up as coverage grows.

Test Setup

The setup file at src/test/setup.ts runs before every test file. It:

  1. Imports @testing-library/jest-dom for DOM matchers
  2. Mocks ResizeObserver (not available in jsdom)
  3. Mocks matchMedia (not available in jsdom)
  4. Mocks IntersectionObserver (not available in jsdom)
  5. Mocks window.scrollTo (not available in jsdom)

CI Pipeline

The GitHub Actions workflow at .github/workflows/test.yml runs on every push and PR to main:

push/PR → fe-unit ─────────→ fe-coverage (uploads HTML report artifact)
        → be-integration ──┐
          (Postgres 17)    └→ e2e (Playwright + Chromium, uploads failure report)

Jobs

JobTriggerWhat it does
fe-unitEvery push/PRRuns npm run test:run — all Vitest tests
fe-coverageAfter fe-unit passesRuns npm run test:coverage, uploads HTML report as artifact (14-day retention)
be-integrationEvery push/PRSpins up a Postgres 17 service container, runs server/ tests with test DB
e2eAfter fe-unit + be-integration passInstalls Chromium, runs Playwright, uploads failure report as artifact

Concurrency

The workflow uses concurrency with cancel-in-progress: true, so pushing again while tests are running cancels the old run to save CI minutes.

When Tests Run

EventWhat runs
Push to any branch targeting mainAll 4 jobs
Pull request to mainAll 4 jobs
Local developmentnpm test (watch mode)

How to Write a Test

Component Test

typescript
// src/components/challenge/__tests__/ChallengeCard.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ChallengeCard } from '../ChallengeCard'

const mockChallenge = {
  id: '123',
  title: 'Build a Birdhouse',
  difficulty: 'beginner' as const,
  isFree: true,
  coverImageUrl: '/img/birdhouse.jpg',
  completionCount: 42,
  trackRecordCount: 15,
}

describe('ChallengeCard', () => {
  it('renders the challenge title', () => {
    render(<ChallengeCard challenge={mockChallenge} />)
    expect(screen.getByText('Build a Birdhouse')).toBeInTheDocument()
  })

  it('shows the free badge when challenge is free', () => {
    render(<ChallengeCard challenge={mockChallenge} />)
    expect(screen.getByText('Free')).toBeInTheDocument()
  })

  it('calls onClick when card is clicked', async () => {
    const user = userEvent.setup()
    const onClick = vi.fn()
    render(<ChallengeCard challenge={mockChallenge} onClick={onClick} />)

    await user.click(screen.getByText('Build a Birdhouse'))
    expect(onClick).toHaveBeenCalledWith('123')
  })
})

Store Test

typescript
// src/store/__tests__/useChallengeStore.test.ts
import { useChallengeStore } from '../useChallengeStore'

describe('useChallengeStore', () => {
  beforeEach(() => {
    useChallengeStore.setState({
      challenges: [],
      myChallenges: [],
      isLoading: false,
    })
  })

  it('starts with empty challenges', () => {
    const state = useChallengeStore.getState()
    expect(state.challenges).toEqual([])
  })

  it('sets loading state during fetch', async () => {
    const promise = useChallengeStore.getState().fetchChallenges()
    expect(useChallengeStore.getState().isLoading).toBe(true)
    await promise
    expect(useChallengeStore.getState().isLoading).toBe(false)
  })
})

Hook Test

typescript
// src/hooks/__tests__/useFeatureFlag.test.ts
import { renderHook } from '@testing-library/react'
import { useFeatureFlag } from '../useFeatureFlag'

describe('useFeatureFlag', () => {
  it('returns disabled when no user is logged in', () => {
    const { result } = renderHook(() => useFeatureFlag('wallet_v2'))
    expect(result.current.enabled).toBe(false)
  })
})

API Test

typescript
// src/api/__tests__/challenge.api.test.ts
import { challengeApi } from '../'

describe('challengeApi', () => {
  it('returns paginated challenges', async () => {
    const response = await challengeApi.getChallenges()
    expect(response.success).toBe(true)
    expect(Array.isArray(response.data)).toBe(true)
    expect(response.pagination).toBeDefined()
  })
})

Test File Conventions

ConventionPattern
Test file locationsrc/**/__tests__/*.test.ts(x)
Test file namingComponentName.test.tsx for components, storeName.test.ts for stores
Describe blocksComponent or module name
Test namesStart with a verb: "renders...", "calls...", "shows...", "returns..."

Vitest Globals

With globals: true in the config, these are available without imports:

typescript
describe('group', () => { ... })
it('test', () => { ... })
expect(value).toBe(expected)
vi.fn()          // Create mock function
vi.spyOn()       // Spy on method
vi.mock()        // Mock module
beforeEach()     // Run before each test
afterEach()      // Run after each test
beforeAll()      // Run once before all tests
afterAll()       // Run once after all tests

Storybook

Storybook provides visual testing and component documentation with 55 story files:

bash
# Launch development server
npm run storybook     # http://localhost:6006

# Build static site
npm run build-storybook

Accessibility Testing

The @storybook/addon-a11y addon provides automatic accessibility audits for every story in the Storybook UI.

See Also

DoCurious Platform Documentation