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:

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 with MODULE_NOT_FOUND on current Node versions. Use npm run test:unit (no argument — Node's default discovery finds tests/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 / expect from ./fixtures.js, not from @playwright/test. The fixture re-exports both and adds the opt-in coverage collector; a spec importing @playwright/test directly 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: search10 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:

  1. features/columns-reorder.feature.js — 27.92 unit / 68.19 browser. The repo's lowest better-suite figure and the only module under 70 %.
  2. 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.
  3. features/sorting-comparator.feature.js (27.19 browser) and storage/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.