Testing
fabr test compiles a target’s tests and runs them with one of three test frameworks: Node.js’s
built-in node:test (the default), jest, or vitest. Fabr compiles the tests itself and starts the
test processes itself, so none of the frameworks reads its own configuration file. What you’d
normally put in jest.config.js or vitest.config.ts either comes from your build file or has a
fixed value, as described on this page.
If you’re converting an existing suite, go to the section for your framework: jest or vitest. Each lists what to declare, what works, and what differs from running that framework’s own command.
The three frameworks
Section titled “The three frameworks”node (default) |
jest |
vitest |
|
|---|---|---|---|
| Runs your tests with | Node.js’s node:test |
jest’s own test framework and libraries (jest 29 or 30) | the vitest you declare (4.1 or later) |
| Test API | describe, it and hooks as globals; no built-in assertions |
jest’s globals: describe, it, expect, jest |
imported from "vitest" |
You add to test_deps |
an assertion library, if you want one | @types/jest; jsdom for DOM tests |
vitest; jsdom for DOM tests |
| Tests are compiled to | CommonJS | CommonJS | ES modules |
| DOM tests (jsdom) | no | yes | yes |
| Module mocking | node:test’s mock |
jest.mock, __mocks__ directories |
vi.mock |
| Stylesheet and image imports in tested code | work | work | work |
| Needs, as the Node.js version | 23.4 or later for snapshots | 22.15 or later | 22.15 or later |
| Configuration file | none | jest.config.js is not read |
vitest.config.ts is not read |
Choose a framework for the whole project with the JS_TEST_FRAMEWORK setting, or for one target
with its test_framework property:
JS_TEST_FRAMEWORK = jest;
js_package legacy { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_framework = node; # this target only}Declaring tests
Section titled “Declaring tests”A js_package carries its own tests. tests names the test files and test_deps the packages
only the tests need:
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; deps = @npm:lodash:4.17.21; test_deps = @npm:chai:4.3.6 @npm:@types/chai:4.3.1;}A test imports the code it tests by relative path, or by the package’s own name. The tests run
against the package as fabr build builds it: the test files are compiled against the type
declarations that build emitted, so only the tests themselves are type-checked and compiled, and
an edit to a test doesn’t recompile the package. Where the package is built in a different module
format from the one the test framework loads, fabr builds it a second time in that format for its
tests. The tests can use everything in deps, compile_deps and test_deps, and find the
package’s resources where the package ships them. Nothing listed under tests or test_deps
becomes part of the built package, and test_deps are not added to its package.json.
For tests that don’t belong to a package, such as an end-to-end suite, declare a js_test. It
takes the same settings under shorter names:
js_package |
js_test |
What it is |
|---|---|---|
tests |
tests |
The test files. |
deps and test_deps |
deps |
Packages and supporting sources the tests use. |
compile_deps |
compile_deps |
Packages the tests are compiled against whose flags don’t change how they run. |
test_framework |
framework |
node, jest or vitest. |
test_env |
env |
Environment variables for the test processes. |
test_resources |
resources |
Files the tests read at run time. |
test_expectations |
expectations |
Snapshot files. |
js_test e2e { tests = test:**/*.test.ts; deps = test:**/*.ts mylib @npm:@types/node:22.15.3;}Every file in tests is run as a test
Section titled “Every file in tests is run as a test”Fabr has no naming rule like jest’s testMatch: each source file that tests matches is run as a
test file, whatever it’s called. Keep helpers, fixtures written in TypeScript, and mocks out of
that pattern, and supply them another way:
- In a
js_package, a helper that lives amongsrcsis part of the package, so the tests can import it. To keep it out of the built package, list it intest_depsinstead: a source file there is compiled with the tests, never run as a test, and never shipped. - In a
js_test, list helpers indeps, as the example above does withtest:**/*.ts. A file matched by bothdepsandtestsis still a test.
Under jest and vitest, a helper that is matched by tests fails with
This test file registered no tests.
Running tests
Section titled “Running tests”fabr test mylib # compile and run mylib's testsfabr test -w mylib # ...and again whenever a source changesfabr test -u mylib # ...and record or update snapshotsfabr test -t 'dates parse' mylib # ...but only the tests whose names matchfabr test --coverage mylib # ...and measure code coverageA passing run prints one line per target, such as mylib: 12 tests passed (1 skipped).
-t takes a JavaScript regular expression, matched against each test’s full name: the names of
the describe blocks it is in and its own, joined by spaces, as dates parse iso for
it("parse iso") inside describe("dates"). Each framework does the matching, so its own rules
apply: jest ignores case, and node:test also matches a test’s own name alone. Under jest and
vitest the tests not selected are counted as skipped. The summary ends (filtered), and if no
test in any target matches, the run stops with No test's name matches the pattern '…'. The
pattern is part of what is cached, so a filtered run and a full one are cached separately.
A failing run prints a line for each failed test and fails the command, naming the test file by its path in
your project, as in greets by name (src/greet.test.ts): 'Hello, x!' == 'Hello, world!'. Under
jest and vitest the line is followed by the detail the framework itself shows, such as a matcher’s
diff and the stack trace, with stack frames naming your TypeScript sources.
Some things differ from running a test framework’s own command:
- A run is cached, passing or failing. If nothing the tests depend on has changed,
fabr testreports the previous result without running them again; a failing run’s failures are reported again, and the command fails again, until something they depend on changes. To run the failed test files again anyway, as for a flaky test, usefabr test --retry-failed. The exit status is 1 whenever a test failed. - Only the test files a change can reach run again. Each test file’s run records what it
loaded and read; after a change, a file runs again if it read something that changed, or failed
last time, and the rest keep their previous results, their coverage included under
--coverage. Adding or deleting a file runs every test file of the target again. A test file that starts a process or a worker thread runs again on every change, since what those read can’t be recorded. - You select tests by target and by name, not by file.
fabr test -tselects by name, asjest -tdoes; there is no equivalent of passing a file path, and arguments aren’t passed through to the framework. To run a subset routinely, give it its own target. - Each test file runs in its own process, in parallel up to the number of processors, so test files share no state. Framework options that control workers or isolation don’t apply.
- A test file’s process ends once its results are reported, even if the tests left a timer,
a server or an open connection behind, as with jest’s
--forceExit. - A test times out after 120 seconds under every framework, where jest and vitest default to
5 seconds. A timeout you set on an individual test still applies, as does
jest.setTimeout.
The test environment
Section titled “The test environment”Tests run in a staged copy of the compiled tree, not in your project directory, and see only what the target declares.
The Node.js version
Section titled “The Node.js version”Tests run on the Node.js release that the
NODE setting names, not on the node installed
on your machine, and so does a program a test starts through process.execPath. The version
requirements on this page are requirements on that release; the default, Node.js 24.22.1, meets
them all. Changing NODE runs the tests again.
Environment variables
Section titled “Environment variables”A test process starts with an empty environment: no PATH, no HOME, no CI, and no NODE_ENV
(jest and vitest normally set NODE_ENV=test). Declare the variables your tests need with
test_env:
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_env = { NODE_ENV = test; TZ = UTC; NODE_OPTIONS = "--max-old-space-size=512"; }}Because there is no PATH, a test that starts another program must name it by absolute path, for
example process.execPath for Node.js itself.
Files the tests read
Section titled “Files the tests read”List data files the tests open at run time in test_resources. They are placed alongside the
compiled tests under the same names they have in your source tree, and the working directory of
each test process is the root of that tree. With srcs = src:**/*.ts and
test_resources = src:fixtures/**, a test finds src/fixtures/users.json either way:
fs.readFileSync("fixtures/users.json"); // relative to the working directoryfs.readFileSync(path.join(__dirname, "fixtures/users.json")); // relative to a test in src/A file that isn’t declared isn’t in that tree, so a path relative to the test or to the working directory won’t find it.
Stylesheets and other assets
Section titled “Stylesheets and other assets”Tested code can import stylesheets and images as it does for a bundler. Under every framework these imports work without configuration:
- A css-module that is one of the target’s own sources is built as it is for the package, so
styles.cardTitlein a test is the real scoped class name, such ascard-title_FCiirPWT. Assert onstyles.cardTitle, not on a literal class name. - Any other stylesheet import (
.css,.scss,.sass,.less,.styl) yields an object whose every property is its own name, sostyles.cardTitleis"cardTitle". This is what jest’sidentity-obj-proxyprovides. - An image, font or media file yields its file name, such as
"logo.png".
The file need not be in the test’s tree: an import of a stylesheet or image that isn’t declared
yields the same stub. Under jest, a jest.mock of the same path takes precedence.
How tests find their packages
Section titled “How tests find their packages”By default the test processes find the packages they load in a node_modules directory that fabr
writes into the test install, laid out as pnpm lays one out. With the layout/pnp flag in a
target’s deps or test_deps, they resolve them instead through a Yarn
PnP-style table over fabr’s cache, and no node_modules is
written, which makes a large install much quicker to set up:
js_package app { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_deps = layout/pnp @npm:jsdom:26.1.0;}Under layout/pnp, a package is only found by being imported or required. Code that finds a
file by looking in node_modules itself (reading node_modules/x/package.json directly, or
walking up from its own directory to a sibling package) finds nothing, as does a program a test
starts. The same goes for a native addon whose binary is linked against a library in a sibling
package by a relative path (sharp, whose @img/sharp-<platform> binary looks for
@img/sharp-libvips-<platform> beside it): each package is its own directory in fabr’s cache, so
the library is not where the binary expects it and the addon fails to load. A package with peer
dependencies is loaded from a path of its own for each set of peers it
is given, which exists only for imports, so the package reading one of its own files through fs
(fs.readFileSync(path.join(__dirname, "template.html"))) finds nothing there either.
layout/node_modules asks for the tree explicitly. --coverage needs the tree, so a
target with layout/pnp can’t be measured.
DOM tests
Section titled “DOM tests”To run a target’s tests in a browser-like environment, add the dom flag to its deps or
test_deps, and jsdom to its test_deps:
js_package widgets { srcs = src:**/*.tsx; tests = src:**/*.test.tsx; deps = @npm:react:19.1.0; test_deps = dom @npm:jsdom:26.1.0 @npm:@types/jest:30.0.0; test_framework = jest;}The flag does two things: it adds the DOM type declarations to the compile (TypeScript’s dom
and dom.iterable libraries), and it runs the tests under jsdom. Put it in deps when the package’s own sources use DOM APIs, and in test_deps when
only the tests do. The node framework has no DOM environment; a target with the dom flag needs
jest or vitest.
Code that runs under node but contains code for a browser, such as the callbacks a Puppeteer or
Playwright script passes to page.evaluate, needs the DOM types without a DOM to run in. Put the
flag in compile_deps instead, on a js_package or a js_test: the tests are compiled against
the DOM and run under node.
js_test recorder_tests { tests = src:**/*.test.ts; deps = src:**/!(*.test).ts @npm:puppeteer:24.10.0 @npm:@types/jest:30.0.0; compile_deps = dom; framework = jest;}Under the jest framework, a jsdom test has no setImmediate, clearImmediate, MessageChannel
or MessagePort, as under jest itself, so code that picks a scheduler by what exists (React does)
behaves as it does under jest.
Both jest and vitest also accept their usual per-file comment, @jest-environment jsdom or
// @vitest-environment jsdom, which overrides the environment for that file. The comment
doesn’t add the DOM types, so a file that names document or window directly still needs the
flag to compile.
A setup file
Section titled “A setup file”A source file named setupTests (setupTests.ts, setupTests.js) at the root of the target’s
source tree is loaded in every test process before the test file, after the framework’s globals
exist. It is the equivalent of jest’s setupFilesAfterEnv and vitest’s setupFiles, limited to
one file. With srcs = src:**/*.ts, the root is src/, so the file is src/setupTests.ts.
The file has to be compiled with the tests without being one of them:
- Don’t let the
testspattern match it. - In a
js_package, list it intest_deps(test_deps = src:setupTests.ts …) so that it isn’t shipped with the package. In ajs_test, list it indeps.
A setupTests file in a subdirectory is an ordinary source and isn’t loaded automatically. Two at
the root is an error.
Snapshots
Section titled “Snapshots”All three frameworks keep snapshots where jest does, in __snapshots__/<test file>.snap beside
the test file, for example src/__snapshots__/render.test.ts.snap. Declare them as the target’s
test_expectations:
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_expectations = src:**/__snapshots__/*.snap;}Declare them there and not in srcs: a snapshot file among a package’s srcs is shipped with the
package and can’t be updated.
An ordinary run only checks. A test whose snapshot is missing fails, where jest and vitest would record it and pass (outside CI). To record new snapshots or update changed ones, run:
fabr test -u mylibIf the tests pass, fabr writes the new and changed snapshot files into your source tree and prints
Updated <file> for each. A run with failing tests writes nothing.
Two limits apply:
- Inline snapshots can be checked but not written.
toMatchInlineSnapshot()compares against a value already in your source, but fabr can’t record or update one, because the test that runs is the compiled file. UsetoMatchSnapshot(). - Obsolete snapshots aren’t reported. A snapshot whose test no longer exists is ignored in an
ordinary run, and dropped from its file the next time
-urewrites that file. Fabr doesn’t delete a.snapfile whose test file is gone.
Coverage
Section titled “Coverage”fabr test --coverage measures which code the tests ran, under every framework. It prints one line
for all the targets tested, such as Coverage: lines 91.23% (104/114), functions 100.00% (32/32), branches 85.47% (100/117), and writes an lcov report to coverage/lcov.info, or to
<dir>/lcov.info with --coverage=<dir>, saying where it wrote it. The report names each source
by its path in your project, which is what Codecov, Coveralls, SonarQube and editor coverage
gutters expect.
What is measured is the target’s own sources, other than its tests, and every package built in your project that the tests run with: a library tested only through another target’s tests gets credit for them, and a file the tests never load is reported as not run at all. Packages from a registry aren’t measured. Testing several targets together reports their coverage as one, a source counted once however many targets measured it.
Coverage comes from Node.js’s own V8 coverage, so nothing is instrumented and the tests run as they otherwise would. Lines, functions and branches are counted as istanbul counts them (jest’s default and nyc’s), with some differences:
- What is measured is the code as compiled for the target’s
JS_TARGET. Syntax that an older target turns into conditionals, such as?.and??, counts as branches only where it is compiled that way. - A parameter’s default value isn’t counted as a branch. V8 doesn’t record whether a default was used.
A source that isn’t a file in your project, such as one generated by another target, is left out
of the report, with the warning Left N generated source(s) out of the coverage report, having no source path, and doesn’t count towards the totals.
A run with --coverage is a different build from one without it: it is cached separately, and
the code under test is compiled with source maps even in a release build. The tools fabr runs,
such as the compiler and the test runner, are never measured.
node:test
Section titled “node:test”The default framework runs your tests with Node.js’s built-in test runner. It needs no packages, so it works in a project with no test dependencies at all.
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; deps = @npm:@types/node:22.15.3; test_deps = @npm:chai:4.3.6 @npm:@types/chai:4.3.1;}import { expect } from "chai";import { greet } from "./index";
describe("greet", () => { it("greets by name", () => { expect(greet("world")).to.equal("Hello, world!"); });});Fabr defines these as globals, with their types, in every test file: describe, it, test,
before, after, beforeEach and afterEach, plus beforeAll and afterAll as other names for
before and after. They are node:test’s own functions, and you can import them, or anything
else, from node:test instead. The types come from @types/node, which the target needs in its
deps.
There is no expect. Use node:assert, or add an assertion library such as chai to test_deps.
For mocks, use mock from node:test.
Snapshots are node:test’s, taken through the test context:
import type { TestContext } from "node:test";
it("renders the greeting", (t: TestContext) => { t.assert.snapshot(render("world"));});What to know:
- Snapshots need Node.js 23.4 or later as the
NODErelease. With an earlier one,fabr test -ufails withRecorded snapshots need node 23.4 or later (this is 22.15.0), so there is nothing to update. --test-onlyisn’t supported, soit.onlyanddescribe.onlyhave no effect. Node.js honoursonlywhen it is started with that flag, and fabr has no way to pass it, so the other tests still run. Useit.skip, or{ skip: true }, on the tests you want left out.- No DOM environment. See DOM tests.
- Tests are compiled to CommonJS, whatever format the package itself is built in.
importstatements work as usual;import.metaand top-levelawaitaren’t available in test files.
Setting the framework to jest runs your tests with jest’s own test framework and libraries:
describe/it/expect, matchers, mock functions, fake timers and snapshots all behave as they
do under jest, because they are jest’s code. What fabr replaces is everything around them. It
compiles the tests, starts the processes, and loads modules through Node.js instead of jest’s
module loader. So jest’s command line and configuration file aren’t involved.
What to declare
Section titled “What to declare”JS_TEST_FRAMEWORK = jest;
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_expectations = src:**/__snapshots__/*.snap; test_deps = @npm:@types/jest:30.0.0 @npm:@types/istanbul-lib-report:3.0.3? @npm:@types/yargs-parser:21.0.3? @npm:ansi-styles:4.1.0? @npm:ansi-styles:5.2.0? @npm:picomatch:2.3.1? @npm:picomatch:4.0.2?;}@types/jestsupplies the types of the globals. Without it the tests don’t compile.- The entries ending in
?are needed by the packages@types/jestdepends on. Two of them are required without a minimum version, so nothing selects one, and two are needed at two versions at once. If you leave any out, or a version has moved on, the build stops and prints the exact lines to add. Dependencies explains the marker. In a project with several test targets, put these in a catalog once. - jest itself is not something you declare. Fabr supplies it, at the version named by the
JESTsetting (@npm:jest:30.3.0by default). SetJEST = @npm:jest:29.7.0;to use another; jest 29 and 30 are supported. - Add
jsdomtotest_deps, with thedomflag, for DOM tests.
The jest framework needs Node.js 22.15 or later as the
NODE release.
Coming from jest.config.js
Section titled “Coming from jest.config.js”No jest configuration is read: not jest.config.js, and not the jest key of a package.json.
| jest option | In fabr |
|---|---|
testMatch, testRegex, roots |
The target’s tests pattern. |
preset: "ts-jest", transform |
Not needed: fabr compiles the tests with TypeScript before they run. Other transformers can’t be added. |
testEnvironment |
node, or jsdom with the dom flag. Per file, the @jest-environment comment. Custom environment modules aren’t loaded. |
testEnvironmentOptions |
Per file only, with the @jest-environment-options comment. |
setupFilesAfterEnv |
One file, by name: setupTests. |
setupFiles |
No equivalent. Use setupTests, which runs after the framework is installed. |
moduleNameMapper for stylesheets and images |
Built in; see stylesheets and other assets. |
moduleNameMapper for path aliases |
No equivalent. Imports resolve as described in Module resolution. |
testTimeout |
Fixed at 120 seconds; set it per test, or call jest.setTimeout in setupTests. |
clearMocks, resetMocks, restoreMocks |
No equivalent. Call jest.clearAllMocks() and the others from an afterEach in setupTests. |
globalSetup, globalTeardown, reporters, resolver, runner, globals |
Not supported. |
collectCoverage |
fabr test --coverage; see Coverage. |
coverageThreshold, coverageReporters and the other coverage options |
Not supported. The report is always lcov. |
What works
Section titled “What works”- The globals
describe,it,test,expect, the hooks, and their variants:.each,.only,.skip,.todo,.failing,.concurrent. They can also be imported from@jest/globals..onlyapplies within its own file, as under jest. jest.fn,jest.spyOn,jest.mocked,jest.replaceProperty, andclearAllMocks,resetAllMocksandrestoreAllMocks.jest.mock, with or without a factory, hoisted above the file’s imports as under jest. Alsojest.doMock,jest.unmock,jest.requireActual,jest.requireMock,jest.createMockFromModule,jest.resetModules,jest.isolateModules, automocking, and{ virtual: true }.__mocks__directories: one at the root of the source tree mocks the npm package of the same name automatically, and one beside a module is used by ajest.mockof that module that has no factory. The mock files must be compiled with the tests, so match them withsrcsortest_deps, nottests.- Fake timers (
jest.useFakeTimers,jest.advanceTimersByTime,jest.setSystemTimeand the rest). - Snapshots with
toMatchSnapshot(); see Snapshots. - The
@jest-environmentand@jest-environment-optionscomments at the top of a test file. - Packages published only as ES modules can be imported from tests and from the code under test
without a
transformIgnorePatternsentry, and ajest.mockof a module applies to imports made from inside such packages too.
What differs from jest
Section titled “What differs from jest”setImmediateandprocess.nextTickaren’t faked by default.jest.useFakeTimers()leaves them real. To fake them as jest does, passjest.useFakeTimers({ doNotFake: [] }).- A module you mock must be one the test can resolve.
jest.mock("x")findsxfrom the test file, as an import there would, and a test resolves only the packages its target declares. Mocking a package your code reaches only through another one, such as the SDK a configuration library loads, fails withCannot find module 'x', where under npm or pnpm it resolved because the package was hoisted into the rootnode_modules. Add the package totest_deps: the mock then also applies where the other package imports it, as long as both resolve to the same version. - Variables used in a
jest.mockfactory needn’t be namedmock…. Jest’s Babel plugin rejects a factory that refers to other outer variables; fabr doesn’t apply that rule, so code that jest rejects for this reason runs. - Not available:
jest.unstable_mockModule,jest.unstable_unmockModule,jest.onGenerateMock, and legacy fake timers (jest.runAllImmediates). Calling one fails the test with a message saying it isn’t supported. NODE_ENVisn’t set totest. Set it intest_envif your code reads it.- Tests must use jest’s globals or
@jest/globals. A file that importsdescribeanditfromnode:testregisters nothing with jest and fails withThis test file registered no tests.
vitest
Section titled “vitest”Setting the framework to vitest runs your tests with the vitest package you declare. Fabr
compiles the tests to ES modules and has vitest load them with Node.js’s own module loader, so
Vite takes no part: there are no Vite plugins and no Vite transforms.
What to declare
Section titled “What to declare”JS_TEST_FRAMEWORK = vitest;
js_package mylib { srcs = src:**/*.ts; tests = src:**/*.test.ts; test_expectations = src:**/__snapshots__/*.snap; test_deps = @npm:vitest:5.0.3 @npm:@types/deep-eql:4.0.2?;}vitest, version 4.1 or later, intest_deps. Earlier versions can’t load tests without Vite.- Node.js 22.15 or later as the
NODErelease. @npm:@types/deep-eql:4.0.2?is needed because vitest’s type declarations require that package without a minimum version, so nothing else selects one. If it’s missing, the build prints the line to add.- With vitest 4.1, also name a
viteversion, such as@npm:vite:6.4.0. Vitest 4.1 accepts any Vite from 6.0.0, fabr selects the lowest version that satisfies a range, and Vite 6.0.0 fails at startup withTypeError: Cannot read properties of undefined (reading 'length'). Vitest 5 needs noviteentry. - Add
jsdomtotest_deps, with thedomflag, for DOM tests.
Import the test API in each file. Vitest’s globals option is off and can’t be turned on:
import { describe, it, expect, vi } from "vitest";What works
Section titled “What works”describe,it,test,expect, the hooks, andvi.fn,vi.spyOnand fake timers.vi.mock, hoisted above the file’s imports.- Snapshots with
toMatchSnapshot(); see Snapshots. - A
setupTestsfile. - The
// @vitest-environment jsdomcomment at the top of a test file.
What differs from vitest
Section titled “What differs from vitest”- No configuration file is read.
vitest.config.ts,vite.config.tsand the workspace file are ignored, and there’s no way to pass an option. Reporters, pools,globals,aliasand the rest keep fabr’s values. - Nothing Vite does to an import happens. These fail, at compile time or when the test loads:
- Vite plugins, including framework plugins such as
@vitejs/plugin-react. JSX itself is fine, because fabr compiles it. resolve.aliasandtsconfigpath aliases. Imports resolve as described in Module resolution.import.meta.env,import.meta.globandimport.meta.hot.?raw,?urland?workerimports. Plain stylesheet and image imports work; see Stylesheets and other assets.
- Vite plugins, including framework plugins such as
- Not available: vitest’s own coverage (use
--coverage), benchmarks (bench), type testing (expectTypeOfwith--typecheck), browser mode, projects, and in-source tests. - Tests are ES modules, so
require,__dirnameand__filenamearen’t defined in a test file. Useimport.meta.dirname. NODE_ENVisn’t set totest. Set it intest_envif your code reads it.
Running without Vite depends on a vitest option that vitest still marks experimental, so a later vitest release may change how it behaves.
Using your own test runner
Section titled “Using your own test runner”Each framework’s tests are run by a program fabr installs with itself: fabr-test-runner,
fabr-jest-runner and fabr-vitest-runner, from the @fabr-build/js-tools package. Each is
named by a setting, JS_NODE_TEST_RUNNER, JS_JEST_RUNNER or JS_VITEST_RUNNER, which you can
point at a program of your own, usually a js_script:
js_script my_runner { entry = tools:run-tests.js;}
JS_NODE_TEST_RUNNER = my_runner;Replacing one framework’s runner leaves the other frameworks as they are. Fabr still compiles the tests, in the module format the framework’s row of the table above gives, and runs your program once for each test file, with the compiled tests as its working directory:
<runner> --report=<file> --env=<node|jsdom> [--update-snapshots] [--name-pattern=<regex>] [--setup=<file>]… [--pnp=<file>] [--reads=<file>] <test file>--envisjsdomfor a target with thedomflag in itsdepsortest_deps(notcompile_deps), andnodeotherwise.--update-snapshotsis passed byfabr test -u.--name-patternis passed byfabr test -t: run only the tests whose full names match.--setupnames asetupTestsfile to load before the test file.--pnpnames the PnP table the test processes resolve packages through, where the install has one in place ofnode_modules(see How tests find their packages).--readsasks the runner to write what the test process read, as a JSON document{"files": […], "lookups": […]}:filesthe files it loaded, read or probed for, by their paths relative to the working directory, andlookupseach package it looked up by a bare specifier, as{"from": <the file that asked>, "name": <the package>, "to": <the file that answered>},toleft out where nothing did. A runner that can’t say what was read, as when a test started another process, or that doesn’t support the option, writes no file, and its test files run again on every change.- The report must be written to the
--reportfile as a CTRF document,{"results": {"tool": …, "summary": …, "tests": […]}}, with each of the summary’s counts (tests,passed,failed,pending,skipped,other,start,stop) a number. - The report is the result. Once it’s written, how the runner exits doesn’t matter, so it may end at once rather than wait for work the tests left running. A runner prints no summary of its own: whatever it writes to stdout or stderr is shown as the tests’ output. The exit status counts only when there’s no report: a non-zero exit is reported as the runner failing, and a zero exit without a report is an error.
The runner starts with the same empty environment as the tests. Under
--coverage its environment also carries NODE_V8_COVERAGE, so tests run by any Node.js process
the runner starts are measured as long as that variable is passed on.
Troubleshooting
Section titled “Troubleshooting”| What you see | Cause | What to do |
|---|---|---|
Cannot test 'x': no rule matches target type 'js_package' |
test_framework or JS_TEST_FRAMEWORK isn’t node, jest or vitest |
Correct the value; the message lists the accepted ones. |
This test file registered no tests |
Under jest or vitest: a helper or setup file matched by tests, or a test file that takes describe/it from node:test |
Narrow the tests pattern (see Every file in tests is run as a test), or use the framework’s own API. |
Cannot find module 'x' from '…/x.test.js', at a jest.mock |
The test mocks a package its target doesn’t declare, one your code reaches only through another package | Add the package to test_deps; see What differs from jest. |
the following packages are required only without a version lower bound ('*') |
A test dependency’s own dependencies name a package without a minimum version | Add the @npm:…? lines the message prints to test_deps or your catalog. |
Cannot find name 'describe' or 'expect' when compiling |
jest: @types/jest isn’t in test_deps. vitest: the API isn’t imported. node: @types/node isn’t in deps, or expect is used without an assertion library |
Add the package or the import. |
Cannot find name 'document' when compiling |
The target doesn’t have the dom flag |
Add dom to deps or test_deps; see DOM tests. |
The fabr test runner provides no 'jsdom' environment |
The target has the dom flag and uses the node framework |
Set test_framework to jest or vitest. |
These tests need a DOM environment … but 'jsdom' is not among its dependencies |
The dom flag without the jsdom package |
Add @npm:jsdom:<version> to test_deps. |
This test has no recorded snapshot, or No recorded snapshot for this test |
A new snapshot, or a .snap file that isn’t declared |
Run fabr test -u, and check the file is matched by test_expectations. |
EACCES: permission denied, open '…/__snapshots__/x.test.ts.snap' under fabr test -u, or .snap files in the built package |
The .snap files are matched by srcs, not test_expectations |
Match them with test_expectations only. |
Recorded snapshots need node 23.4 or later |
node framework snapshots, with NODE set to an older release |
Set NODE to Node.js 23.4 or later. |
The jest compatibility runner needs node 22.15 or later, or The vitest runner needs node 22.15 or later |
NODE is set to an older release |
Set NODE to Node.js 22.15 or later. |
Measuring coverage needs the tests' packages in a node_modules tree; drop layout/pnp for --coverage |
fabr test --coverage on a target with the layout/pnp flag |
Measure it without the flag; see How tests find their packages. |
No test's name matches the pattern '…' |
fabr test -t with a pattern that no test’s full name matches |
Check the pattern against the describe and test names, joined by spaces. |
Test target has more than one setupTests script |
Two setupTests files at the root of the source tree, such as a .ts and a .js |
Keep one. |
The jest compatibility runner supports jest 29 and 30, but JEST is pinned to … |
JEST names another major version |
Set JEST to a jest 29 or 30 release. |
fabr runs vitest 4.1 or later, but the target's vitest is … |
An older vitest in test_deps |
Use vitest 4.1 or later. |
TypeError: Cannot read properties of undefined (reading 'length') at new ModuleRunner, under vitest 4.1 |
Vite 6.0.0 was selected | Add a later vite, such as @npm:vite:6.4.0, to test_deps. |
| A test passes under jest or vitest but times out, or reads an undefined variable, under fabr | The test relies on PATH, NODE_ENV, CI, TZ or another inherited variable |
Declare it in test_env. |