@hyperfrontend/ testing§
Jest-style describe, it, expect and jest on Node's built-in test runner, with module mocks, fake timers, an optional DOM and a coverage gate.
What is @hyperfrontend/testing?
@hyperfrontend/testing is a thin layer over Node's built-in test runner. It supplies the four things a suite written in the Jest idiom needs and node:test does not have: a describe and it carrying Jest's modifiers, an expect with Jest's matcher names, a jest namespace for mock functions, spies and fake timers, and a loader that honours jest.mock under native ES modules. Everything Node already provides, the runner, the lifecycle hooks, the assertion error, the coverage collector, is used as it is.
It is not Jest and does not try to be. The matcher set is closed, there are no snapshots, no watch mode and no HTML coverage report, and the places where behaviour differs are listed in this document rather than papered over. What it offers is the familiar shape of a Jest spec on a runtime that ships with Node, with no test framework installed.
Key Features
The modifiers Jest users reach for,
.each,.only,.skip,.todo,.concurrentand.failing, plus thedonecallback and a trailing timeout, mapped ontonode:testTwenty-eight matcher names with Jest's semantics,
.not,.resolvesand.rejects, asymmetric matchers, andexpect.assertionsverified once the test settlesjest.fnandjest.spyOnrecord calls, results andthis, queue once-only values, and restore on demandjest.mock under native ES modules
The preload reads
jest.mockout of a spec before its imports link, so factories, partial mocks, automocks andjest.requireActualwork without a transpilerjest.useFakeTimersdrives Node's own fake clock, with the cascade-drainingrunAllTimers,clearAllTimersandsetSystemTimefilled in where Node has no primitiveOne preload copies a jsdom window onto the global for browser-facing suites; jsdom is an optional peer dependency and nothing else is installed
A
test.config.tsdeclares environments, setup files, specifier redirects, timeouts and coverage, andrunProjectTestsruns onenode --testper environmentCoverage that cannot look away
Per-environment lcov reports are merged per file, thresholds are judged on the merged totals, and a source file no test loaded fails the run instead of vanishing from the report
Zero runtime dependencies
Only Node built-ins at runtime, so the package adds nothing to a consumer's dependency tree beyond itself
TypeScript specs as written
Specs, configuration and setup files run through Node's own type stripping, extensionless specifiers and tsconfig path aliases resolve, and JSON imports need no attribute
Why Use @hyperfrontend/testing?
Fewer dependencies, same habits
Node ships a test runner, an assertion module, a module mocking facility, fake timers and a coverage collector. What it does not ship is the shape of a Jest spec, and that shape is what most teams have in their fingers. This package supplies the shape and nothing else, so the install is one package with no runtime dependencies instead of a framework, a transformer and a reporter.
A migration, not a rewrite
Suites written against Jest keep their describe blocks, matchers, mock functions and jest.mock declarations as written. The lifecycle hooks come from node:test, the configuration file changes shape, and the differences that remain are listed in Migrating from Jest rather than discovered one failing test at a time.
Coverage you can trust
Node's coverage omits files no test loaded and counts a re-evaluated module once per evaluation. The runner merges the lcov records per file, judges thresholds on the merged totals, and fails a run that left an included source file unmeasured, so a full-coverage gate guarantees what it says.
With thanks to Jest
Jest set the vocabulary this package speaks. expect(value).toEqual(...), jest.fn() and jest.mock('./module') read the way they do because Jest made them read that way for a decade of JavaScript developers, and the familiarity that gives a new team member is Jest's achievement, not this package's. Jest remains the fuller tool, with snapshots, a watch mode, custom matchers, an interactive runner and an ecosystem of reporters and environments. This package is for the case where Node's built-ins are enough and the familiar syntax is what you want to keep.
Who it is for
Teams moving suites written against Jest onto Node's built-in runner, and anyone who wants Jest-style assertions and mocks with no test framework installed. A project that depends on snapshot testing, expect.extend, watch mode or an HTML coverage report should keep Jest, or choose a framework that provides them.
Installation
npm install --save-dev @hyperfrontend/testing
The package needs Node.js 22.18 or later: the preload installs synchronous module hooks and loads TypeScript specs through Node's own type stripping, neither of which older releases provide. It declares no runtime dependencies.
A DOM is opt-in. Install jsdom alongside the package when a suite needs document; the /dom entry imports it, and nothing else does.
npm install --save-dev jsdom
Quick Start
A spec
import { afterEach, beforeEach, before as beforeAll, after as afterAll } from 'node:test'
import { describe, expect, it, jest } from '@hyperfrontend/testing'
import { createCart } from './cart'
jest.mock('./pricing', () => ({
priceOf: (sku: string) => (sku === 'tea' ? 3 : 5),
}))
describe('cart', () => {
let cart: ReturnType<typeof createCart>
beforeEach(() => {
cart = createCart()
})
it('totals the priced lines', () => {
cart.add('tea', 2)
expect(cart.total()).toBe(6)
})
it.each([
['tea', 3],
['coffee', 5],
])('prices %s at %d', (sku: string, price: number) => {
cart.add(sku, 1)
expect(cart.total()).toBe(price)
})
it('notifies subscribers once per change', () => {
const onChange = jest.fn()
cart.subscribe(onChange)
cart.add('tea', 1)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ total: 3 }))
})
})
Lifecycle hooks come straight from node:test, which already has them; its before and after are what Jest calls beforeAll and afterAll. Take describe and it from this package even in files that use no modifier: it is the package's it that resets the assertion counter and checks expect.assertions once the body settles, and a spec running on Node's own it would declare a count nobody verifies. Nothing is global: expect and jest are imports too.
Running with node --test
node --import @hyperfrontend/testing/hooks --test 'src/**/*.spec.ts'
The /hooks preload is what makes the spec above loadable: it resolves extensionless and aliased specifiers, applies specifier redirects, and reads the jest.mock calls out of each spec before its imports link. Add the /dom preload after it when a suite needs a browser-shaped global, and --test-only when a suite relies on .only:
node --import @hyperfrontend/testing/hooks \
--import @hyperfrontend/testing/dom \
--test 'src/**/*.browser.spec.ts'
Running from a configuration
A test.config.ts takes the place of a Jest configuration. Each environment is its own node --test run, selecting its own files and loading its own setup modules; the coverage settings apply to the project as a whole:
import type { TestConfig } from '@hyperfrontend/testing/runner'
const config: TestConfig = {
environments: [
{
name: 'node',
testMatch: ['src/**/*.spec.ts'],
testIgnore: ['**/*.browser.spec.ts'],
},
{
name: 'browser',
testMatch: ['src/**/*.browser.spec.ts'],
dom: true,
setupFiles: ['test.setup.ts'],
},
],
coverageInclude: ['src/**/*.ts'],
coverageExclude: ['src/**/index.ts'],
coverageThresholds: { lines: 100, branches: 95, functions: 100 },
moduleNameMapper: { '^@app/(.*)$': '<rootDir>/src/$1' },
testTimeout: 10_000,
}
export default config
A short script drives it. runProjectTests spawns the runs, merges the reports and judges them; the script decides what to print and what the exit code is:
import { runProjectTests } from '@hyperfrontend/testing/runner'
import config from './test.config.ts'
const outcome = runProjectTests(config, {
workspaceRoot: import.meta.dirname,
projectRoot: import.meta.dirname,
coverageDir: `${import.meta.dirname}/coverage`,
})
for (const row of outcome.coverageTable) console.log(row)
for (const failure of outcome.failures) console.error(failure)
process.exitCode = outcome.success ? 0 : 1
node run-tests.ts
The run leaves lcov.<environment>.info per environment and a merged lcov.info in the coverage directory, with paths relative to the workspace root so a coverage service can match them to the repository. A package script, a task runner target or a CI step can each be the driver; the package ships no command line of its own.
API Overview
The root entry is the spec surface. describe and it wrap their node:test namesakes and add what Node lacks: .each over an array of rows (an array row is spread across the parameters, anything else arrives as one argument, and the title takes %s, %d, %i, %f, %j, %o, %p, %# and %%), .only and .skip on both, and .todo, .concurrent and .failing on it. A body may take the done callback; a trailing number is the per-test timeout in milliseconds.
expect carries a closed set of matchers, small enough to list. Equality and identity: toBe, toEqual, toStrictEqual, toMatchObject. Presence and type: toBeDefined, toBeUndefined, toBeNull, toBeNaN, toBeTruthy, toBeFalsy, toBeInstanceOf. Numbers: toBeGreaterThan, toBeGreaterThanOrEqual, toBeLessThan, toBeLessThanOrEqual, toBeCloseTo.
Collections and strings: toHaveLength, toContain, toContainEqual, toMatch, toHaveProperty. Mocks: toHaveBeenCalled, toHaveBeenCalledTimes, toHaveBeenCalledWith, toHaveBeenLastCalledWith, toHaveBeenNthCalledWith. Errors: toThrow and its alias toThrowError, accepting a message fragment, a pattern, an error class, an error whose message must match, or an object shape.
Every matcher is reachable through .not, and through .resolves and .rejects, which await the subject first and return promises. The statics are the asymmetric matchers expect.any, expect.anything, expect.objectContaining, expect.arrayContaining, expect.stringContaining, expect.stringMatching and expect.closeTo, their negations under expect.not, and the counters expect.assertions and expect.hasAssertions.
A failed matcher throws Node's own AssertionError with its actual, expected and operator fields filled in, so reporters and editors treat it like any node:assert failure. The comparison behind the matchers is exported as equals and matchesSubset for code that needs the same answer outside an assertion.
jest is one object with three jobs. Mocks: jest.fn returns a MockFn whose mock record holds calls, results, instances, lastCall and invocationCallOrder, and which takes mockImplementation, mockReturnValue, mockResolvedValue, mockRejectedValue, each with a once-only variant, plus mockReturnThis, mockName, mockClear, mockReset and mockRestore; jest.spyOn replaces a method, or a getter or setter, with a mock that calls through until restored; jest.mocked narrows a value for the type checker; and clearAllMocks, resetAllMocks and restoreAllMocks reach every mock created in the process.
Timers: useFakeTimers installs Node's fake clock over setTimeout, setInterval, setImmediate and Date, taking doNotFake and now; advanceTimersByTime, advanceTimersByTimeAsync, runAllTimers, runOnlyPendingTimers, clearAllTimers, setSystemTime and useRealTimers drive it. Modules: jest.mock and jest.unmock are read out of the spec's source by the preload and have no runtime work left to do, jest.doMock registers a replacement for modules imported after it, jest.requireActual and jest.requireMock read the real module and the factory's product, and jest.resetModules makes later dynamic imports evaluate their target again.
The other entries are the machinery around a run, and the root entry never re-exports the two that act on import. /hooks is the node --import preload that installs the resolution hooks. /dom is the preload that installs the jsdom window. /reporter is Node's spec reporter minus the per-evaluation coverage summary. /runner holds runProjectTests, the pieces it is built from (buildRunPlan, withDefaults, BASELINE_COVERAGE_EXCLUDE) and the configuration types TestConfig, TestEnvironment, CoverageThresholds, RunContext and RunOutcome.
/coverage holds the lcov merge (mergeLcov, renderLcov), the arithmetic (fileTotals, overallTotals, percentOf, uncoveredRanges), the gate (findThresholdShortfalls, renderCoverageTable) and the completeness check (findUnmeasuredFiles).
Every option, type and signature is in the API reference.
Migrating from Jest
What stays
describe and it with .each, .only, .skip, .todo, .concurrent and .failing; expect with the matchers listed above, the asymmetric matchers, .not, .resolves, .rejects, expect.assertions and expect.hasAssertions; jest.fn, jest.spyOn, jest.mocked and the three *AllMocks resets; the fake timer controls; jest.mock, jest.unmock, jest.doMock, jest.requireActual, jest.requireMock and jest.resetModules; moduleNameMapper and setupFiles in the configuration.
What changes
- Imports. Nothing is global.
describe,it,expectandjestare imported from the package; there is notestalias, so importitunder that name if a suite uses it. The hooks come fromnode:test, withbeforeandafterstanding in forbeforeAllandafterAll, and a hook timeout is passed as{ timeout }rather than a trailing number. - Configuration. A
test.config.tsexporting aTestConfigreplaces the Jest configuration. Environments select files by glob instead of by docblock, a DOM is a per-environment flag, andsetupFilesAfterEnvbecomessetupFiles. The default timeout is 30 seconds and there is nojest.setTimeout; settestTimeoutor pass a trailing timeout to one test. - Coverage. The provider is V8 through Node's collector, not istanbul. There is no statements metric, so a statements threshold is carried by
lines; branch percentages read lower because V8 counts the untaken side of default parameters and optional chaining;/* istanbul ignore */does nothing and/* node:coverage ignore next N */is the replacement; there is no HTML report, only the per-file table and the lcov files. - Not provided. Snapshot matchers,
expect.extendand custom matchers, watch mode,jest.isolateModules,jest.retryTimesand the rest of thejestsurface not listed above. A spec that needs them is a spec to keep on Jest.
Behavioural differences
The places where the same call cannot behave exactly as it did under Jest, and what it does instead.
| Concern | What this package does |
|---|---|
| Coverage provider | V8 through Node's collector, not istanbul. /* istanbul ignore */ has no effect; use /* node:coverage ignore next N */. Branch percentages read lower because V8 counts default parameters and optional chaining. |
| Files no test loaded | Node omits them from the report. The completeness check fails the run and names each one, which is what a full-coverage threshold is meant to guarantee. |
| Re-evaluated modules | Node measures each evaluation as a file of its own, so a suite calling jest.resetModules would be judged once per generation. The gate merges the records per file and judges the thresholds itself. |
| statements | Node has no such metric. Carry a statements threshold as lines. |
it.todo | Any body is ignored, as under Jest. node:test on its own would run it. |
it.only | Honoured only when Node runs with --test-only; without the flag every test runs. runProjectTests does not pass it. |
expect.assertions | Verified when a body returns or its promise settles. A body that takes the done callback is not checked. |
| Fake timers | Only setTimeout, setInterval, setImmediate and Date are faked. process.nextTick, queueMicrotask, requestAnimationFrame and performance stay real. |
runAllTimers | Node's own drain runs only the queue as it stood when called. The queue is re-run until no timeout is outstanding, which is what Jest does, and abandoned after a thousand passes. runOnlyPendingTimers is the same call. |
clearAllTimers | Node has no primitive, so the clock is torn down and rebuilt at the same instant. |
advanceTimersByTimeAsync | Advances the clock, then yields one microtask turn so promise callbacks the timers scheduled settle before the caller asserts. |
| Timer delays | A delay below one millisecond is raised to one, as the real schedulers do. A spy on the scheduler still records what the caller passed. |
| Partial mocks | A factory naming only some exports leaves the rest reachable and real. Under Jest they were undefined, but under ES modules importing an undefined name is a link error that stops the whole file. |
| Automocks | jest.mock with no factory replaces every export with a fresh jest.fn. The module is loaded once to learn its export names. |
| Namespace spies | jest.spyOn on an ES module namespace cannot work: the namespace is sealed and importers bind the name at link time. Declare a replacement whose factory wraps the function instead. |
jest.doMock | Reaches modules imported after it, never ones already linked. Call jest.resetModules first, then declare the replacement, then import dynamically. |
Where jest.mock is read | Out of files recognised by name, .spec or .test before .ts, .tsx, .mts, .cts, .js, .jsx, .mjs or .cjs, and out of declared setup files. A declaration in a helper module a spec imports is not read. |
each tables | The array form only. The tagged template form is not supported. |
| Decorators | Node's type stripping does not parse them. A decorated class in a spec applies its decorator explicitly, the way the compiler would. |
| Test counting | A test that stubs process.stdout.write across an await makes node:test drop the preceding test from its counts. The exit code is unaffected, so the verdict stays correct. |
| HTML report | None. A run produces the per-file table, lcov.info with the merged records, and lcov.<environment>.info with each environment's raw report. |
Compatibility
Runs on
- Node.js>=22.18.0supported
- Browsersnot supported
- Web Workersnot supported
Needs Node.js 22.18 or later: the preload installs synchronous module hooks and loads TypeScript specs through Node's own type stripping, neither of which older releases provide.
Ships as
- ESMTree-shakeable
- CJSNode and older bundlers
Peer Dependencies
| Package | Version | Type |
|---|---|---|
| jsdom | >= 26.0.0 | Optional |
Only the /dom entry imports jsdom. A project that never preloads it never needs it installed.
Architecture Highlights§
Nothing is injected into the global scope: a spec imports what it uses. The loader, the mock registry and the module generation meet on globalThis under registered symbols, so the package's entries agree with each other however they are bundled. Depth belongs to the architecture guide, which covers the preload, the mock registry, the DOM preload, the runner and coverage pipeline, and the full table of behavioural differences from Jest with the reason for each.
API Reference§
Module Structure
6 modules · 58 total exports
Jest-style `describe`, `it`, `expect` and `jest` on Node's built-in test runner: the surface a spec imports.
ƒ Functions
toEqual and toStrictEqual do. Asymmetric matchers on either side decide the outcome themselves. Dates, regular expressions, errors, Maps, Sets, and typed arrays are compared by their meaningful contents rather than by enumerable properties.
Parameters
| Name | Type | Description |
|---|---|---|
§received | unknown | The value under test. |
§expected | unknown | The value it is compared against. |
§mode | EqualityMode | Whether undefined properties and prototypes are significant.(default: 'structural') |
Returns
booleanExample
Structural against strict comparison
equals({ id: 1, label: undefined }, { id: 1 }) // => true, an undefined property is ignored
equals({ id: 1, label: undefined }, { id: 1 }, 'strict') // => false
equals(new Map([[{ id: 1 }, 'a']]), new Map([[{ id: 1 }, 'a']])) // => true, keys are compared structurally tootoMatchObject does. Arrays must match in length and order; objects need only carry the expected keys.
Parameters
Returns
booleanExample
Accepting extra keys but not extra elements
matchesSubset({ id: 1, tags: ['a', 'b'], meta: { at: 5 } }, { tags: ['a', 'b'], meta: {} }) // => true
matchesSubset({ tags: ['a', 'b'] }, { tags: ['a'] }) // => false, arrays must match in length◆ Types
type AccessType = "get" | "set".resolves and .rejects.type AsyncAssertions = mappeddescribe surface, including the modifiers node:test does not provide.structural reproduces Jest's toEqual: undefined-valued own properties are ignored and prototypes are not compared, so a class instance can equal a plain object. strict reproduces toStrictEqual, where both of those differences are significant.type EqualityMode = "structural" | "strict"type Expectation = Assertions & ExpectationModesjest.useFakeTimers, narrowed to the two Node's own clock honours.it surface, including the modifiers node:test does not provide. The optional trailing number is Jest's per-test timeout in milliseconds; it overrides the runner's
--test-timeout default for that one test.jest namespace object, carrying the mock, spy, and timer controls the suites use. mock and requireActual are the shapes of calls that have already happened by the time a spec body runs. The loader reads them out of the spec's source and substitutes the module before the spec's imports are linked, so what remains here is the call the spec still makes at runtime, by which point the substitution has already happened.message describes the positive expectation. The negated form is derived from it, so a matcher never has to phrase both directions.jest.Mock, so replacing one with the other is a rename rather than a re-typing.type Mock = MockFn< TArgs, TReturn>type Mocked = mapped & TTargettype MockedFunction = MockFn< Parameters< TFunction>, ReturnType< TFunction>> & TFunctionjest.mocked hands back: a function becomes its mocked signature, anything else an object of mocked members.type MockedValue = conditionalfn.mock.done-callback style.type TestBody = ( done: ( error?: unknown) => void) => unknown● Variables
it, with the Jest modifiers node:test does not provide. Import this instead of
node:test's it only in files that need .each, .failing, .concurrent, the done callback, or expect.assertions. Everywhere else, importing straight from node:test keeps the spec closer to the platform.The coverage side of a run: lcov reports merged per file, the threshold gate, the per-file table, and the completeness check for source files no test loaded.
ƒ Functions
Parameters
| Name | Type | Description |
|---|---|---|
§file | FileCoverage | The file's merged counters. |
Returns
MetricTotalsExample
Reducing a file's counters
const file = {
path: 'src/double.ts',
lines: new Map([[1, 5], [2, 4], [3, 0]]),
branches: new Map([['2,0', 4], ['2,1', 0]]),
functions: new Map([['1,double', 5]]),
}
fileTotals(file) // => { lines: { found: 3, hit: 2 }, branches: { found: 2, hit: 1 }, functions: { found: 1, hit: 1 } }The comparison is against the project as a whole, which is what a Jest
global threshold meant.Parameters
| Name | Type | Description |
|---|---|---|
§files | unknown | The merged files. |
§thresholds | CoverageThresholds | The percentages the project requires. |
Returns
string[ ]Example
Judging the merged report against a project's thresholds
const measured = [...mergeLcov(reports).values()]
findThresholdShortfalls(measured, { lines: 99, branches: 96, functions: 97 })
// => ['80.00% line coverage does not meet threshold of 99%', '50.00% branch coverage does not meet threshold of 96%']
findThresholdShortfalls(measured, undefined) // => [], nothing declared means nothing to missfindUnmeasuredFiles( measured: unknown, projectRoot: string, include: string[ ], exclude: string[ ]): CompletenessReport
Node's coverage only reports files the run actually loaded, so a source file no test imports simply vanishes instead of landing at zero percent. That silently removes the guarantee a full-coverage threshold is supposed to give: a new, wholly untested file would not fail the build. Comparing the report against the include globs restores it.
Parameters
Returns
CompletenessReportExample
Catching a source file no spec imported
const measured = [...mergeLcov(['/repo/coverage/libs/example/lcov.node.info']).values()]
findUnmeasuredFiles(measured, '/repo/libs/example', ['src/**/*.ts'], ['**/*.spec.ts'])
// => { missing: ['src/orphan.ts'], covered: 4 }A file appears more than once whenever a module was evaluated more than once: after
jest.resetModules a later import re-runs the module body, and V8 measures each evaluation as a script of its own. Treating those as separate files counts every line of the module once per evaluation while crediting only the evaluation that ran it, which drags the percentages down in proportion to how often a suite reset. Combining them is what Jest's provider did implicitly, by accumulating into counters keyed by file rather than by script. Reports from different environments merge the same way, so a file exercised by one environment and merely imported by another is judged on both.
Anything a
node:coverage ignore comment covers is dropped rather than measured. Node omits an ignored line from the report but still writes out the branches and functions sitting on it, marked untaken, while counting them as covered in its own totals. Dropping them is both what makes the two agree and what istanbul ignore used to mean.Parameters
| Name | Type | Description |
|---|---|---|
§lcovPaths | unknown | Paths to the emitted lcov files. |
Returns
Map< string, FileCoverage>Example
Combining every environment's report into one entry per file
const files = mergeLcov(['/repo/coverage/libs/example/lcov.node.info', '/repo/coverage/libs/example/lcov.browser.info'])
files.get('src/double.ts')?.lines // => Map { 1 => 5, 2 => 4, 3 => 0 }, each count summed across records and reportsParameters
| Name | Type | Description |
|---|---|---|
§files | Iterable< | The merged files. |
Returns
MetricTotalsExample
Adding up a whole report
overallTotals(mergeLcov(reports).values()).lines // => { found: 5, hit: 4 }, the sum over every fileA metric with nothing to measure is complete, which is what Jest reported for a file holding no branches at all.
Returns
numberExample
Rendering a metric as a percentage
percentOf({ found: 4, hit: 3 }) // => 75
percentOf({ found: 0, hit: 0 }) // => 100, nothing to measure counts as completeParameters
| Name | Type | Description |
|---|---|---|
§files | unknown | The merged files, in any order. |
Returns
string[ ]Example
Printing the table a run ends with
console.log(renderCoverageTable([...mergeLcov(reports).values()]).join('\n'))
// file | line % | branch % | funcs % | uncovered lines
// -------------------------------------------------------------
// src/double.ts | 66.67 | 50.00 | 100.00 | 3
// src/other.ts | 100.00 | 100.00 | 100.00 |
// -------------------------------------------------------------
// all files | 80.00 | 50.00 | 100.00 |
// -------------------------------------------------------------The raw reports are what Node measured, one per environment and one record per evaluation. A consumer reading coverage off disk wants what the gate judged instead: one record per file, with the counts combined. Paths are written as given, so a caller that needs them anchored somewhere other than the project root rebases them first.
Parameters
| Name | Type | Description |
|---|---|---|
§files | Iterable< | The merged files, in any order. |
Returns
stringExample
Writing the merged report back out
const merged = mergeLcov(['coverage/lcov.node.info', 'coverage/lcov.browser.info'])
writeFileSync('coverage/lcov.info', renderLcov(merged.values()))
// one record per file, its counts combined, in the shape a coverage service readsParameters
| Name | Type | Description |
|---|---|---|
§file | FileCoverage | The file's merged counters. |
Returns
string[ ]Example
Collapsing missed lines into ranges
const file = { path: 'src/a.ts', lines: new Map([[1, 1], [5, 0], [6, 0], [9, 0]]), branches: new Map(), functions: new Map() }
uncoveredRanges(file) // => ['5-6', '9']◆ Types
The counters stay as maps rather than totals because merging is the whole point: two records for the same file have to be combined per line, per function, and per branch before any percentage means anything.
The preload that gives a suite a browser-shaped global: a jsdom window copied onto `globalThis` before the suite's own setup modules run.
ƒ Functions
This module is an
--import entry, loaded after the resolution hooks and before a project's own setup files, so a setup file may already assume document exists. pretendToBeVisual matches what the Jest environment this replaces asked for: it is what supplies requestAnimationFrame and makes the document report itself as visible.Example
Starting a spec from a fresh document
installDom()
document.body.append(document.createElement('main'))
document.querySelector('main') // => the element just appended, on a document no earlier spec has touchedThe preload that makes a Jest-style spec loadable by Node's test runner: path aliases, extensionless specifiers, `moduleNameMapper` redirects and `jest.mock` replacements.
ƒ Functions
moduleNameMapper redirects, extensionless TypeScript specifiers, and the module generation that backs jest.resetModules. Node resolves none of these itself. This module is the
--import entry, so it runs before any test code and imports its own dependencies by explicit path: the hooks it is about to install are not available to it yet.Example
Installing the hooks for a hand-run `node --test`
HF_TEST_WORKSPACE_ROOT=/repo node --import @hyperfrontend/testing/hooks --test src/double.spec.tsThe reporter a run names with `--test-reporter`: Node's own spec reporter, with the per-evaluation coverage summary held back so the merged table the runner prints is the only verdict a run shows.
ƒ Functions
Node reports each evaluation of a module as a file of its own, so a suite that calls
jest.resetModules gets one entry per generation and a summary that counts every line of the module once per evaluation. The runner prints the merged table instead, from the lcov report, and printing both would show two different answers for the same run.Parameters
| Name | Type | Description |
|---|---|---|
§source | AsyncIterable< | The test runner's event stream. |
Example
Naming the reporter on a hand-run `node --test`
node --import @hyperfrontend/testing/hooks --test --test-reporter=@hyperfrontend/testing/reporter --test-reporter-destination=stdout src/double.spec.tsThe runner behind a project's `test.config.ts`: one `node --test` run per environment, then the merged coverage report, the threshold gate and the completeness check.
ƒ Functions
buildRunPlan( config: TestConfig, environment: TestEnvironment, projectRoot: string, coverageDir: string): RunPlan
Coverage is always collected: every project used to inherit
collectCoverage: true from the shared preset, so making it conditional would quietly weaken the gate.Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration, with defaults already applied. |
§environment | TestEnvironment | The environment to run. |
§projectRoot | string | Absolute path to the project root. |
§coverageDir | string | Absolute path to the project's coverage output directory. |
Returns
RunPlanExample
Planning a project's only environment
const resolved = withDefaults(config)
const plan = buildRunPlan(resolved, resolved.environments[0], '/repo/libs/example', '/repo/coverage/libs/example')
spawnSync(process.execPath, plan.argv, { cwd: '/repo/libs/example', stdio: 'inherit' })file: URL Node loads on every platform.Parameters
| Name | Type | Description |
|---|---|---|
§path | string | Absolute path to a module. |
Returns
stringfile: address Node accepts as a specifier.Example
Rendering a preload path Node accepts on every platform
moduleUrl('/repo/libs/example/test.setup.ts') // => 'file:///repo/libs/example/test.setup.ts'
// on Windows the same call turns 'C:\\repo\\test.setup.ts' into 'file:///C:/repo/test.setup.ts', where the bare path would parse as a URL with scheme 'c:'Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration. |
§context | RunContext | Where the run happens and where coverage is written. |
Returns
RunOutcomeExample
Running a project and reporting its verdict
const outcome = runProjectTests(config, {
workspaceRoot: '/repo',
projectRoot: '/repo/libs/example',
coverageDir: '/repo/coverage/libs/example',
})
console.log(outcome.coverageTable.join('\n'))
if (!outcome.success) {
console.error(outcome.failures.join('\n'))
process.exitCode = 1
}Node has no way to exclude anything from a
--test glob, so an environment that ignores files cannot express its selection as a pattern and has it expanded here instead. An environment that ignores nothing keeps its globs, which leaves discovery to Node and the argument list readable.Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
string[ ]Example
Expanding globs only when something is ignored
const environment = { name: 'node', testMatch: ['src/**/*.spec.ts'] }
selectSpecFiles(environment, '/repo/libs/example') // => ['src/**/*.spec.ts'], left to Node
selectSpecFiles({ ...environment, testIgnore: ['**/*.browser.spec.ts'] }, '/repo/libs/example')
// => ['src/double.spec.ts', 'src/other.spec.ts'], expanded here because Node cannot exclude files from a globmoduleNameMapper for the resolution hooks, which read it from the environment because they run in a child process.Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The project's configuration. |
Returns
stringExample
Rendering the value of `HF_TEST_MODULE_MAP`
serialiseModuleMap({ ...config, moduleNameMapper: { '^\\./real$': '<rootDir>/src/stub.ts' } })
// => '[["^\\\\./real$","<rootDir>/src/stub.ts"]]'
serialiseModuleMap(config) // => undefined when no mapping is declaredThe hooks read
jest.mock out of a file's source as it loads, and they only read it from files they can recognise. A spec is recognisable by its name; a setup module is not, so the runner has to name them.Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
stringExample
Rendering the value of `HF_TEST_SETUP_FILES`
serialiseSetupFiles({ name: 'node', testMatch: ['src/**/*.spec.ts'], setupFiles: ['test.setup.ts'] }, '/repo/libs/example')
// => '["/repo/libs/example/test.setup.ts"]'Parameters
| Name | Type | Description |
|---|---|---|
§environment | TestEnvironment | The environment being run. |
§projectRoot | string | Absolute path to the project root. |
Returns
string[ ]Example
Anchoring setup modules to the project
setupPaths({ name: 'node', testMatch: ['src/**/*.spec.ts'], setupFiles: ['test.setup.ts'] }, '/repo/libs/example')
// => ['/repo/libs/example/test.setup.ts']withDefaults( config: TestConfig): Required< Pick< TestConfig, "coverageExclude" | "testTimeout">> & TestConfig
Parameters
| Name | Type | Description |
|---|---|---|
§config | TestConfig | The configuration as authored. |
Returns
Required< Pick< TestConfig, "coverageExclude" | "testTimeout">> & TestConfigExample
Seeing what a minimal configuration resolves to
const resolved = withDefaults({ environments: [{ name: 'node', testMatch: ['src/**/*.spec.ts'] }], coverageInclude: ['src/**/*.ts'] })
resolved.testTimeout // => 30000
resolved.coverageExclude // => BASELINE_COVERAGE_EXCLUDE, with a project's own exclusions appended after it◆ Types
Node measures lines, branches, and functions. It has no statements metric, so a project's former statements threshold is carried by
lines.jest.config.ts used to hold.A project with both Node-only and DOM-facing suites declares two, each selecting its own files and loading its own setup.
● Variables
Related reading§
- Architecture
Architecture
How @hyperfrontend/testing is put together, and why.
- Getting started
Getting Started
Set HyperFrontend up and embed a first feature.
- Architecture
Architecture Guide
How the packages fit together.
No guides cover @hyperfrontend/testing yet.Request one

