Files
Matt 8cc86bae20 docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:14:08 +01:00

9.9 KiB

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: truedescribe, 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:

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:

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:

// Builder functions at top of file construct minimal test objects
function baseMemberContext(overrides: Partial<MemberContext> = {}): 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):

// 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:

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):

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<ReturnType<typeof createTestUser>>

  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):

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:

it('creates evaluation', async () => {
  const result = await caller.evaluation.start({ assignmentId: assignment.id })
  expect(result.status).toBe('DRAFT')
})

Error Testing:

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):

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