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:
true—describe,it,expectavailable without imports (but explicit imports are used in practice) fileParallelism: false— test files run sequentiallypool: 'forks'— each test file in isolated subprocess
Assertion Library:
- Vitest built-in (
expect)
Path Aliases:
@/resolves to./src/in test files (configured invitest.config.tsviaresolve.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 databasetests/integration/— database-backed tests using real Prisma client (currentlyassignment-policy.test.tsin 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.tsfiles — 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(), orvi.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 anyfor 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 databasecreateTestProgram(overrides)— creates ProgramcreateTestCompetition(programId, overrides)— creates CompetitioncreateTestRound(competitionId, overrides)— creates Round (default: EVALUATION, ROUND_ACTIVE)createTestProject(programId, overrides)— creates ProjectcreateTestProjectRoundState(projectId, roundId, overrides)— creates ProjectRoundStatecreateTestAssignment(userId, projectId, roundId, overrides)— creates AssignmentcreateTestEvaluation(assignmentId, formId, overrides)— creates EvaluationcreateTestEvaluationForm(roundId, criteria)— creates EvaluationFormcreateTestFilteringRule(roundId, overrides)— creates FilteringRulecreateTestCOI(assignmentId, userId, projectId, hasConflict)— creates ConflictOfInterestcreateTestCohort(roundId, overrides)— creates CohortcreateTestCohortProject(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.tstestsresolveEffectiveCap,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 toDATABASE_URL) - Sequential execution (
fileParallelism: false) to avoid DB conflicts
E2E Tests:
- Playwright configured (
@playwright/testinstalled,npm run test:e2escript) - 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 } prismais 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
cleanupTestDatainafterAll, never skip
Testing analysis: 2026-02-26