Back to Reactive Resume

Testing Patterns

.planning/codebase/TESTING.md

5.1.315.4 KB
Original Source

Testing Patterns

Analysis Date: 2026-05-11

Test Framework

Runner: Vitest 4.x.

  • Root devDependency in package.json: "vitest": "^4.1.5", "@vitest/coverage-v8": "^4.1.5".
  • Every workspace package and 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.1

Run commands (root package.json):

bash
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):

bash
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.

Shared Vitest Configuration

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:

  • Provider: v8
  • Output: ./coverage per package
  • Reporters: text, text-summary, json-summary, json, lcov, html
  • Include: src/**/*.{ts,tsx}
  • Exclude: src/**/*.{test,spec}.*, src/**/*.d.ts, src/routeTree.gen.ts
  • reportOnFailure: true

No global coverage thresholds are enforced (no thresholds: {...} block). Per-package coverage HTML lives under each package's coverage/ directory after pnpm test:coverage.

Global Setup (vitest.setup.ts)

Applied to every project:

  1. import "@testing-library/jest-dom/vitest" — registers toBeInTheDocument, toHaveAttribute, etc.
  2. afterEach(() => cleanup()) — explicit RTL cleanup (Vitest doesn't expose afterEach globally without test.globals: true).
  3. Polyfills for jsdom/happy-dom gaps: ResizeObserver, IntersectionObserver, Element.prototype.scrollIntoView, window.matchMedia (used by cmdk, Base UI, next-themes).

Per-Package Vitest Configs

All vitest.config.ts files reuse the shared factory. Notable variants:

  • Node default (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".
  • DOM (packages/ui/vitest.config.ts:7) — environment: "happy-dom" for component tests.
  • Web app (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.ts
  • packages/utils/src/file.test.ts
  • apps/web/src/components/resume/preview.browser.test.tsx
  • apps/web/src/components/resume/preview.shared.test.tsx
  • apps/web/src/components/typography/combobox.test.tsx

Test File Organization

Location: 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).
  • No .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.tsdto/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.

Test Structure Patterns

Idiomatic skeleton (from packages/utils/src/string.test.ts:1-21 and packages/api/src/helpers/resume-access-policy.test.ts:1-17):

typescript
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:

  • Top-level describe per exported function; nested describe blocks group behaviors.
  • One assertion focus per it — short, declarative names ("returns X", "throws Y when Z", "does not mutate the input").
  • Negative cases are first-class — every helper has tests for 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).
  • Setup with 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(...).
  • Temp directories use fs.mkdtempSync + realpathSync and are torn down in afterEach (packages/utils/src/monorepo.node.test.ts:7-17).
  • Fake timers via vi.useFakeTimers() / vi.advanceTimersByTime(300) for animation/transition assertions (apps/web/src/dialogs/store.test.ts:54-65).

Mocking

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.
  • Spies via vi.spyOn are rare; mock modules are preferred so the real implementation stays out of scope.

Test data / fixtures:

  • No central 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).
  • Local helper builders are inlined per test file (e.g. the resumeDataWithPageCount helper at apps/web/src/components/resume/preview.browser.test.tsx:14-23).

React Component Testing

Render + query via Testing Library (packages/ui/src/components/button.test.tsx):

typescript
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.

Reporters

Per-package package.json scripts (uniform pattern, e.g. packages/api/package.json:15-18):

  • testvitest run --passWithNoTests
  • test:coveragevitest run --coverage --passWithNoTests
  • test:civitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests
  • test:agentvitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests

The 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).

CI

  • .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.ymlworkflow_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.

Test Types

  • Unit tests — Dominant. Pure functions in packages/utils, packages/schema, packages/pdf/src/templates/shared, packages/api/src/{helpers,dto} are exercised in isolation.
  • Component testspackages/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".
  • Integration tests — None against the live Drizzle client or the running TanStack Start server. 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.
  • E2E / browser tests — Not present. No Playwright, Cypress, or vitest --browser config.

Common Patterns

Async error testing:

typescript
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:

typescript
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).

Coverage Gaps Worth Flagging

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.tsfoo.test.ts) and reuse defaultResumeData / sampleResumeData from @reactive-resume/schema/resume/* instead of inventing new fixtures.


Testing analysis: 2026-05-11