Testing Guide
Test slices in isolation, routes with integration tests, and user flows end-to-end.
| Tool | Purpose |
|---|---|
| Vitest | Unit & integration tests |
| Playwright | E2E browser tests |
| MSW | API mocking for frontend tests |
| React Doctor | React best-practice, security, accessibility, performance, and architecture checks |
Install Dependencies
Section titled “Install Dependencies”pnpm add -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/dom jsdompnpm add -D playwright @playwright/testpnpm add -D mswpnpm add -D react-doctorVitest Configuration
Section titled “Vitest Configuration”import { defineConfig } from 'vitest/config'import react from '@vitejs/plugin-react'import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: 'jsdom', globals: true, setupFiles: ['./test/setup.ts'], include: ['**/*.test.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: ['node_modules/', 'test/', '**/*.d.ts'], }, },})Test Setup File
Section titled “Test Setup File”import '@testing-library/jest-dom/vitest'import { afterAll, afterEach, beforeAll } from 'vitest'import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))afterEach(() => server.resetHandlers())afterAll(() => server.close())Package.json Scripts
Section titled “Package.json Scripts”{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "doctor:react": "react-doctor .", "doctor:react:diff": "react-doctor . --diff main", "doctor:react:staged": "react-doctor . --staged" }}React Doctor Quality Gate
Section titled “React Doctor Quality Gate”Use React Doctor before pushing UI work and as a required PR check before merging to main. It scans React code for correctness, security, accessibility, performance, bundle-size, and architecture issues, then returns a 0-100 health score with actionable findings. See React Best Practices for the lightweight rules agents should follow while coding.
React Doctor compatibility with the TypeScript 7/Corsa programmatic API is unverified. Keep typescript installed side-by-side with tsgo so React Doctor and other API-consuming tools can run against the stable Strada API if needed.
Local workflow
Section titled “Local workflow”# Full scanpnpm doctor:react
# Feature branch scan against mainpnpm doctor:react:diff
# Pre-commit/pre-push scan for staged React changespnpm doctor:react:stagedRecommended threshold:
| Score | Meaning | Action |
|---|---|---|
75+ | Healthy | OK to push/merge if tests pass |
50-74 | Needs work | Fix high-signal findings before merge |
<50 | Critical | Block merge except for explicitly approved emergencies |
CI gate for pull requests and main
Section titled “CI gate for pull requests and main”name: React Doctor
on: pull_request: push: branches: [main]
permissions: contents: read pull-requests: write
jobs: react-doctor: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - uses: millionco/react-doctor@latest with: diff: main github-token: ${{ secrets.GITHUB_TOKEN }}Make this workflow a required status check in branch protection for main. Keep fmt, lint, typecheck, tests, and React Doctor together as the minimum merge gate.
Agent workflow
Section titled “Agent workflow”When an AI agent touches React components, routes, hooks, forms, or client state:
- run
pnpm doctor:react:diffbefore handing off; - fix security/correctness/accessibility findings before polish findings;
- mention any ignored or deferred findings in the PR description.
Unit Testing: Services
Section titled “Unit Testing: Services”Services contain business logic and are the easiest to test.
Structure
Section titled “Structure”packages/api/src/routers/├── order/│ ├── router.ts│ ├── service.ts│ └── service.test.ts # Unit tests hereExample: Order Service Tests
Section titled “Example: Order Service Tests”import { describe, it, expect, vi, beforeEach } from 'vitest'import { calculateTotal, validateStock, createOrder } from './service'
describe('orderService', () => { describe('calculateTotal', () => { it('sums item prices correctly', () => { const items = [ { productId: 'p1', quantity: 2, price: 10 }, { productId: 'p2', quantity: 1, price: 25 }, ] expect(calculateTotal(items)).toBe(45) })
it('returns 0 for empty array', () => { expect(calculateTotal([])).toBe(0) })
it('handles decimal prices', () => { const items = [{ productId: 'p1', quantity: 3, price: 9.99 }] expect(calculateTotal(items)).toBeCloseTo(29.97) }) })
describe('validateStock', () => { it('throws when quantity exceeds stock', async () => { const mockDb = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([{ stock: 5 }]), }
await expect( validateStock(mockDb as any, 'p1', 10) ).rejects.toThrow('Insufficient stock') })
it('passes when stock is sufficient', async () => { const mockDb = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([{ stock: 20 }]), }
await expect( validateStock(mockDb as any, 'p1', 10) ).resolves.not.toThrow() }) })})Testing with Database Mocks
Section titled “Testing with Database Mocks”import { vi } from 'vitest'
export function createMockDb() { return { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), insert: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), returning: vi.fn(), update: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), }}
// Usage in testsimport { createMockDb } from '@/test/mocks/db'
const db = createMockDb()db.returning.mockResolvedValue([{ id: 1, name: 'Test' }])Integration Testing: Routers
Section titled “Integration Testing: Routers”Test the full request/response cycle through ORPC routers.
Structure
Section titled “Structure”packages/api/src/routers/├── order/│ ├── router.ts│ ├── router.test.ts # Integration tests here│ └── service.tsTest Client Setup
Section titled “Test Client Setup”import { createRouterClient } from '@orpc/server'import { appRouter } from '@/api/router'
export function createTestClient(context: Partial<Context> = {}) { const defaultContext: Context = { db: createMockDb(), user: null, ...context, }
return { client: createRouterClient(appRouter, { context: defaultContext, }), context: defaultContext, }}
export function createAuthedTestClient(user = { id: 1, email: 'test@test.com' }) { return createTestClient({ user })}Example: Router Integration Tests
Section titled “Example: Router Integration Tests”import { describe, it, expect, beforeEach } from 'vitest'import { createTestClient, createAuthedTestClient } from '@/test/helpers/client'
describe('orderRouter', () => { describe('list', () => { it('requires authentication', async () => { const { client } = createTestClient()
await expect(client.order.list({})).rejects.toThrow('UNAUTHORIZED') })
it('returns user orders only', async () => { const { client, context } = createAuthedTestClient({ id: 1, email: 'test@test.com' })
context.db.returning.mockResolvedValue([ { id: 1, userId: 1, total: 100 }, { id: 2, userId: 1, total: 200 }, ])
const orders = await client.order.list({})
expect(orders).toHaveLength(2) expect(context.db.where).toHaveBeenCalledWith( expect.objectContaining({ userId: 1 }) ) }) })
describe('create', () => { it('validates input schema', async () => { const { client } = createAuthedTestClient()
await expect( client.order.create({ items: [] }) ).rejects.toThrow('VALIDATION') })
it('creates order with valid input', async () => { const { client, context } = createAuthedTestClient()
context.db.returning.mockResolvedValue([{ id: 1, total: 45 }])
const order = await client.order.create({ items: [ { productId: 'p1', quantity: 2 }, { productId: 'p2', quantity: 1 }, ], })
expect(order.id).toBe(1) expect(context.db.insert).toHaveBeenCalled() }) })
describe('delete', () => { it('prevents deleting other users orders', async () => { const { client, context } = createAuthedTestClient({ id: 1, email: 'test@test.com' })
context.db.returning.mockResolvedValue([{ id: 1, userId: 2 }]) // Different user
await expect( client.order.delete({ id: 1 }) ).rejects.toThrow('FORBIDDEN') }) })})Component Testing
Section titled “Component Testing”Test React components with Testing Library.
Example: Form Component Test
Section titled “Example: Form Component Test”import { describe, it, expect, vi } from 'vitest'import { render, screen, fireEvent, waitFor } from '@testing-library/react'import userEvent from '@testing-library/user-event'import { CustomerForm } from './customer-form'
describe('CustomerForm', () => { it('renders all fields', () => { render(<CustomerForm onSubmit={vi.fn()} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument() expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/phone/i)).toBeInTheDocument() })
it('validates required fields', async () => { const user = userEvent.setup() render(<CustomerForm onSubmit={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText(/name is required/i)).toBeInTheDocument() expect(await screen.findByText(/email is required/i)).toBeInTheDocument() })
it('validates email format', async () => { const user = userEvent.setup() render(<CustomerForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'invalid-email') await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument() })
it('calls onSubmit with form data', async () => { const user = userEvent.setup() const onSubmit = vi.fn() render(<CustomerForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/name/i), 'Acme Corp') await user.type(screen.getByLabelText(/email/i), 'contact@acme.com') await user.type(screen.getByLabelText(/phone/i), '+1234567890') await user.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ name: 'Acme Corp', email: 'contact@acme.com', phone: '+1234567890', }) }) })})Testing with React Query
Section titled “Testing with React Query”import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, })
return function Wrapper({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ) }}
// Usageimport { renderHook, waitFor } from '@testing-library/react'import { createWrapper } from '@/test/helpers/wrapper'import { useCustomers } from './use-customers'
it('fetches customers', async () => { const { result } = renderHook(() => useCustomers(), { wrapper: createWrapper(), })
await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data).toHaveLength(3)})MSW: API Mocking
Section titled “MSW: API Mocking”Mock API responses for frontend tests without hitting real servers.
Setup MSW
Section titled “Setup MSW”import { http, HttpResponse } from 'msw'
export const handlers = [ http.get('/api/orpc/customers.list', () => { return HttpResponse.json([ { id: 1, name: 'Acme Corp', email: 'acme@test.com' }, { id: 2, name: 'Globex', email: 'globex@test.com' }, ]) }),
http.post('/api/orpc/customers.create', async ({ request }) => { const body = await request.json() return HttpResponse.json({ id: 3, ...body }) }),
http.delete('/api/orpc/customers.delete', () => { return HttpResponse.json({ success: true }) }),]
// Error scenariosexport const errorHandlers = [ http.get('/api/orpc/customers.list', () => { return HttpResponse.json( { code: 'INTERNAL_SERVER_ERROR', message: 'Database connection failed' }, { status: 500 } ) }),]import { setupServer } from 'msw/node'import { handlers } from './handlers'
export const server = setupServer(...handlers)Override Handlers in Tests
Section titled “Override Handlers in Tests”import { server } from '@/test/mocks/server'import { errorHandlers } from '@/test/mocks/handlers'
it('shows error state on API failure', async () => { server.use(...errorHandlers)
render(<CustomerList />)
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument()})E2E Testing: Playwright
Section titled “E2E Testing: Playwright”Test complete user flows in real browsers.
Playwright Configuration
Section titled “Playwright Configuration”import { defineConfig, devices } from '@playwright/test'
export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, 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', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, { name: 'mobile', use: { ...devices['iPhone 13'] } }, ], webServer: { command: 'pnpm dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, },})Page Object Pattern
Section titled “Page Object Pattern”import { Page, Locator } from '@playwright/test'
export class CustomersPage { readonly page: Page readonly heading: Locator readonly searchInput: Locator readonly addButton: Locator readonly table: Locator
constructor(page: Page) { this.page = page this.heading = page.getByRole('heading', { name: /customers/i }) this.searchInput = page.getByPlaceholder(/search/i) this.addButton = page.getByRole('button', { name: /add customer/i }) this.table = page.getByRole('table') }
async goto() { await this.page.goto('/customers') }
async search(query: string) { await this.searchInput.fill(query) await this.page.waitForTimeout(300) // debounce }
async clickAddCustomer() { await this.addButton.click() }
async getRowByName(name: string) { return this.table.getByRole('row').filter({ hasText: name }) }}Example E2E Tests
Section titled “Example E2E Tests”import { test, expect } from '@playwright/test'import { CustomersPage } from './pages/customers.page'
test.describe('Customers', () => { test('displays customer list', async ({ page }) => { const customersPage = new CustomersPage(page) await customersPage.goto()
await expect(customersPage.heading).toBeVisible() await expect(customersPage.table).toBeVisible() })
test('filters customers by search', async ({ page }) => { const customersPage = new CustomersPage(page) await customersPage.goto()
await customersPage.search('Acme')
const acmeRow = await customersPage.getRowByName('Acme') await expect(acmeRow).toBeVisible() })
test('creates new customer', async ({ page }) => { const customersPage = new CustomersPage(page) await customersPage.goto() await customersPage.clickAddCustomer()
await page.getByLabel(/name/i).fill('New Corp') await page.getByLabel(/email/i).fill('new@corp.com') await page.getByRole('button', { name: /create/i }).click()
await expect(page).toHaveURL('/customers') await expect(page.getByText('New Corp')).toBeVisible() })})Auth Fixtures
Section titled “Auth Fixtures”import { test as base, Page } from '@playwright/test'
type AuthFixtures = { authedPage: Page}
export const test = base.extend<AuthFixtures>({ authedPage: async ({ page }, use) => { await page.goto('/login') await page.getByLabel(/email/i).fill('test@test.com') await page.getByLabel(/password/i).fill('password123') await page.getByRole('button', { name: /sign in/i }).click() await page.waitForURL('/dashboard') await use(page) },})
// Usageimport { test } from './fixtures/auth'
test('authenticated user can access dashboard', async ({ authedPage }) => { await authedPage.goto('/dashboard') await expect(authedPage.getByRole('heading', { name: /dashboard/i })).toBeVisible()})CI Integration
Section titled “CI Integration”GitHub Actions Workflow
Section titled “GitHub Actions Workflow”name: Test
on: push: branches: [main] pull_request: branches: [main]
jobs: unit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm test:run - run: pnpm test:coverage - uses: codecov/codecov-action@v3 with: files: ./coverage/coverage-final.json
e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm exec playwright install --with-deps - run: pnpm test:e2e - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7Test Organization Summary
Section titled “Test Organization Summary”project/├── packages/api/src/routers/│ └── {feature}/│ ├── router.ts│ ├── router.test.ts # Integration tests│ ├── service.ts│ └── service.test.ts # Unit tests├── app/components/│ ├── customer-form.tsx│ └── customer-form.test.tsx # Component tests├── e2e/│ ├── fixtures/│ │ └── auth.ts│ ├── pages/│ │ └── customers.page.ts│ └── customers.spec.ts # E2E tests├── test/│ ├── helpers/│ │ ├── client.ts│ │ └── wrapper.tsx│ ├── mocks/│ │ ├── db.ts│ │ ├── handlers.ts│ │ └── server.ts│ └── setup.ts├── vitest.config.ts└── playwright.config.tsQuick Reference
Section titled “Quick Reference”| Test Type | Tool | Location | Command |
|---|---|---|---|
| Unit (services) | Vitest | *.test.ts next to source | pnpm test |
| Integration (routers) | Vitest | *.test.ts next to source | pnpm test |
| Component | Vitest + RTL | *.test.tsx next to source | pnpm test |
| E2E | Playwright | e2e/*.spec.ts | pnpm test:e2e |