# Testing Patterns **Analysis Date:** 2026-02-26 ## Test Framework **Runner:** - Vitest 4.0.18 - Config: `vitest.config.ts` - Environment: `node` (no jsdom — tests are server-side only) - Globals: `true` — `describe`, `it`, `expect` available without imports (but explicit imports are used in practice) - `fileParallelism: false` — test files run sequentially - `pool: 'forks'` — each test file in isolated subprocess **Assertion Library:** - Vitest built-in (`expect`) **Path Aliases:** - `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`) **Run Commands:** ```bash npx vitest # Watch mode (all tests) npx vitest run # Run all tests once npx vitest run tests/unit/assignment-policy.test.ts # Single file npx vitest run -t 'test name' # Single test by name/pattern ``` **Timeout:** - Default `testTimeout: 30000` (30 seconds) — allows for database operations ## Test File Organization **Location:** - All tests live under `tests/` (not co-located with source files) - `tests/unit/` — pure-logic tests, no database - `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories) - Setup: `tests/setup.ts` - Factories: `tests/helpers.ts` **Naming:** - `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts` - No `.spec.ts` files — exclusively `.test.ts` **Structure:** ``` tests/ ├── setup.ts # Global test context, prisma client, createTestContext() ├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.) ├── unit/ │ └── assignment-policy.test.ts # Pure logic, no DB └── integration/ └── assignment-policy.test.ts # DB-backed tests ``` ## Test Structure **Suite Organization:** ```typescript import { describe, it, expect } from 'vitest' import type { CapMode } from '@prisma/client' import { resolveEffectiveCap } from '@/server/services/assignment-policy' // ============================================================================ // Section Title with box dividers // ============================================================================ describe('functionName', () => { it('returns expected value when condition', () => { const result = functionName(input) expect(result.value).toBe(expected) expect(result.source).toBe('system') }) describe('nested scenario group', () => { it('specific behavior', () => { ... }) }) }) ``` **Helper/Stub Pattern:** ```typescript // Builder functions at top of file construct minimal test objects function baseMemberContext(overrides: Partial = {}): MemberContext { return { competition: {} as any, round: {} as any, member: { id: 'member-1', role: 'MEMBER', ... } as any, currentAssignmentCount: 0, ...overrides, } } function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext { return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any } } ``` **Patterns:** - Build minimal context objects inline — no heavy mocking frameworks - Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }` - Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')` - Tests are descriptive: `'admin per-member override takes precedence over group default'` ## Mocking **Framework:** None — unit tests avoid mocking entirely by testing pure functions. **Approach:** - Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies - No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files - Prisma is a real client connected to a test database (see integration tests) - tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked **What to Mock:** - External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used - Anything not relevant to the assertion being made (`{} as any` for unused context fields) **What NOT to Mock:** - Business logic functions under test - Prisma in integration tests — use real database with `DATABASE_URL_TEST` - The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks ## Fixtures and Factories **Test Data (from `tests/helpers.ts`):** ```typescript // uid() creates unique prefixed IDs to avoid collisions export function uid(prefix = 'test'): string { return `${prefix}-${randomUUID().slice(0, 12)}` } // Factories accept overrides for specific test scenarios export async function createTestUser( role: UserRole = 'JURY_MEMBER', overrides: Partial<{ email: string; name: string; ... }> = {} ) { const id = uid('user') return prisma.user.create({ data: { id, email: overrides.email ?? `${id}@test.local`, role, ... } }) } ``` **Available Factories:** - `createTestUser(role, overrides)` — creates User in database - `createTestProgram(overrides)` — creates Program - `createTestCompetition(programId, overrides)` — creates Competition - `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE) - `createTestProject(programId, overrides)` — creates Project - `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState - `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment - `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation - `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm - `createTestFilteringRule(roundId, overrides)` — creates FilteringRule - `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest - `createTestCohort(roundId, overrides)` — creates Cohort - `createTestCohortProject(cohortId, projectId)` — creates CohortProject **Location:** - Factories in `tests/helpers.ts` - Shared Prisma client in `tests/setup.ts` ## Coverage **Requirements:** None enforced — no coverage thresholds configured. **View Coverage:** ```bash npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed) ``` ## Test Types **Unit Tests (`tests/unit/`):** - Scope: Pure business logic functions with no I/O - Approach: Construct in-memory objects, call function, assert return value - Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy` - No database, no HTTP, no file system **Integration Tests (`tests/integration/`):** - Scope: tRPC router procedures via `createCaller` - Approach: Create real database records → call procedure → assert DB state or return value → cleanup - Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`) - Sequential execution (`fileParallelism: false`) to avoid DB conflicts **E2E Tests:** - Playwright configured (`@playwright/test` installed, `npm run test:e2e` script) - No test files found yet — framework is available but not implemented ## Common Patterns **Integration Test Pattern (calling tRPC procedures):** ```typescript import { describe, it, expect, afterAll } from 'vitest' import { prisma } from '../setup' import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers' import { roundRouter } from '@/server/routers/round' describe('round procedures', () => { let programId: string let adminUser: Awaited> beforeAll(async () => { adminUser = await createTestUser('SUPER_ADMIN') const program = await createTestProgram() programId = program.id }) it('activates a round', async () => { const competition = await createTestCompetition(programId) const caller = createCaller(roundRouter, adminUser) const result = await caller.activate({ roundId: round.id }) expect(result.status).toBe('ROUND_ACTIVE') }) afterAll(async () => { await cleanupTestData(programId, [adminUser.id]) }) }) ``` **Unit Test Pattern (pure logic):** ```typescript import { describe, it, expect } from 'vitest' import { resolveEffectiveCap } from '@/server/services/assignment-policy' describe('resolveEffectiveCap', () => { it('returns system default when no jury group', () => { const ctx = baseMemberContext() // local builder function const result = resolveEffectiveCap(ctx) expect(result.value).toBe(SYSTEM_DEFAULT_CAP) expect(result.source).toBe('system') }) }) ``` **Async Testing:** ```typescript it('creates evaluation', async () => { const result = await caller.evaluation.start({ assignmentId: assignment.id }) expect(result.status).toBe('DRAFT') }) ``` **Error Testing:** ```typescript it('throws FORBIDDEN when accessing others evaluation', async () => { const otherUser = await createTestUser('JURY_MEMBER') const caller = createCaller(evaluationRouter, otherUser) await expect( caller.get({ assignmentId: assignment.id }) ).rejects.toThrow('FORBIDDEN') }) ``` **Cleanup (afterAll):** ```typescript afterAll(async () => { // Pass programId to cascade-delete competition data, plus explicit user IDs await cleanupTestData(programId, [adminUser.id, jurorUser.id]) }) ``` ## Test Infrastructure Details **`createTestContext(user)`** in `tests/setup.ts`: - Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }` - `prisma` is the shared test client - Used internally by `createCaller` **`createCaller(routerModule, user)`** in `tests/setup.ts`: - Shorthand: `const caller = createCaller(evaluationRouter, adminUser)` - Returns type-safe caller — procedures called as `await caller.procedureName(input)` - Import the router module directly, not `appRouter` **Database Isolation:** - Tests share one database — isolation is by unique IDs (via `uid()`) - `cleanupTestData(programId)` does ordered deletion respecting FK constraints - Always call `cleanupTestData` in `afterAll`, never skip --- *Testing analysis: 2026-02-26*