.planning/codebase/TESTING.md
Analysis Date: 2026-05-11
Runner: Vitest 4.x.
package.json: "vitest": "^4.1.5", "@vitest/coverage-v8": "^4.1.5".apps/web has its own vitest.config.ts that delegates to the shared factory at vitest.shared.ts.DOM environment: happy-dom 20.x (migrated from jsdom in commit 7a60a42a0). Default test environment per vitest.shared.ts:19 is "node"; per-package configs opt into browser-like envs.
Assertion / testing libraries (root package.json devDependencies):
@testing-library/react ^16.3.2@testing-library/dom ^10.4.1@testing-library/jest-dom ^6.9.1 — registered globally in vitest.setup.ts:1@testing-library/user-event ^14.6.1Run commands (root package.json):
pnpm test # turbo run test → vitest run --passWithNoTests in every package
pnpm test:coverage # turbo run test:coverage → adds --coverage flag
pnpm test:ci # adds GitHub Actions, JSON, and JUnit reporters
pnpm test:agent # agent-friendly reporter + JSON output for agentic runs
Per-package commands (uniform across all packages, see packages/api/package.json:13-19):
pnpm --filter @reactive-resume/utils test
pnpm --filter @reactive-resume/api test
pnpm --filter web test
Vitest test paths are package-relative when filtering: pnpm --filter @reactive-resume/utils test -- src/string.test.ts.
vitest.shared.ts exports createVitestProjectConfig({ name, dirname, environment, plugins }). Highlights:
root: dirname — each package runs in isolation.envDir: workspaceRoot — .env at repo root is loaded for every package.resolve: { tsconfigPaths: true } — TS path aliases resolve from the package's own tsconfig.json.setupFiles: [./vitest.setup.ts] — global hooks applied to all projects.include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"] — both .test.* and .spec.* are picked up.exclude: ["node_modules", "dist", ".output", "coverage", "reports"].pool: "threads", isolate: false — fast threaded execution with shared module state inside a worker.passWithNoTests: true — packages without tests don't fail CI.environmentOptions.happyDOM disables JS/CSS file loading and navigation for safety.Coverage is configured directly in vitest.shared.ts:49-56:
v8./coverage per packagetext, text-summary, json-summary, json, lcov, htmlsrc/**/*.{ts,tsx}src/**/*.{test,spec}.*, src/**/*.d.ts, src/routeTree.gen.tsreportOnFailure: trueNo global coverage thresholds are enforced (no thresholds: {...} block). Per-package coverage HTML lives under each package's coverage/ directory after pnpm test:coverage.
vitest.setup.ts)Applied to every project:
import "@testing-library/jest-dom/vitest" — registers toBeInTheDocument, toHaveAttribute, etc.afterEach(() => cleanup()) — explicit RTL cleanup (Vitest doesn't expose afterEach globally without test.globals: true).ResizeObserver, IntersectionObserver, Element.prototype.scrollIntoView, window.matchMedia (used by cmdk, Base UI, next-themes).All vitest.config.ts files reuse the shared factory. Notable variants:
packages/utils/vitest.config.ts, packages/api/vitest.config.ts, packages/db/vitest.config.ts, packages/schema/vitest.config.ts, packages/ai/vitest.config.ts, packages/email/vitest.config.ts, packages/fonts/vitest.config.ts, packages/env/vitest.config.ts, packages/auth/vitest.config.ts, packages/import/vitest.config.ts, packages/pdf/vitest.config.ts, packages/config/vitest.config.ts) — environment: "node".packages/ui/vitest.config.ts:7) — environment: "happy-dom" for component tests.apps/web/vitest.config.ts) — environment: "node" plus Vite plugins to mirror dev: @tailwindcss/vite, @lingui/vite-plugin (with linguiTransformerBabelPreset), and @rolldown/plugin-babel. Individual web tests opt into the DOM via the @vitest-environment happy-dom file-level comment.Per-test environment overrides (declared at the top of the file as // @vitest-environment happy-dom or in a /** @vitest-environment happy-dom */ block):
packages/utils/src/sanitize.test.tspackages/utils/src/file.test.tsapps/web/src/components/resume/preview.browser.test.tsxapps/web/src/components/resume/preview.shared.test.tsxapps/web/src/components/typography/combobox.test.tsxLocation: Co-located with implementation. foo.ts lives next to foo.test.ts (or foo.test.tsx for React).
Naming:
<name>.test.ts — Node/pure logic (e.g. packages/utils/src/string.test.ts).<name>.test.tsx — JSX/component tests (e.g. packages/ui/src/components/button.test.tsx).<name>.node.test.ts — Node-only modules whose implementation is also .node.ts (e.g. packages/utils/src/url-security.node.test.ts, packages/utils/src/monorepo.node.test.ts)..spec.* files in the repo today, but the include pattern supports them.Test counts (current): 127 *.test.ts files + 98 *.test.tsx files across packages/ and apps/web/src/.
Hot spots (where coverage is densest):
packages/utils/src/*.test.ts — string, color, html, date, level, locale, sanitize, rate-limit, field, file, network-icons, style, url, url-security.node, monorepo.node, plus resume/patch.test.ts.packages/ui/src/components/*.test.tsx — ~30+ component tests (button, dialog, alert, badge, card, combobox, command, popover, scroll-area, sidebar, switch, tabs, textarea, toggle, tooltip, etc.).packages/pdf/src/templates/shared/*.test.ts — columns, section-links, rich-text, metrics, picture, filtering.packages/pdf/src/section-title.test.ts and packages/pdf/src/hooks/use-register-fonts.test.ts.packages/api/src/{dto,helpers,services}/*.test.ts — dto/resume, helpers/resume-access-policy, services/ai.packages/schema/src/{templates,page}.test.ts and packages/schema/src/resume/{data,default}.test.ts.packages/ai/src/{tools,resume}/*.test.ts — patch-proposal, sanitize, extraction-template.packages/import/src/reactive-resume-v4-json.test.ts, packages/fonts/src/index.test.ts.apps/web/src/libs/{pwa,locale,theme,error-message}.test.ts, apps/web/src/dialogs/store.test.ts, apps/web/src/components/resume/preview.{browser,shared}.test.tsx, apps/web/src/components/typography/combobox.test.tsx.Idiomatic skeleton (from packages/utils/src/string.test.ts:1-21 and packages/api/src/helpers/resume-access-policy.test.ts:1-17):
import { describe, expect, it } from "vitest";
import { thingUnderTest } from "./thing";
describe("thingUnderTest", () => {
it("returns X for Y", () => {
expect(thingUnderTest(input)).toBe(expected);
});
it("returns Z for empty input", () => {
expect(thingUnderTest("")).toBe("");
});
});
Patterns observed:
describe per exported function; nested describe blocks group behaviors.it — short, declarative names ("returns X", "throws Y when Z", "does not mutate the input").null, empty string, unknown shapes, etc.it.each([...] as const)("variant=%s renders without throwing", (variant) => {...}) for matrix tests over discriminated union variants (see packages/ui/src/components/button.test.tsx:55-79).beforeEach / afterEach is used only when needed (timers, temp dirs, store reset). Example: apps/web/src/dialogs/store.test.ts:4-13 resets a Zustand store between tests with useDialogStore.setState(...).fs.mkdtempSync + realpathSync and are torn down in afterEach (packages/utils/src/monorepo.node.test.ts:7-17).vi.useFakeTimers() / vi.advanceTimersByTime(300) for animation/transition assertions (apps/web/src/dialogs/store.test.ts:54-65).Library: Vitest's built-in vi (no Jest). Used sparingly — most tests cover pure functions.
Patterns:
vi.fn() for callback assertions: expect(onClick).toHaveBeenCalledOnce() (packages/ui/src/components/button.test.tsx:32-37).vi.fn().mockResolvedValue(true) for async handlers (apps/web/src/dialogs/store.test.ts:86-93).vi.mock("module-path", () => ({ ... })) for replacing modules. Heaviest example: apps/web/src/components/resume/preview.browser.test.tsx:25-81 mocks @react-pdf/renderer, @/libs/resume/pdf-document, ./builder-resume-draft, and ./pdf-canvas so the preview component can be exercised without React PDF.vi.hoisted(() => ({ ... })) to share mutable state with hoisted vi.mock factories (apps/web/src/components/resume/preview.browser.test.tsx:8-12). This is required because vi.mock calls are hoisted above imports.vi.spyOn are rare; mock modules are preferred so the real implementation stays out of scope.Test data / fixtures:
fixtures/ directory. Tests build minimal objects inline or extend canonical defaults from the schema package:
import { defaultResumeData } from "@reactive-resume/schema/resume/default" (used by packages/api/src/helpers/resume-access-policy.test.ts:2).import { sampleResumeData } from "@reactive-resume/schema/resume/sample" (used by apps/web/src/components/resume/preview.browser.test.tsx:5).resumeDataWithPageCount helper at apps/web/src/components/resume/preview.browser.test.tsx:14-23).Render + query via Testing Library (packages/ui/src/components/button.test.tsx):
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole("button"));
Accessibility-first queries: getByRole("button", { name: "..." }) is the default; aria-label is asserted explicitly (packages/ui/src/components/button.test.tsx:81-84).
Slot / data-attr conventions: Components expose data-slot for shadcn/Base UI slotting and tests assert it (button.test.tsx:22-25).
Async UI: Use waitFor from @testing-library/react and await userEvent.*. The cleanup() afterEach in vitest.setup.ts ensures DOM doesn't leak between tests despite isolate: false.
Per-package package.json scripts (uniform pattern, e.g. packages/api/package.json:15-18):
test → vitest run --passWithNoTeststest:coverage → vitest run --coverage --passWithNoTeststest:ci → vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTeststest:agent → vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTestsThe agent reporter is a Vitest 4 feature optimized for LLM-driven runs; pnpm test:agent is the canonical script to use when an agent needs structured pass/fail data.
JUnit + JSON outputs land in reports/ inside each package (Biome-ignored).
.github/workflows/autofix.yml — runs on every PR and push to main. It runs pnpm knip --fix and pnpm check. It does NOT run pnpm test. Tests are not currently gating CI..github/workflows/docker-build.yml — workflow_dispatch only; builds multi-arch images. No test step..github/workflows/crowdin-sync.yml — translation sync only.Tests are run locally (pnpm test) or by agents via pnpm test:agent / pnpm test:ci. No PR is currently blocked by a failing test on GitHub Actions.
packages/utils, packages/schema, packages/pdf/src/templates/shared, packages/api/src/{helpers,dto} are exercised in isolation.packages/ui/src/components/*.test.tsx and a handful in apps/web/src/components/. Driven by @testing-library/react + happy-dom (file-level override) or packages/ui's package-level environment: "happy-dom".apps/web/src/components/resume/preview.browser.test.tsx is the closest, mocking React PDF and exercising the preview pipeline end-to-end in happy-dom.vitest --browser config.Async error testing:
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
try {
assertCanView({ userId: "u1", isPublic: false }, null);
expect.unreachable();
} catch (error: unknown) {
expect((error as { code?: string }).code).toBe("NOT_FOUND");
}
(See packages/api/src/helpers/resume-access-policy.test.ts:29-44.)
Immutability assertions:
const before = JSON.stringify(resume);
redactResumeForViewer(resume, false);
expect(JSON.stringify(resume)).toBe(before);
(See packages/api/src/helpers/resume-access-policy.test.ts:86-93.)
Time-sensitive logic: UUIDv7 ordering is verified with a setTimeout and a string compare instead of mocking time (packages/utils/src/string.test.ts:15-20).
These areas have implementation but no *.test.* files alongside them today — agents adding features here should consider adding tests.
packages/email/src/transport.ts and templates — no tests. SMTP transport is untested.packages/env/src/server.ts — no tests for env-var schema validation.packages/auth/ — no tests; Better Auth config and helpers are uncovered.packages/api/src/services/{resume,storage,statistics,auth,flags,resume-events}.ts — only ai.ts has a service-level test (ai.test.ts). Most resume mutation flow logic is exercised only indirectly through helpers/resume-access-policy.test.ts and dto/resume.test.ts.packages/api/src/routers/* — no router-level tests. End-to-end oRPC procedure behavior (auth + rate limit + service composition) is not asserted.packages/api/src/middleware/rate-limit/* — no tests.packages/db/src/schema/* — no tests (schema correctness is implicit, but no migration roundtrip tests).packages/scripts/ — no tests.packages/runtime-externals/ — no tests.apps/web/src/routes/** — route handlers (api/rpc.$.ts, api/auth.$.ts, api/health.ts, uploads/..., mcp/..., auth/oauth.ts) and most builder UI under routes/builder/$resumeId are uncovered.apps/web/src/dialogs/** — only store.test.ts covers the dialog store. Individual dialog components (resume create/update/import, two-factor, api-key) are uncovered.packages/pdf/src/templates/{azurill,bronzor,...} — individual template renderers have no tests; only the shared primitives in templates/shared/ are covered.When adding tests in any of these areas, follow the colocation rule (foo.ts ↔ foo.test.ts) and reuse defaultResumeData / sampleResumeData from @reactive-resume/schema/resume/* instead of inventing new fixtures.
Testing analysis: 2026-05-11