@hyperfrontend/testing§

A Jest-style spec, cart.spec.ts, types itself onto a sheet line by line, each call opening with its package chip; as each line lands a wire draws across to the Node primitive it rests on, describe and it to node:test, expect to node:assert with a count rising to 2, jest.mock to module.registerHooks and jest.useFakeTimers to mock.timers, each label lighting as its wire arrives; the verdict returns along the test's wire as a green check, the runner's coverage figure counts up to line 100.00, branch 95.65 and funcs 100.00, and a tick lands beside itA Jest-style spec, cart.spec.ts, types itself onto a sheet line by line, each call opening with its package chip; as each line lands a wire draws across to the Node primitive it rests on, describe and it to node:test, expect to node:assert with a count rising to 2, jest.mock to module.registerHooks and jest.useFakeTimers to mock.timers, each label lighting as its wire arrives; the verdict returns along the test's wire as a green check, the runner's coverage figure counts up to line 100.00, branch 95.65 and funcs 100.00, and a tick lands beside it

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

  • Jest-style describe and it

    The modifiers Jest users reach for, .each, .only, .skip, .todo, .concurrent and .failing, plus the done callback and a trailing timeout, mapped onto node:test

  • The expect you already know

    Twenty-eight matcher names with Jest's semantics, .not, .resolves and .rejects, asymmetric matchers, and expect.assertions verified once the test settles

  • Mock functions and spies

    jest.fn and jest.spyOn record calls, results and this, queue once-only values, and restore on demand

  • jest.mock under native ES modules

    The preload reads jest.mock out of a spec before its imports link, so factories, partial mocks, automocks and jest.requireActual work without a transpiler

  • Fake timers on Node's clock

    jest.useFakeTimers drives Node's own fake clock, with the cascade-draining runAllTimers, clearAllTimers and setSystemTime filled in where Node has no primitive

  • Optional DOM

    One preload copies a jsdom window onto the global for browser-facing suites; jsdom is an optional peer dependency and nothing else is installed

  • Config-driven runner

    A test.config.ts declares environments, setup files, specifier redirects, timeouts and coverage, and runProjectTests runs one node --test per environment

  • Coverage 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, expect and jest are imported from the package; there is no test alias, so import it under that name if a suite uses it. The hooks come from node:test, with before and after standing in for beforeAll and afterAll, and a hook timeout is passed as { timeout } rather than a trailing number.
  • Configuration. A test.config.ts exporting a TestConfig replaces the Jest configuration. Environments select files by glob instead of by docblock, a DOM is a per-environment flag, and setupFilesAfterEnv becomes setupFiles. The default timeout is 30 seconds and there is no jest.setTimeout; set testTimeout or 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.extend and custom matchers, watch mode, jest.isolateModules, jest.retryTimes and the rest of the jest surface 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.

ConcernWhat this package does
Coverage providerV8 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 loadedNode 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 modulesNode 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.
statementsNode has no such metric. Carry a statements threshold as lines.
it.todoAny body is ignored, as under Jest. node:test on its own would run it.
it.onlyHonoured only when Node runs with --test-only; without the flag every test runs. runProjectTests does not pass it.
expect.assertionsVerified when a body returns or its promise settles. A body that takes the done callback is not checked.
Fake timersOnly setTimeout, setInterval, setImmediate and Date are faked. process.nextTick, queueMicrotask, requestAnimationFrame and performance stay real.
runAllTimersNode'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.
clearAllTimersNode has no primitive, so the clock is torn down and rebuilt at the same instant.
advanceTimersByTimeAsyncAdvances the clock, then yields one microtask turn so promise callbacks the timers scheduled settle before the caller asserts.
Timer delaysA 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 mocksA 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.
Automocksjest.mock with no factory replaces every export with a fresh jest.fn. The module is loaded once to learn its export names.
Namespace spiesjest.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.doMockReaches modules imported after it, never ones already linked. Call jest.resetModules first, then declare the replacement, then import dynamically.
Where jest.mock is readOut 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 tablesThe array form only. The tagged template form is not supported.
DecoratorsNode's type stripping does not parse them. A decorated class in a spec applies its decorator explicitly, the way the compiler would.
Test countingA 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 reportNone. 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

PackageVersionType
jsdom>= 26.0.0Optional

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§

View:
Organized by entry point

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.

2 fn21 type4 var

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.

9 fn4 type

The preload that gives a suite a browser-shaped global: a jsdom window copied onto `globalThis` before the suite's own setup modules run.

1 fn

The preload that makes a Jest-style spec loadable by Node's test runner: path aliases, extensionless specifiers, `moduleNameMapper` redirects and `jest.mock` replacements.

1 fn

The 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.

1 fn

The 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.

8 fn6 type1 var

No guides cover @hyperfrontend/testing yet.Request one