-
Notifications
You must be signed in to change notification settings - Fork 1
Testing Strategy
This page describes how openrunic is tested: the runner and its per-workspace configuration, where coverage floors are enforced, and what a good test asserts in a clinical system. It is for anyone writing tests here.
Vitest 4 across every workspace, per ADR-0001. Run scoped:
pnpm --filter web test
pnpm --filter api test
pnpm --filter @openrunic/fhir test
pnpm --filter @openrunic/database test
pnpm --filter @openrunic/ui test| Workspace | Environment | Coverage provider | Thresholds in config |
|---|---|---|---|
web |
jsdom | istanbul | None. CI enforces on merged coverage. |
api |
node | istanbul | None. CI enforces on merged coverage. |
@openrunic/ui |
jsdom | v8 | 95 on statements, branches, functions, lines |
@openrunic/database |
node | v8 | 95 on all four, scoped to the pure modules |
@openrunic/fhir |
node | v8 | Suite runs without coverage in CI |
@openrunic/types |
node | v8 | Suite runs without coverage in CI |
Two provider quirks are worth knowing before you file a coverage bug.
The apps deliberately omit @vitejs/plugin-react from their vitest config. Its second transform pass double-instruments files under istanbul and roughly halves the reported numbers. Both configs use esbuild's automatic JSX runtime instead. The React plugin still appears in the Vite build config and in Storybook, where it belongs.
The apps have no thresholds in their vitest config, on purpose. CI shards the suites, and a per-shard threshold would evaluate against a slice rather than the whole. Floors are enforced once, on the merged coverage map. Adding thresholds to those configs would make local runs fail for reasons CI does not care about.
COVERAGE_FLOORS in .github/workflows/_test.yaml, applied by scripts/ci/merge-coverage.mjs after the shards are merged.
Current values on dev:
| App | statements | branches | functions | lines |
|---|---|---|---|---|
web |
80 | 70 | 75 | 80 |
api |
85 | 75 | 85 | 85 |
The
feat/emr-appbranch raises these toward 95: statements, functions, and lines at 95 for both apps, with branches at 90 forweband 95 forapi. That change has not merged todev. Treat 95 as the target and the table above as what CI enforces today.
Branch coverage sits below the other three deliberately. Both apps carry defensive code whose second arm is unreachable through the product: a fallback behind a bounds check, a component prop default React always supplies. A branch floor level with the rest would only be reachable by writing tests that call code no user path calls, which is coverage theatre rather than confidence.
The merge job fails when an app reports coverage but has no floor entry. A missing floor is a hole in the gate, not a default pass.
Shared packages run without coverage in CI and need no entry. @openrunic/ui and @openrunic/database enforce their own 95 percent floors through their vitest configs on every run, including in CI.
Coverage percentages are the floor, not the goal. What earns its keep in this system:
Test through the real seam, not around it. The API suite drives the actual Hono application with app.request(...) against in-memory repositories. No database, no port binding, no mocked router. That is why 16 suites are fast enough to shard, and why they catch middleware-ordering bugs that a handler unit test never would.
Test the negative path, always. For any route: the anonymous 401, the wrong-role 403, and the cross-tenant 404. The cross-tenant case is the one people skip and the one that matters most. There is already a generated suite that walks the model graph and attempts a cross-tenant read and write through every repository path, asserting both the denial and the audit record of the denial.
Test the shape of failure, not just its presence. A 422 with a field-level errors array pointing at the right path is a different contract from a bare 422. Assert the problem document's type, status, and errors, not only the status code.
Round-trip every mapper. A mapper without round-trip tests does not merge. The harness asserts domain -> FHIR -> domain, FHIR -> domain -> FHIR, and that the emitted JSON contains no nulls, undefined keys, empty strings, or empty arrays anywhere. Fixtures must include a sparse case and a degenerate all-empty case, because the interesting failures are in the absent fields.
Test that immutability holds. A signed note cannot be edited. A published form version cannot be changed. An applied migration cannot be modified. An audit row is never written twice. Each of those is a rule the code enforces, so each deserves a test that tries to break it.
Verify the audit chain, not just its presence. verifyAuditChain returns a structured reason on failure. Assert { valid: true, checked: n }, and where the test is about tampering, assert the specific break reason.
Prefer deterministic fixtures over generators. The database seed has no random source and no wall clock; ids come from a UUIDv7 generator wired to a fixed clock and byte source, so two runs produce byte-identical rows. The web app's mock layer uses a fixed clock for the same reason. A flaky fixture is worse than no fixture.
Assert accessibility structurally where you can. The component library's tests check that a status badge carries a label or icon and not only a colour, that focus is trapped and restored in a modal, and that hint and error text is wired as a description rather than folded into the accessible name.
The single most valuable integration test this project can have is not a unit test. It is one continuous run through a working day, in order, on one synthetic patient:
book -> check in -> room -> chart the visit -> place an order -> resolve the result
-> capture charges -> scrub -> submit the claim -> post the remittance
-> take a patient payment -> send the statement
Each step's output is the next step's input, so the drill catches the failures unit tests structurally cannot: an appointment status that never produces an encounter, a charge whose diagnosis pointers do not survive into the claim line, a remittance that posts against the wrong allocation, a step that works alone and not in sequence.
Use Testina Patientsson, MRN OR-100482, and a provider such as Dr. Okafor. The deterministic seed exists precisely so this drill can assert exact values rather than only shapes.
This drill is not fully implemented yet. The stages exist across the mock layer and the schema, but the API aggregates it needs are still reserved. Build it as those land.
CI expands the affected-workspace matrix into shards with scripts/ci/test-matrix.mjs and runs each leg as:
pnpm --filter <workspace> exec vitest run \
--coverage --coverage.reporter=json \
--coverage.reportsDirectory=coverage \
--maxWorkers=2 --shard=<n>/<total>Vitest is invoked through pnpm exec rather than the package's test script, so every leg carries identical flags and does not depend on argument forwarding.
Shards emit istanbul JSON rather than lcov, because merging lcov text loses function coverage data. lcov is generated once from the merged coverage map, for Sonar. See CI pipeline.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.