Testing Guide
Framework
Vitest across all packages β TypeScript-native, ESM-first, fast.
| Package | Environment | Key Libraries |
|---|---|---|
workers/api | node | Vitest, Hono app.request() |
apps/web | jsdom | Vitest, React Testing Library, @testing-library/jest-dom |
packages/shared | node | Vitest |
Directory Structure
workers/api/src/__tests__/ __mocks__/env.ts # KV, R2, Env factories __fixtures__/html-samples.ts middleware/auth.test.ts routes/auth.test.ts routes/files.test.ts routes/convert.test.ts routes/export.test.ts routes/benchmark.test.ts services/wcag-validator.test.ts services/html-analyzer.test.ts services/ux-optimizer.test.ts services/math-detector.test.ts services/claude-converter.test.ts services/marker-converter.test.ts services/unpdf-claude-converter.test.ts services/mathpix-pdf.test.ts services/image-enhancer.test.ts services/benchmark-orchestrator.test.ts services/axe-validator.test.ts services/pdf-generator.test.ts utils/crypto.test.ts utils/response.test.ts index.test.ts
apps/web/src/__tests__/ setup.ts # RTL + jsdom + matchMedia mock lib/utils.test.ts lib/mathpix-storage.test.ts lib/api.test.ts lib/auth-context.test.tsx components/upload/DropZone.test.tsx
packages/shared/src/__tests__/ constants.test.ts benchmark-types.test.tsRunning Tests
# All packages (via Turborepo)npm run test:run
# With coveragenpm run test:coverage
# Watch modenpm test
# Single packagecd workers/api && npx vitest runcd apps/web && npx vitest runcd packages/shared && npx vitest run
# Browser integration tests (real Puppeteer, requires internet for axe-core CDN)cd workers/api && npm run test:browserBrowser Integration Tests
Browser tests live in workers/api/src/__tests__/integration/ and are excluded from the default vitest run. They use real Puppeteer (bundled Chromium, installed automatically with npm install) and axe-core loaded from CDN.
Run them explicitly before a deployment or major release:
cd workers/api && npm run test:browserWhat they test:
- 10 axe-core end-to-end scenarios (real violations detected in a real browser)
- 2 resize text checks β 1.4.4 (responsive passes, fixed-width fails)
- 2 text spacing checks β 1.4.12 (normal HTML passes, fixed-height clip fails)
- 2 focus order checks β 2.4.3 (natural order passes, positive tabindex disruption warned)
Requirements:
- Puppeteer Chromium binary (auto-installed with
npm install) - Internet access (axe-core loaded from
cdnjs.cloudflare.com) - ~30 seconds to run all 16 tests
Config: workers/api/vitest.browser.config.ts β 60 second test timeout.
Mock Strategy
| Dependency | Approach |
|---|---|
| Cloudflare KV | In-memory Map-backed mock (createMockKV()) |
| Cloudflare R2 | In-memory Map-backed mock (createMockR2()) |
| Cloudflare Puppeteer | vi.mock('@cloudflare/puppeteer') with mock page/browser |
| Anthropic SDK | vi.mock('@anthropic-ai/sdk') with mock messages.create |
| unpdf | vi.mock('unpdf') |
| External fetch (Mathpix, Marker) | vi.stubGlobal('fetch', mockFetch) |
| Supabase client | vi.mock('@/lib/supabase') |
| localStorage | jsdomβs built-in window.localStorage |
| Auth (JWTs) | Real HMAC-SHA256 signed JWTs via createTestJwt() helper |
Adding New Tests
- Create a
.test.ts(or.test.tsx) file in the appropriate__tests__/directory - Import from
vitest:{ describe, it, expect, vi, beforeEach } - Use existing mock factories from
__mocks__/env.tsfor API tests - For route tests, use the
createTestJwtpattern from existing route tests - Run
npx vitest runin the package to verify
Ratchet Tests (declining allowlists)
Some rules cannot be expressed as a unit test of one module β they are invariants across files, apps or runtimes. We encode those as ratchets: a test that statically parses the source, asserts the invariant, and carries an explicit allowlist of the places that do not satisfy it yet. Two rules make a list a ratchet rather than a rug:
- A subject not on the allowlist that breaks the invariant fails, naming what is missing.
- A subject on the allowlist that now satisfies the invariant also fails, telling the developer to delete its entry.
Rule 2 is what makes the list tighten-only: an allowlist entry can never quietly outlive the gap it documents.
| Ratchet | Guards |
|---|---|
workers/api/src/__tests__/route-parity.test.ts | route registration matches between the Worker and Lambda entry points (plus: every org-chart route is also on the Node composition). server.tsβs full set is deliberately not diffed β see that testβs header |
workers/api/src/__tests__/env-parity.test.ts | every Env key is wired into buildSyntheticEnv() (Node) and c.env (Lambda) |
packages/accessible-org-ui/src/__tests__/toast-standard-ratchet.test.ts | every released app mounts the shared toast with error auto-reporting (below) |
workers/api/src/__tests__/zdr-google-endpoint-guard.test.ts | no module outside an explicit allowlist constructs a direct Google AI client |
workers/api/src/__tests__/config-ratchet-coverage.test.ts | the workflowβs unconditional cross-workspace step lists exactly the workers/api tests that read outside their workspace, in both directions |
That table is an index, not a closed set β add a row when you add a ratchet.
Note that config-ratchet-coverage.test.ts polices only workers/api/src/__tests__:
it walks its own __dirname, so a cross-workspace ratchet living in any other
package β the toast one below included β is outside its reach and is not
scheduled by it. See the scheduling caveat at the end of the next section.
Error-reporting standard (released apps)
Every released app β see RELEASED_APPS in that test for the current set β
must meet three mechanical rules, which the ratchet enforces statically:
- mount the
ToastProviderexported by@accessible-org/ui, directly or through a thin local wrapper (AppToastProvider,components/toast/β¦); - pass it a
recordErrorβ normallycreateErrorRecorder({ apiBase, getAccessToken })β souseNotifyErrorPOSTs to/api/errors, which files or updates a support ticket and alerts admin over Telegram on each new error code (repeats throttled), plus an immediate email forseverity: 'critical'. Without it users see a toast and nobody is told; - actually raise toasts from its call sites (
useToast/useNotify/useNotifyErrorfrom the shared package), rather than ad-hoc inline error text.
Plus one rule no parser can judge, left to code review: surface the serverβs
error.message for failed calls, falling back to a generic string only when
the server returned none β and report even then.
Detection is by TypeScript AST, not grep: JSX tag names and the recordError
expression are resolved through each moduleβs import bindings, so aliased
imports, local wrappers and barrels are followed while dead imports and
unrendered provider trees are not counted. The detectorβs own limits are listed
in its file header β when one bites, teach the detector rather than adding a
KNOWN_GAPS entry.
To enforce a newly released app, add one line to RELEASED_APPS in that
test. If it is not compliant yet, add a KNOWN_GAPS entry naming the issue
that tracks the work and pinning the failure signature β never to silence a
regression in an app that used to comply. Unreleased apps are deliberately out
of scope (several carry forked local toast implementations; see #3445).
Scheduling caveat β #3451. This ratchet lives in
packages/accessible-org-ui but inspects apps/*, and CI selects workspaces
with turbo --filter='...[origin/main]' β changed packages plus their
dependents. An apps-only PR does not schedule the shared package. The ratchet
does run on every PR touching packages/accessible-org-ui, which includes every
PR that edits KNOWN_GAPS. #3451 carries the one workflow step that closes the
gap.
Coverage Targets
| Area | Target |
|---|---|
workers/api/src/services/ | 80% |
workers/api/src/routes/ | 70% |
workers/api/src/middleware/ | 85% |
workers/api/src/utils/ | 90% |
packages/shared/ | 95% |
apps/web/src/lib/ | 70% |
apps/web/src/components/ | 60% |
| Overall | 75% |