290 lines
9.9 KiB
Markdown
290 lines
9.9 KiB
Markdown
# 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> = {}): 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<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):**
|
|
```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*
|