Testing and Code Coverage
How this repository is tested, how coverage is measured, and how to read the numbers. This document covers all three components and the sample apps; it is the reference for the test suites themselves, not for any one component's behaviour.
1. Two suites plus a type check, by design
npm test runs all three, type check first. The two suites are complementary,
not redundant:
| Suite | Runner | What it covers | Needs |
|---|---|---|---|
Unit (tests/node/) |
Node's built-in node:test |
Pure logic: data managers, filter model, scheduler, comparators, storage providers, templates, scroll arithmetic | Nothing — no browser, no server |
E2E (tests/playwright/) |
Playwright + Chromium (plus Firefox for the firefox-placement / firefox-keyboard projects) |
Everything touching the DOM: rendering, virtualization, header menus, resizing, theming, pointer and wheel input | Chromium and Firefox; the static server starts automatically |
Neither suite measures the third check, tests/types/ (npm run test:types):
tsc --noEmit compiling small hand-written .dts-typecheck.ts files against
each component's hand-authored .d.ts (CLAUDE.md rule 3). Deliberately not
named *.test.ts / *-test.ts: those suffixes match Node's own --test
discovery glob, and npm run test:unit would then try to execute them —
they use browser globals (document, HTMLElement) with no DOM present and
would fail. It's a compile-time type check, not a coverage-measured suite — a
compile error is the assertion, and nothing in it executes. It complements the
unit suite's tests/node/dts-conformance.test.js, which checks method/property
names match at runtime but can't see a type going stale (e.g. a parameter
widened from string to string | number); the type check catches exactly
that. Extend the matching *.dts-typecheck.ts file when a component's public
API grows.
The split is deliberate. A module full of arithmetic is cheaper and more
precisely testable in Node — a real browser adds nothing to deltaMode handling
or $filter string generation, but makes the branches harder to reach. A module
that manipulates layout is the opposite: asserting it against hand-built DOM
stubs proves the code works against the stubs, which is a weaker guarantee than
the browser test that already exists.
Reading the two percentages
They use different denominators — Node counts its own executable lines, the browser aggregator counts statement-bearing lines derived from V8 byte ranges — so they are not addable and not directly comparable. Judge each module by the better of its two numbers, because a module is well covered if either suite covers it well:
viewport.feature.jsreads ~30 % in the unit suite and 94 % in the browser. It is well covered.odata-data-manager.jsreads ~56 % in the browser and 100 % in the unit suite. It is well covered.
Blending them, or reading one alone, is misleading in both directions.
2. Running the suites
npm install # first time only
npx playwright install chromium firefox # first time only
npm test # all three: type check, then unit, then E2E
npm run test:types # .d.ts type check only (fastest)
npm run test:unit # Node unit suite only (fast)
npm run test:e2e # Playwright suite only
node --test tests/node/dom-scheduler.test.js # one unit file
npx playwright test tests/playwright/autofit.spec.js # one spec
node --test tests/node(a bare directory) fails withMODULE_NOT_FOUNDon current Node versions. Usenpm run test:unit(no argument — Node's default discovery findstests/node/*.test.js), a glob, or an explicit file path.
Coverage
npm run test:coverage # unit-suite coverage over src/, ~5 s
npm run test:coverage:e2e # browser-side V8 coverage, ~23 min
test:coverage uses Node's --experimental-test-coverage, scoped to src/**
so build.js and the tests themselves stay out of the denominator. It enforces
thresholds (§5) and exits non-zero below them.
It reports only files a test actually loads — a source file no unit test
requires is absent from the table rather than shown as 0 %.
tests/node/dts-conformance.test.js loads every component source precisely so
nothing can hide from the report.
test:coverage:e2e re-runs the Playwright suite with PW_COVERAGE=1,
collecting Chromium's V8 coverage per test via the fixture in
tests/playwright/fixtures.js, then aggregates the per-test captures with
scripts/aggregate-coverage.js into a table plus
coverage/e2e-summary.json (which carries per-file uncovered line numbers —
useful when you want to know which lines are missing, not just how many).
Writing a spec: import
test/expectfrom./fixtures.js, not from@playwright/test. The fixture re-exports both and adds the opt-in coverage collector; a spec importing@playwright/testdirectly still runs but contributes nothing to the coverage table.
3. Current measurement
Measured 2026-08-17 on Node v25.9.0 / Playwright 1.59.1 / Chromium.
| Suite | Coverage | Tests | Runtime |
|---|---|---|---|
| Unit | 64.53 % lines · 76.38 % branches · 54.35 % functions | 872 in 118 suites | 4.7 s |
| Browser | 80.32 % statement lines (10,754 / 13,389) | 373 total; 352 instrumented | 22.6 min |
The browser total is not exact to the decimal. Across five green runs of the same tree it landed between 80.23 % and 80.32 % — a spread of about ±0.05 pt, because a few timing-dependent paths (rAF-deferred renders, idle timers) are hit in some runs and not others. Treat a sub-0.1 pt change as noise. Per-module figures are stable to the decimal.
By module, ranked by the better suite
| Module | LOC | Unit line % | Unit func % | Browser line % | Best |
|---|---|---|---|---|---|
vanilla-grid/features/columns-reorder.feature.js |
437 | 27.92 | 18.18 | 68.19 | 68.19 |
vanilla-grid/vanilla-grid.js |
1693 | 73.73 | 51.65 | 77.79 | 77.79 |
vanilla-grid/features/excel-export.feature.js |
413 | 39.51 | 90.91 | 78.69 | 78.69 |
vanilla-grid/features/columns-resize.feature.js |
803 | 15.06 | 33.33 | 80.57 | 80.57 |
vanilla-grid/vanilla-grid-element.js |
1040 | 41.69 | 9.57 | 80.58 | 80.58 |
vanilla-grid/features/interaction-pointer.feature.js |
60 | 30.77 | 25.00 | 81.67 | 81.67 |
vanilla-grid/features/columns-freeze.feature.js |
305 | 53.96 | 85.71 | 81.97 | 81.97 |
vanilla-grid/features/excel-export-worker.feature.js |
234 | 80.24 | 16.67 | 82.91 | 82.91 |
vanilla-grid/features/interaction.feature.js |
481 | 70.40 | 61.90 | 83.58 | 83.58 |
vanilla-grid/features/persistence.feature.js |
534 | 85.59 | 69.62 | 55.24 | 85.59 |
vanilla-grid/features/sorting-worker.feature.js |
208 | 87.40 | 87.50 | 80.77 | 87.40 |
vanilla-grid/features/header-menu.feature.js |
464 | 22.04 | 16.67 | 87.50 | 87.50 |
vanilla-grid-toolbar/vanilla-grid-toolbar.js |
1158 | 38.12 | 4.22 | 88.60 | 88.60 |
vanilla-grid/features/rendering.feature.js |
793 | 31.83 | 27.91 | 89.16 | 89.16 |
vanilla-grid/features/columns-visibility.feature.js |
176 | 71.81 | 90.91 | 89.77 | 89.77 |
vanilla-grid/features/sorting.feature.js |
357 | 89.94 | 90.00 | 78.71 | 89.94 |
vanilla-resize-box/vanilla-resize-box.js |
234 | 57.24 | 44.00 | 90.60 | 90.60 |
vanilla-grid/data-managers/graphql-data-manager.js |
358 | 90.99 | 66.67 | 70.95 | 90.99 |
vanilla-grid/intl-cache.js |
20 | 92.42 | 100.00 | 85.00 | 92.42 |
vanilla-grid/data-managers/data-manager.js |
35 | 92.67 | 35.29 | 74.29 | 92.67 |
vanilla-grid/features/stretch-to-fit.feature.js |
216 | 89.22 | 90.91 | 93.06 | 93.06 |
vanilla-grid/features/interaction-keyboard.feature.js |
117 | 55.38 | 62.50 | 93.16 | 93.16 |
vanilla-grid/storage/grid-storage-provider.js |
86 | 94.05 | 62.50 | 51.16 | 94.05 |
vanilla-grid/column-key.js |
12 | 94.44 | 100.00 | 83.33 | 94.44 |
vanilla-grid/features/viewport.feature.js |
397 | 30.06 | 32.35 | 94.46 | 94.46 |
vanilla-grid/features/selection.feature.js |
304 | 94.78 | 94.74 | 77.96 | 94.78 |
vanilla-grid/features/columns.feature.js |
232 | 90.88 | 55.88 | 94.83 | 94.83 |
vanilla-grid/features/sorting-comparator.feature.js |
114 | 95.24 | 90.91 | 27.19 | 95.24 |
vanilla-grid/shimmer-threshold.js |
16 | 96.00 | 100.00 | 87.50 | 96.00 |
vanilla-grid/data-managers/static-data-manager.js |
1199 | 96.12 | 82.00 | 86.24 | 96.12 |
vanilla-grid/dom-scheduler.js |
54 | 96.58 | 100.00 | 87.04 | 96.58 |
vanilla-grid/templates.js |
55 | 96.90 | 100.00 | 78.18 | 96.90 |
vanilla-grid-toolbar/vanilla-grid-toolbar-events.js |
13 | 97.37 | 100.00 | 84.62 | 97.37 |
vanilla-grid/filter-model.js |
140 | 98.81 | 95.45 | 95.00 | 98.81 |
vanilla-grid/grid-events.js |
23 | 99.02 | 100.00 | 91.30 | 99.02 |
vanilla-grid/data-managers/odata-data-manager.js |
525 | 100.00 | 100.00 | 55.62 | 100.00 |
vanilla-grid/features/excel-export-delivery.feature.js |
83 | 100.00 | 100.00 | 74.70 | 100.00 |
LOC is statement-bearing lines as counted by the browser aggregator. A dash in a unit column means no unit test loads that file.
No module sits below 60 % in its better suite. The lowest is
columns-reorder.feature.js at 68.19 %.
4. What the instrumented run deliberately excludes
playwright.config.js defines INSTRUMENTATION_SENSITIVE_SPECS, applied via
testIgnore only when PW_COVERAGE=1. Two groups, for two different reasons.
Both are skipped, not weakened, and both run in full on every ordinary
npm run test:e2e.
Timing-sensitive specs
scrolling-performance, autofit-performance, search-warm-latency,
search-worker-performance, touch-momentum-sync-render,
grouping-toggle-performance.
Collecting V8 coverage slows the page enough to break their frame-budget and slice-latency assertions. Coverage collection perturbs the very thing they measure, so they are permanently outside the coverage picture — running them instrumented fails reliably even with no parallel contention.
The consequence is worth internalising: for any behaviour those specs own,
coverage can only be earned by re-testing it in a form that can be measured.
wheel-input.spec.js exists for exactly that reason — it asserts the wheel
state machine on a small dataset with no timing claims, so it runs inside the
measured suite, while scrolling-performance.spec.js keeps the timing claim
outside it.
Live network specs
northwind-orders-js-smoke (Northwind OData), github-repos-js-smoke (GitHub
Search), wikipedia-pages-js-smoke (MediaWiki), people-cities-js-smoke (MINT,
this project's own hosted BFF), plus late-children-framework-integration
(GitHub Search) and sample-search-box, which intercepts Northwind requests to
assert the $filter URL but lets them continue to the wire.
These let a real service respond, so their result depends on a service's
availability and quota. The first two are someone else's infrastructure; MINT
(people-cities-app.mthome.org) is the project's own, which makes it the one outage that
would be self-inflicted — the trade accepted for having any end-to-end check
that people-cities-js still works against a real backend, since its other 42
specs block the BFF entirely. GitHub's unauthenticated Search API is the binding
constraint:
| Observation | Value |
|---|---|
| Live search requests per page load | 1 (/search/repositories) |
| Rate limit | x-ratelimit-limit: 10, x-ratelimit-resource: search — 10 per minute |
| Consumption across three sequential loads | remaining: 9 → 8 → 7 |
One suite run makes 3–5 such requests and fits inside the budget. Repeated or
overlapping runs do not — and measuring coverage several times in a row is
exactly that. Sampling the limit at a single moment shows a healthy 200, which
is why this is easy to misdiagnose as a component regression.
Excluding them costs almost nothing: each is a thin smoke test, or asserts only
the request URL, over modules many other specs exercise.
odata-data-manager.js's browser figure is unchanged either way.
A spec that merely needs rows on screen does not belong in this group — it
should stub the backend. search-term-persistence, filter-panel-localization
and theme-applies-on-load used to fetch Northwind purely to populate the grid,
then assert something unrelated to the data (persistence round-trip, i18n
labels, rendered theme). That made them fail on a third party's latency:
Northwind's tail runs past 5s under ten concurrent queries, and combined with
full-suite worker contention it pushed search-term-persistence's 30s row wait
past its timeout — a failure that never reproduced in isolation (32/32 passes at
--repeat-each=4). They now use _northwind-stub.js, which serves canned rows
honouring $top / $skip and the contains() disjunction the grid emits in
searchMode: 'filter'. No assertion was weakened, and the three specs went from
~8–18s each to ~2s.
Why the instrumented run is single-worker
scripts/run-e2e-coverage.js passes --workers=1. A few specs assert on a race
— a host-set toolbar status message versus an error status — and lose it when
instrumentation overhead is compounded by parallel workers competing for CPU.
They pass instrumented in isolation, so the fix is to remove the contention
rather than to loosen timeouts that are correct for the ordinary run.
Note that serialising does not help the rate-limited specs above, and slightly hurts: run in parallel their requests are interleaved with unrelated specs and spread out in time; run serially they execute back-to-back, packing every request into one short window. That is why both mechanisms are needed.
5. Thresholds and the ratchet
npm run test:coverage enforces unit thresholds, and CI runs it on every push
and pull request (.github/workflows/ci.yml: lint, test:types,
test:coverage, build). The Playwright suite is not run in CI — it needs a
Chromium download and several minutes. Run it locally before merging.
| Metric | Threshold | Current |
|---|---|---|
| lines | 63 | 64.53 |
| branches | 75 | 76.38 |
| functions | 53 | 54.35 |
They sit a point or two under the current numbers so they ratchet upward rather than blocking ordinary work. Raise them when coverage improves; never lower them to make a red build green.
6. Where the remaining gap is — and why most of it should stay
Ranked by absolute uncovered unit lines:
| Module | Uncovered unit lines | Unit line % | Browser line % |
|---|---|---|---|
vanilla-grid-toolbar.js |
1,354 | 38.12 | 88.60 |
vanilla-grid-element.js |
1,295 | 41.69 | 80.58 |
features/columns-resize.feature.js |
1,139 | 15.06 | 80.57 |
features/rendering.feature.js |
1,011 | 31.83 | 89.16 |
features/header-menu.feature.js |
573 | 22.04 | 87.50 |
features/viewport.feature.js |
463 | 30.06 | 94.46 |
That is the bulk of all uncovered unit lines, and every one of those modules is
covered at 80–94 % by the browser suite. Covering them in Node would mean
building elaborate DOM stubs and then asserting the code behaves correctly
against those stubs — expensive, brittle, and a weaker guarantee than the
browser test that already exists. The same argument disposes of the low unit
function percentages (vanilla-grid-toolbar.js 4.22 %,
vanilla-grid-element.js 9.57 %): those methods are called from a browser, and
that is where they are tested.
Genuinely thin in both suites, in priority order:
features/columns-reorder.feature.js— 27.92 unit / 68.19 browser. The repo's lowest better-suite figure and the only module under 70 %.features/columns-resize.feature.js— 15.06 unit / 80.57 browser. The browser number is healthy; the unit number is the lowest in the repo.features/sorting-comparator.feature.js(27.19 browser) andstorage/grid-storage-provider.js(51.16 browser) look alarming in the browser table but are 95.24 % and 94.05 % in the unit suite. Well covered — no action.
Not planned, deliberately: cross-browser Playwright projects, any third-party coverage stack, and restructuring the existing suites.
7. Practical guidance
Chase branches, not just lines. A module can read ~97 % on lines and still
hide real gaps: odata-data-manager.js sat at 96.79 % lines / 83.33 % branches,
and the missing branches were per-operator $filter serialization — where a
mistake does not throw, it silently returns the wrong rows.
A green test is not evidence it tested anything. During this work a
count-failure test asserted the right value and passed, but reached it through
an early return (if (!this._countUrl)) and never executed the branch it named.
Only the coverage data exposed it. When you add a test for a specific branch,
confirm the branch's line count actually moved.
Use coverage/e2e-summary.json to find the gap. It carries per-file
uncoveredLines, so you can target the largest coherent block instead of
guessing which behaviour is missing.
Hunt flakes with --workers=1. High --repeat-each with parallel workers
overwhelms the static file server the suite starts on port 4173; the resulting
net::ERR_CONNECTION_REFUSED failures look alarming and say nothing about the
components.
Never sleep in a unit test. Use waitFor(predicate) from
tests/node/_helpers.js. A fixed setTimeout ties the result to machine speed,
and the suite runs its files concurrently — that is how the one historical flake
in this suite happened.
Poll boot-time state in a spec. bootGrid blocks the BFF with
page.route(...).abort(), which intercepts inside the browser before the
network — so whether a backend is actually listening on port 3000 is irrelevant,
and the app's boot fetch always fails. Components therefore transiently show
an error state before injected rows clear it. Any assertion about that state
must be polled (Playwright's expect.poll / auto-retrying assertions), never
sampled once.
Assert proportion, not pixels, for pointer drags. Thumb geometry depends on
the theme's row height and the runner's window size. Assert direction and
proportional movement, as column-resizing.spec.js and custom-scrollbar.spec.js
do, and verify every new spec under --repeat-each=3 — instrumented as well as
not, since coverage overhead widens every render race.
8. Test layout
tests/
types/ # tsc --noEmit smoke tests (npm run test:types)
*.dts-typecheck.ts
playwright/ # Playwright E2E specs (npm run test:e2e)
fixtures.js # test/expect re-export + PW_COVERAGE fixture
_helpers.js # shared boot/row-injection helpers
*.spec.js
node/ # Node built-in (node:test) unit suite
_helpers.js # shared helpers (waitFor)
dts-conformance.test.js # runtime name check of the .d.ts files
*.test.js
scripts/
run-e2e-coverage.js # runs the E2E suite with coverage enabled
aggregate-coverage.js # aggregates browser V8 coverage into a table
run-tests.ps1 / record-test.ps1 # PowerShell helpers
playwright.config.js # Playwright configuration + spec exclusions
tsconfig.json # tsc config for tests/types/
playwright.config.js starts a static server on port 4173 for the whole repo
root, so every sample app is reachable; baseURL points relative navigations at
people-cities-js, and specs targeting other apps use an absolute path.