Files
familienarchiv/docs/adr/012-browser-test-mocking-strategy.md
Marcel b9e2ed4af8
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 17s
CI / Backend Unit Tests (push) Successful in 4m13s
CI / fail2ban Regex (push) Successful in 37s
CI / Compose Bucket Idempotency (push) Successful in 56s
CI / Unit & Component Tests (pull_request) Failing after 2m47s
CI / OCR Service Tests (pull_request) Successful in 16s
CI / Backend Unit Tests (pull_request) Successful in 4m16s
CI / fail2ban Regex (pull_request) Successful in 38s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
docs(adr): 012 — browser-mode test mocking strategy
Documents why vi.mock(module, factory) races with birpc teardown for
dynamically-imported modules, the libLoader injection pattern used to fix
#535, and the residual exceptions ($app/*, $env/*) that are safe to keep
as vi.mock because they are resolved statically before any test runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:32:12 +02:00

4.5 KiB
Raw Blame History

ADR 012 — Browser-Mode Test Mocking Strategy

Status: Accepted
Date: 2026-05-11
Issue: #535 — Unit & Component Tests job exits 1 from vitest-browser teardown race


Context

Vitest browser-mode tests (the client project, run with @vitest/browser-playwright / Chromium) use a different module resolution path than Node-environment tests. When a spec calls vi.mock('some-module', factory), vitest registers a ManualMockedModule. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over birpc (resolveManualMock) to evaluate the factory and return the module body.

This is safe for modules that are imported statically at spec module-eval time (e.g. $app/navigation, $env/static/public): those requests resolve before the first test runs and well before any teardown occurs.

It is unsafe for modules that are imported dynamically (e.g. inside an async onMount, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing:

Error: [birpc] rpc is closed, cannot call "resolveManualMock"
   ManualMockedModule.factory   node_modules/@vitest/browser/dist/index.js:3221:34

This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green.

pdfjs-dist and pdfjs-dist/build/pdf.worker.min.mjs?url are loaded via await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]) inside usePdfRenderer.svelte.ts::init(), which is called from onMount. These dynamic imports triggered the race.


Decision

Prefer prop injection over vi.mock(module, factory) for any module that is loaded dynamically in browser-mode specs.

The libLoader pattern (for external rendering libraries)

When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default:

// usePdfRenderer.svelte.ts
type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;

const defaultLibLoader: LibLoader = () =>
  Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);

export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... }

The component threads the loader as an optional prop:

<!-- PdfViewer.svelte -->
let { url, ..., libLoader = undefined } = $props();
const renderer = untrack(() => createPdfRenderer(libLoader));

Tests supply a synchronous fake — no vi.mock needed:

const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} };
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
render(PdfViewer, { url: '...', libLoader: fakeLoader });

The test-host pattern (for component behaviour)

For components that fetch data or call services, the *.test-host.svelte pattern threads the dependency as a prop rather than mocking the module. See PersonMentionEditor.test-host.svelte for the canonical example.


Residual exceptions

The following vi.mock(module, factory) calls in browser specs are acceptable because the mocked modules are loaded statically at spec module-eval time and cannot produce a teardown race:

Module Why it stays as vi.mock
$app/navigation SvelteKit virtual module — no DI seam
$app/forms SvelteKit virtual module — no DI seam
$app/state SvelteKit virtual module — no DI seam
$app/stores SvelteKit virtual module — no DI seam
$env/static/public Vite env virtual module — no DI seam

These modules are resolved at static import time (before any test runs). Their vi.mock factories are served by birpc synchronously during module graph resolution, not after worker teardown.


Consequences

  • New browser-mode specs that need to stub an external library must not use vi.mock(externalLib, factory). Add a loader/factory parameter to the underlying hook or service instead.
  • The CI unit-tests job includes a permanent grep guard that fails the build if rpc is closed appears in any coverage run log. This catches regressions before they reach the acceptance criterion.
  • Acceptance criterion for #535: 60 consecutive green workflow_dispatch CI runs against main after the fix is merged, with zero rpc is closed lines in any log.