Testing Guidelines
Use unit tests for isolated logic, integration tests for database-backed behavior, and E2E tests for user journeys. Unit tests may mock dependencies; integration tests exercise real PostgreSQL and Payload.
Test Types
| Type | Location | Framework | Speed | What to test |
|---|---|---|---|---|
| Unit | tests/unit/ | Vitest | < 100ms | Pure functions, validation, parsing, formatting |
| Integration | tests/integration/ | Vitest + PostgreSQL | seconds | API endpoints, job processing, access control |
| E2E | tests/e2e/ | Playwright | 10-30s | Complete user workflows in the browser |
Running Tests
# From project root
make test # All tests
make test-ai # AI-friendly JSON output
make test-ai FILTER=date.test # Single file (24-120x faster)
make test-ai FILTER=tests/unit # Directory
make test-e2e # Playwright E2E tests
# From apps/web
pnpm test # All tests
pnpm test:unit # Unit only
pnpm test:integration # Integration only
pnpm test:e2e # E2E testsResults are saved as timestamped JSON in apps/web/.test-results/.
Mocking Rules
Integration Tests
- Database operations — use test database
- Payload CMS — use real Payload
- Internal services — use actual implementations
- File system — use temp directories
- Job queues — use real handlers
External-service fakes are narrow, documented exceptions, not the default. For example, apps/web/tests/integration/services/all-transforms-pipeline.test.ts substitutes a geocoder to exercise transforms without provider credentials. Keep provider integration covered separately.
Unit Tests
Mock dependencies when needed to isolate the behavior under test. Prefer real pure helpers and temporary files where practical. Examples of controlled test substitutes include:
- External paid APIs (Google Maps) — costs and rate limits
- Rate-limited services — avoid CI quotas
- Network failures — test error handling
- Time —
vi.setSystemTime()for date-dependent tests
Integration Test Setup
Use createIntegrationTestEnvironment() for tests that need a real database:
import { afterAll, beforeAll, beforeEach, describe } from "vitest";
import {
createIntegrationTestEnvironment,
IMPORT_PIPELINE_COLLECTIONS_TO_RESET,
} from "@/tests/setup/integration/environment";
describe.sequential("Import workflow", () => {
let testEnv: Awaited<ReturnType<typeof createIntegrationTestEnvironment>>;
beforeAll(async () => {
testEnv = await createIntegrationTestEnvironment();
});
afterAll(async () => {
await testEnv.cleanup();
});
beforeEach(async () => {
await testEnv.seedManager.truncate(IMPORT_PIPELINE_COLLECTIONS_TO_RESET);
});
// Add tests using the real database and Payload here.
});Integration databases are isolated per worker, but multiple files share a worker process. Use sequential suites and explicit cleanup scope; never close the shared Payload connection. apps/web/vitest.config.ts declares the separately isolated project and retry policy.
Worker databases use the fork’s process ID and remain available after a run for debugging. The next integration global setup prunes leftover worker databases without open connections; it does not terminate active connections to clean them up. The template database is retained and reused when its schema is valid. Do not rely on a worker database surviving the next test run.
E2E has a different model: Playwright workers share one server and database per run. Use distinct test data and the fixtures in apps/web/tests/e2e/fixtures/; do not assume worker-level database isolation.
Unit Test Setup
Use factories and mocks for business logic:
import { vi } from "vitest";
import { createEvent } from "@/tests/setup/factories";
const mockPayload = { findByID: vi.fn(), create: vi.fn() };
it("validates event data", () => {
const event = createEvent({ data: { title: "Test" } });
expect(validateEvent(event).valid).toBe(true);
});Test Credentials
Always use centralized test credentials to avoid hardcoded secrets:
import { TEST_CREDENTIALS, TEST_EMAILS } from "../constants/test-credentials";
const testUser = await payload.create({
collection: "users",
data: { email: TEST_EMAILS.admin, password: TEST_CREDENTIALS.basic.password, role: "admin" },
});Analyzing Failures
# Failed test names
cat apps/web/.test-results/$(ls -t apps/web/.test-results/ | head -1) | jq '.testResults[] | select(.status=="failed") | .name'
# Lint errors
cat apps/web/.lint-results/$(ls -t apps/web/.lint-results/ | head -1) | jq '.[] | select(.errorCount > 0) | .filePath'
# TypeScript errors
cat apps/web/.typecheck-results/$(ls -t apps/web/.typecheck-results/ | head -1) | jq '.errors[] | {file, line, code, message}'