Testing
Testing philosophy, TDD workflow, per-layer patterns, and coverage guidelines
Testing Philosophy — The Testing Trophy
Adopt the Testing Trophy model instead of the traditional test pyramid. Integration tests form the largest layer because they catch the most bugs per line of test code.
/ E2E \ ← Small: critical user flows only
/ Integr. \ ← LARGEST: real modules working together
/ Unit \ ← Focused: pure functions, utilities
/ Static Anal.\ ← Foundation: TypeScript strict + BiomeStatic Analysis (foundation): TypeScript strict mode and Biome catch type errors, formatting issues, and code smells at zero runtime cost. Every file passes through this layer automatically.
Unit Tests: Test pure functions, utilities, and type guards in isolation. Keep these fast and deterministic with no external dependencies.
Integration Tests (largest layer): Use NestJS Test.createTestingModule() for backend tests that wire multiple providers together. Use Testing Library with real hooks and context for frontend components. This layer delivers the highest confidence-to-effort ratio.
E2E Tests: Reserve Playwright for critical user journeys — signup, login, core workflows. These are slow and brittle, so keep the count small and the scope broad.
Testing Layer Guidance
Use this table to decide which layer to write a test at. Write at the lowest layer that gives you sufficient confidence.
| Scenario | Layer | Tool |
|---|---|---|
| Pure function, validator, type guard | Unit | Vitest |
| Service with mocked dependencies | Unit | Vitest + vi.fn() |
| Multiple NestJS providers wired together | Integration | Vitest + Test.createTestingModule() |
| React component with real hooks and context | Integration | Vitest + Testing Library |
queryOptions() factory + component consuming it | Integration | Vitest + Testing Library + QueryClientProvider |
| Auth flow, onboarding, checkout end-to-end | E2E | Playwright |
Unit — isolated logic: Write unit tests for services, utilities, validators, and pure functions. Instantiate the class directly and pass vi.fn() stubs through the constructor. No module-level mocking, no NestJS DI.
Integration — module wiring: Write integration tests when multiple things must cooperate. On the backend, use Test.createTestingModule() so the real dependency injection graph is exercised. On the frontend, render real components inside the providers they need (QueryClientProvider, RouterProvider, session context) and assert against visible output.
E2E — critical user flows: Reserve Playwright for journeys that span the entire stack — signup, login, role-based access, checkout. Keep the count small. A broken E2E suite blocks everyone; a narrow E2E suite catches real regressions.
This section complements the Testing Trophy above. The Trophy describes how much of each layer to write; this table describes when to write each one.
Red-Green-Refactor Cycle
Follow this cycle for every unit of work:
- Red: Write a failing test that describes the expected behavior. Run it. Confirm it fails for the right reason.
- Green: Write the minimum code to make the test pass. No more.
- Refactor: Clean up the implementation and the test while the suite stays green. This step is not optional — take it every time.
Walkthrough: UserService.findById
// 1. RED — Write the test first
import { describe, it, expect } from 'vitest'
describe('UserService', () => {
it('should return user when found by id', async () => {
// Arrange
const service = new UserService(mockUserRepository)
await mockUserRepository.insert({ id: '1', name: 'Ada' })
// Act
const user = await service.findById('1')
// Assert
expect(user).toEqual({ id: '1', name: 'Ada' })
})
it('should throw NotFoundException when user does not exist', async () => {
const service = new UserService(mockUserRepository)
await expect(service.findById('nonexistent'))
.rejects.toThrow('User not found')
})
})
// 2. GREEN — Minimum implementation
// 3. REFACTOR — Extract shared setup, improve namingNote: "Test-after" is acceptable during prototyping and exploration. Tests must exist before the PR merges.
Test Structure (AAA Pattern)
Use the Arrange-Act-Assert pattern with explicit section comments. This example comes from apps/api/src/common/filters/allExceptions.filter.test.ts:
import { describe, it, expect } from 'vitest'
it('should handle HttpException with string response', () => {
// Arrange
const { host, statusFn, getSentBody } = createMockHost()
const exception = new HttpException('Not found', HttpStatus.NOT_FOUND)
// Act
filter.catch(exception, host as never)
// Assert
expect(statusFn).toHaveBeenCalledWith(404)
expect(getSentBody().message).toBe('Not found')
})Rules:
- Test one behavior per test case (unit). Coherent multi-step scenarios are acceptable for integration and E2E tests.
- Use descriptive names:
should X when Y. - Extract shared setup into factory/helper functions. Reserve
beforeEachfor teardown and mock resets only. - Do not test private methods directly. If a private method is complex enough to need its own tests, extract it into a separate module.
Test Organization
Naming: Use .test.ts for unit and integration tests. Use .spec.ts for E2E tests (Playwright).
Location: Colocate test files next to source files. Place shared test helpers and fixtures in a __tests__/ directory.
Configuration: Each package has its own Vitest config extending @repo/vitest-config:
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config'
import { nodeConfig } from '@repo/vitest-config/node'
export default defineConfig({
test: {
...nodeConfig,
name: 'api',
root: import.meta.dirname,
setupFiles: ['./src/test/setup.ts'],
},
})Environments: Use node for backend packages (apps/api). Use jsdom for frontend packages (apps/web, packages/ui).
Imports: Import test utilities explicitly from vitest. Do not rely on globals: true:
import { describe, it, expect, vi } from 'vitest'Coverage Guidelines
Threshold: 80% minimum coverage floor (76% for branches). Thresholds auto-ratchet upward via autoUpdate: true in the root vitest.config.ts — current minimums are higher than the initial floor.
Provider: Use V8 with text, json, and html reporters.
Per-directory targets:
- Business logic (services, domain): 90%+
- Glue code (controllers, modules): 80%
- Config files, generated code, and type declarations: exclude from coverage
Backend Testing Patterns
Filter / Interceptor / Middleware Testing
From apps/api/src/common/filters/allExceptions.filter.test.ts:
- Instantiate the class directly — do not use NestJS test modules for unit tests.
- Build factory helpers like
createMockHost()andcreateMockContext()to construct mock objects. - Test the happy path and every error path.
- Verify the response structure matches the API contract (status code, message, correlationId, path, timestamp).
Provider / Factory Testing
From apps/api/src/database/drizzle.provider.test.ts:
- Mock
ConfigServicewith acreateMockConfig()helper:
function createMockConfig(values: Record<string, string | undefined>) {
return {
get: vi.fn((key: string, defaultValue?: string) => values[key] ?? defaultValue),
}
}- Call
useFactoryfunctions directly with the mock config. - Verify null handling for optional dependencies and error throwing in production mode.
When to Use Test.createTestingModule() (Integration)
Use the NestJS testing module when:
- Multiple providers must work together through real dependency injection.
- You need to test module initialization and lifecycle hooks.
- You are verifying that DI wiring is correct across module boundaries.
Frontend Testing Patterns
Setup
From apps/web/src/test/setup.ts:
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})Import @testing-library/jest-dom/vitest for DOM matchers (toBeInTheDocument, toHaveTextContent). Call cleanup() after each test to unmount rendered components.
Component Testing
From packages/ui:
- Use
render()andscreenqueries from Testing Library. - Prefer role-based selectors in this order:
getByRole('button', { name: 'Submit' })getByLabelText('Email')getByText('Welcome')getByTestId('submit-btn')— last resort only
- Use
fireEventfor simple interactions. UseuserEventfor realistic user behavior (typing, tabbing). - Assert on visible text, ARIA roles, and attributes. Do not assert on DOM structure or CSS classes.
Utility Testing
- Test comprehensive edge cases: null, empty string, invalid input, and boundary values.
- Group tests by function under
describeblocks. - Test type guards with both valid and invalid inputs to verify the return type narrows correctly.
TanStack Query Testing
See frontend-patterns.mdx section 1.7 for the queryOptions() factory pattern. This section covers how to test those factories and the components that consume them.
QueryClientProvider wrapper
Create a shared test utility so every test that touches a query gets a fresh QueryClient with retries disabled:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import type { ReactNode } from 'react'
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
})
}
export function renderWithQuery(ui: ReactNode) {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { ...render(ui, { wrapper }), queryClient }
}Testing queryOptions() factories
queryOptions() factories are pure functions — test them directly without rendering anything:
import { describe, expect, it } from 'vitest'
import { apiKeysQueryOptions } from './apiKeys.queries'
describe('apiKeysQueryOptions', () => {
it('should produce a stable query key', () => {
const options = apiKeysQueryOptions()
expect(options.queryKey).toEqual(['api-keys'])
})
it('should set staleTime to 30 seconds', () => {
const options = apiKeysQueryOptions()
expect(options.staleTime).toBe(30_000)
})
})Testing components that use queries
Wrap the component in renderWithQuery. Seed the cache directly via queryClient.setQueryData() to avoid real network calls:
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ApiKeysList } from './ApiKeysList'
import { renderWithQuery } from '@/test/renderWithQuery'
import { createMockSession } from '@/test/__mocks__/mockSession'
describe('ApiKeysList', () => {
it('should display keys from the cache', async () => {
// Arrange
const { queryClient } = renderWithQuery(<ApiKeysList />)
queryClient.setQueryData(['api-keys'], {
data: [{ id: 'key-1', name: 'Production Key' }],
})
// Assert
expect(await screen.findByText('Production Key')).toBeInTheDocument()
})
it('should show empty state when no keys exist', async () => {
// Arrange
const { queryClient } = renderWithQuery(<ApiKeysList />)
queryClient.setQueryData(['api-keys'], { data: [] })
// Assert
expect(await screen.findByText(/no api keys/i)).toBeInTheDocument()
})
})Use createMockSession from apps/web/src/test/__mocks__/mockSession.ts when a component requires an authenticated user in context.
Testing mutations and cache invalidation
Assert that the mutation calls the correct endpoint and that invalidateQueries is triggered afterward:
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { RevokeApiKeyButton } from './RevokeApiKeyButton'
import { renderWithQuery } from '@/test/renderWithQuery'
vi.mock('@/lib/apiKeys', () => ({
revokeApiKey: vi.fn().mockResolvedValue({ id: 'key-1', revokedAt: '2026-01-01T00:00:00.000Z' }),
}))
describe('RevokeApiKeyButton', () => {
it('should invalidate the api-keys cache after a successful revoke', async () => {
// Arrange
const { queryClient } = renderWithQuery(<RevokeApiKeyButton keyId="key-1" />)
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
// Act
await userEvent.click(screen.getByRole('button', { name: /revoke/i }))
// Assert
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['api-keys'] })
})
})
})Rules
- Always disable retries (
retry: false) in the testQueryClient— otherwise failed queries silently retry and slow tests down. - Set
gcTime: 0so stale cache entries from one test do not leak into the next. - Seed the cache with
queryClient.setQueryData()rather than letting queries fire real fetch calls. - When testing loading and error states, use
queryClient.setQueryData()withundefinedto simulate loading, orqueryClient.setQueryDefaults()to force an error state. - Use
findBy*(async) queries when asserting on content that appears after a query resolves.
E2E Testing Patterns
Configuration
Shared Playwright configuration lives in @repo/playwright-config with 4 browser projects: Chromium, Firefox, WebKit, and Mobile Chrome (Pixel 5). PRs run Chromium-only for speed; staging and main pushes run the full matrix.
From playwright.config.ts:
import { defineConfig } from '@playwright/test'
import { basePlaywrightConfig } from '@repo/playwright-config/base'
// For fast local runs, use: bun run test:e2e --project=chromium
export default defineConfig({
...basePlaywrightConfig,
})Reuse the existing dev server locally. Start a fresh server in CI.
Test Structure
test.describe('Feature Name', () => {
test('should perform user action', async ({ page }) => {
await page.goto('/path')
await page.getByRole('button', { name: 'Submit' }).click()
await expect(page).toHaveTitle(/Expected/)
})
})Selector priority: getByRole → getByLabel → getByText → getByTestId (last resort).
Patterns:
- Use the Page Object Model to encapsulate page interactions.
- Use fixtures for authentication state and shared setup.
- Do not place assertions inside page objects — page objects return data, tests assert on it.
Mocking Strategies
Factory Helper Pattern (Preferred)
From apps/api/src/common/filters/allExceptions.filter.test.ts:
import { vi } from 'vitest'
function createMockHost(headers: Record<string, string> = {}) {
const sendFn = vi.fn()
const statusFn = vi.fn().mockReturnValue({ send: sendFn })
const request = {
url: '/test',
method: 'GET',
headers: { 'x-correlation-id': 'test-id', ...headers },
}
const response = { status: statusFn }
const host = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
}
const getSentBody = () => {
const call = sendFn.mock.calls[0]
expect(call).toBeDefined()
return call?.[0] as Record<string, unknown>
}
return { host, statusFn, getSentBody } as const
}Rules
- Build factory functions that accept
Partial<T>overrides for flexibility. - Keep mocks close to the tests that use them.
- Backend: Prefer dependency injection over module-level mocking. Pass
vi.fn()stubs through DI. Avoidvi.mock(). - Environment variables: Use
vi.stubEnv()as shown inapps/api/src/test/setup.ts. - Network calls (frontend): Prefer MSW over
vi.spyOn(globalThis, 'fetch')for any test that exercises a full fetch path. Reservevi.spyOn(fetch)for low-level unit tests of HTTP client utilities where you must inspect headers or the rawRequestInitobject. See MSW Adoption below.
Frontend Mocking Rules
Default: Render real components. Do not mock @repo/ui or other internal packages. Rendering real components produces integration-style tests with higher confidence.
Accepted uses of vi.mock():
- Codegen modules (
@/paraglide/messages): Auto-generated i18n modules have no injection point. Mock to return predictable strings. - TanStack Router file-based routes: Route components are loaded via file conventions. Module-level mocking is required to isolate route-level tests from child route components.
- Third-party libraries with side effects or browser APIs not available in jsdom (e.g.,
next/navigation,IntersectionObserver).
Prefer vi.fn() and vi.spyOn() for everything else. Use factory helpers to construct mock objects.
MSW Adoption
MSW is the recommended replacement for vi.spyOn(fetch). Install with bun add -d msw before using these patterns. MSW is not currently installed in this project.
Why MSW over vi.spyOn(fetch)
vi.spyOn(globalThis, 'fetch') works at the JavaScript function level. It intercepts the call before the network stack runs, which means request headers, serialization, and URL construction are never verified unless you inspect mock.calls manually.
MSW intercepts at the service-worker (browser) or node:http adapter level — after your code builds the full request. This gives you:
- Network-level interception: The real
fetchruns. URL construction, headers, andcredentialsare verified automatically. - Request validation: Assert on the incoming
Requestobject inside the handler. - Shared handlers: Define handlers once in a
src/test/msw/handlers.tsfile and reuse them across tests. Narrow overrides per test withserver.use(). - Readable error simulation: Return
HttpResponse.error()or a non-2xx status without constructing aResponseobject by hand.
When to keep vi.spyOn(fetch): Low-level unit tests of HTTP client utilities (e.g., apiClient.test.ts) that must inspect the raw RequestInit — headers, credentials, correlation IDs — should keep vi.spyOn. MSW does not expose RequestInit; it exposes a Request object.
Note: MSW is not yet installed in this project. Run
bun add -d mswbefore using the patterns below.
Setup
// src/test/msw/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)// src/test/setup.ts (extend the existing file)
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './msw/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
cleanup()
server.resetHandlers()
})
afterAll(() => server.close())Use onUnhandledRequest: 'error' so any unmatched request fails the test immediately rather than silently hanging.
Before / After comparison
Before — vi.spyOn(fetch):
it('should return the list of API keys on success', async () => {
// Arrange
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ data: [makeApiKey()] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
)
// Act
const result = await listApiKeys()
// Assert
expect(result.data).toHaveLength(1)
fetchSpy.mockRestore()
})After — MSW:
import { http, HttpResponse } from 'msw'
import { server } from '@/test/msw/server'
it('should return the list of API keys on success', async () => {
// Arrange — narrow override for this test
server.use(
http.get('/api/api-keys', () =>
HttpResponse.json({ data: [makeApiKey()] })
)
)
// Act
const result = await listApiKeys()
// Assert
expect(result.data).toHaveLength(1)
// handler is automatically reset in afterEach
})The MSW version requires no spy setup, no manual mockRestore(), and validates the URL automatically.
Handler Patterns
Define baseline handlers that match the happy path for every endpoint. Override per test when you need errors or delays.
// src/test/msw/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET — return list
http.get('/api/api-keys', () =>
HttpResponse.json({ data: [] })
),
// POST — create resource
http.post('/api/api-keys', async ({ request }) => {
const body = await request.json() as { name: string }
return HttpResponse.json(
{ id: 'key-new', name: body.name },
{ status: 201 }
)
}),
// PATCH — partial update
http.patch('/api/api-keys/:id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>
return HttpResponse.json({ id: params.id, ...body })
}),
]Error simulation:
server.use(
http.get('/api/api-keys', () =>
HttpResponse.json({ message: 'Unauthorized' }, { status: 401 })
)
)Network error (no response):
server.use(
http.get('/api/api-keys', () => HttpResponse.error())
)Network delay:
import { delay } from 'msw'
server.use(
http.get('/api/api-keys', async () => {
await delay(500) // milliseconds
return HttpResponse.json({ data: [] })
})
)Test Data Management
- Inline data: Use directly in the test for simple, self-contained cases.
- Factory functions: Build helper functions that produce complex entities with sensible defaults and optional overrides.
- Fixtures: Store static JSON data in
__tests__/fixtures/for large or shared datasets. - Database: (Planned) Use Testcontainers for integration tests that require a real PostgreSQL instance. Not yet configured in this project.
- Cleanup: Each test owns its data. Do not rely on shared mutable state between tests.
AI-Assisted TDD Workflow
- Describe the feature or function you need to build.
- Ask Claude to write the test first. Be explicit: "Write the test FIRST. Do not create implementation code."
- Review the test — does it capture the right behavior? This is the most critical step.
- Ask Claude to implement the code that makes the test pass.
- Run
bun run testand verify the result. - Refactor together — improve both test and implementation.
- Validate — a human reviews both the test and the implementation before merge.
Tips:
- Provide existing test files as context so Claude follows the project's patterns.
- Ask for edge cases after the happy path is covered.
- Watch for circular validation — where the test and implementation are derived from the same flawed logic.
Advanced Testing
These techniques apply to specific scenarios. Introduce them incrementally, not all at once.
- Property-based testing (fast-check): Use for utility functions, validators, and type guards where you want to verify invariants across random inputs.
- Mutation testing (Stryker): Apply to critical business logic to verify that tests actually catch regressions. Use incremental mode in CI to keep build times manageable.
- Contract testing (Pact): Use consumer-driven contracts when the frontend and backend are developed by different teams or deployed independently.
- Snapshot testing: Do not use for UI components — snapshots are brittle and provide low signal. Inline snapshots are acceptable for small data structures like serialized error responses.