Testing Patterns
This document defines how to structure tests in the monorepo. An autonomous agent should follow these conventions.
Stack
| Layer | Tool | Mock DB |
|---|---|---|
Workers (workers/) | vitest | mockD1.ts (integration) / MemoryEntityRepo (unit, preferred) |
App (app/) | vitest | createMockStore / createEmptyMockStore (SQLite WASM) |
Schemas (packages/schemas/) | vitest | None (pure schema test) |
Permissions (packages/permissions/) | vitest | None (pure logic test) |
Single Command
pnpm test # Runs all monorepo testsWorkers: test structure
EntityRepository — MemoryEntityRepo (preferred for service tests)
Services migrated to the EntityRepository pattern (v0.54.0+) use MemoryEntityRepo instead of mockD1.sql:
import { MemoryEntityRepo } from "workers/src/storage/repository";
const repo = new MemoryEntityRepo<StudentRow>("student_id", columns);
repo.seed({ student_id: "s1", display_name: "Alice", status: "ACTIVE" });
const service = new StudentService(repo);
const result = await service.findById("s1");
expect(result?.display_name).toBe("Alice");Benefits: no SQL-string mocking, deterministic, fast, scope-aware via constructor injection.
// workers/src/__tests__/my-test.test.ts
import { describe, it, expect } from "vitest";
import { mockD1 } from "./mockD1";
import { getDB } from "../db/d1";
describe("My module", () => {
it("does something", async () => {
const db = mockD1();
// db.prepare(...).bind(...).all() works
// db.prepare(...).bind(...).first() works
// db.prepare(...).bind(...).run() works
});
});Conventions:
describe("module name", ...)— name of the file or tested functionit("verb in present tense: does something specific", ...)— behavior, not implementation- Handler tests: mock
getDB()before calling the handler
Handler test example
import { describe, it, expect, vi } from "vitest";
import { mockD1 } from "./mockD1";
import { getDB } from "../db/d1";
// Replace getDB with mock before each test
vi.mock("../db/d1", () => ({
getDB: vi.fn(),
}));
describe("handleListClasses", () => {
it("returns list of active classes", async () => {
const db = mockD1();
db.prepare().all.mockResolvedValue({
results: [
{
class_id: "1",
name: "Berçário",
age_min: null,
age_max: null,
status: "ACTIVE",
created_at: "2026-01-01",
updated_at: "2026-01-01",
},
],
success: true,
});
(getDB as any).mockReturnValue(db);
const response = await handleListClasses(
new Request("http://localhost"),
null as any,
null as any,
"corr-id",
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(1);
expect(body.items[0].name).toBe("Berçário");
});
});Workers — Integration tests (pool-workers)
For integration tests that need real D1 (SQLite via Miniflare), use @cloudflare/vitest-pool-workers:
// vitest.integration.config.ts
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
export default defineConfig({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.toml" } })],
test: { include: ["src/**/__integration__/**/*.test.ts"] },
});Tests live in workers/src/__integration__/ and run separately from unit tests (pnpm test:integration).
The existing mockD1.ts mock was updated in v0.22.0 to support batch() for testing atomic D1 operations.
App: test structure
Mock IndexedDB
Use fake-indexeddb (already configured in the app's vitest.config.ts):
// app/src/modules/myModule/__tests__/my-test.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { resetDatabase } from "../../../db/db";
describe("My module", () => {
beforeEach(async () => {
await resetDatabase(); // clears fake IndexedDB
});
it("does something", async () => {
// Fake IndexedDB is ready for use
// Use Dexie repositories normally
});
});Tests with seed
import { seedDatabaseIfNeeded } from "../../../db/seed";
beforeEach(async () => {
await resetDatabase();
await seedDatabaseIfNeeded(); // populates demo data
});Schemas: test structure
Pure Zod schema test — no mock:
import { describe, it, expect } from "vitest";
import { studentCreateSchema } from "../../workers/src/schemas";
describe("studentCreateSchema", () => {
it("accepts valid payload", () => {
const result = studentCreateSchema.parse({
displayName: "João",
photoRef: "photo:ref",
guardianName: "Maria",
phones: [{ number: "11999999999", qualifier: "Celular" }],
classId: "550e8400-e29b-41d4-a716-446655440000",
});
expect(result.displayName).toBe("João");
});
it("rejects empty name", () => {
expect(() => studentCreateSchema.parse({ displayName: "" })).toThrow();
});
});Test Helpers — Mocking getStore()
(app/src/storage/test-helpers.ts)
Two strategies for mocking the storage layer in tests:
createMockStore — fast unit tests
Use when testing service logic in isolation. Returns a plain mock — no WASM, no schema, instant.
import { createMockStore } from "../../storage/test-helpers";
const store = createMockStore();
vi.mock("../../storage", () => ({ getStore: () => store }));
// Seed mock data
store.query.mockResolvedValue([{ id: "1", name: "Test" }]);
// Test the service
const result = await myService.doSomething();
expect(result).toHaveLength(1);createTestStore — integration / behavior tests
Use when testing storage-layer behavior or end-to-end flows. Returns a real SQLiteStore with vfs: "memory" — real SQLite, ephemeral.
import { getStore } from "../../storage";
import { SQLiteStore } from "../../storage/sqliteStore";
const testStore = new SQLiteStore({ vfs: "memory", dbName: "my-test" });
vi.mock("../../storage", async () => ({
...(await vi.importActual("../../storage")),
getStore: () => testStore,
}));
// Real SQLite operations — schema auto-initializes
await testStore.exec("INSERT INTO students (...) VALUES (...)");
const rows = await testStore.query("SELECT * FROM students");
expect(rows).toHaveLength(1);When to use which
| Scenario | Helper |
|---|---|
| Testing service validation logic | createMockStore() |
| Testing query result handling | createMockStore() |
| Testing storage-layer behavior | SQLiteStore({ vfs: "memory" }) |
| Testing migration application | SQLiteStore({ vfs: "memory" }) |
| Testing transaction rollback | SQLiteStore({ vfs: "memory" }) |
Test ID Naming
Use the prefixes defined in the Test Execution Handbook:
| Prefix | Domain |
|---|---|
TC-RBAC-### | Role-based access control |
TC-ATT-### | Attendance |
TC-STU-### | Students |
TC-SYNC-### | Sync |
TC-CONFLICT-### | Conflict resolution |
TC-TTL-### | Session TTL |
TC-A11Y-### | Accessibility |
TC-SEC-### | Security |
TC-COMP-### | Compliance |
TC-PERF-### | Performance |
Run before commit
pnpm test # Required. Husky's pre-commit hook also runs this.2026 Notes
@cloudflare/vitest-pool-workers
Worker integration tests use @cloudflare/vitest-pool-workers, which runs each test file in an isolated workerd worker with real D1 bindings. Configure in workers/vitest.integration.config.ts:
import { defineConfig } from "vitest/config";
import { readD1Migrations } from "@cloudflare/vitest-pool-workers/config";
export default defineConfig({
test: {
include: ["src/__integration__/**/*.test.ts"],
retry: 2,
},
});TC-ID pattern in test names
Integration and unit tests can use traceability IDs in names:
it("TC-SEC-001: login with invalid credentials returns 401", async () => { ... });TC-IDs are extracted automatically by pnpm docs:rtm to generate the traceability matrix.
resetDatabase() in beforeEach
Integration tests that use D1 call applyD1Migrations() in beforeAll. To isolate data between tests, use resetDatabase() or create test-specific data with beforeEach.