The tools
Testing
uf test is a Rust runner with JavaScript workers. Rust decides what runs, in
what order, on how many cores, and what the report says; the host runs the
bodies, because running JavaScript is the one thing Rust cannot do.
Writing a test
// @flow
import { describe, expect, it } from "@uniflowed/test";
import { total } from "./cart.js";
describe("total", () => {
it("sums the line items", () => {
expect(total([{ price: 300 }, { price: 120 }])).toBe(420);
});
it("rejects a negative price", async () => {
await expect(total([{ price: -1 }])).rejects.toThrow("negative");
});
});
Test files are Flow like everything else — component, hook, match and
enums all work inside one, because each worker imports its file through the same
transform a build uses.
describe, it, test, beforeAll, beforeEach, afterEach and afterAll
are there, with .only, .skip, .todo and .each on it and describe.
expect has 25 matchers, .not, .resolves, .rejects, and fn() for spies.
Replacing a module
fn() and uft.spyOn replace a method on an object a test can reach. A
component that imports a client and calls it while it is being evaluated has no
such object — the import is the only seam — so uft.mock replaces the whole
module.
// @flow
import typeof * as ClientModule from "./client.js";
import { describe, expect, it, uft } from "@uniflowed/test";
describe("the dashboard", () => {
it("renders what the client returned", async () => {
await uft.mock<ClientModule>("./client.js", () => ({
send: uft.fn(() => ({ total: 420 })),
}));
const { summary } = await import("./dashboard.js");
expect(summary()).toBe("420");
});
});
When a mock takes effect
Nothing is hoisted. Vitest lifts vi.mock above the importing file's
import declarations with a Babel pass. uf has no Babel in its pipeline and is
not adding one for a test helper, so the rule is Bun's:
- every
importdeclaration runs before the first statement in the file, so a static import is never affected by auft.mockwritten below it — it already holds the real module; - a mock takes effect for every import that begins after the promise
uft.mockreturns settles; - so
await import("./client.js")is how a test reaches the stand-in.
A synchronous factory is installed before uft.mock returns, so the rule holds
whether or not you await. Await anyway: an automatic stand-in has to read the
real module first, and cannot be installed synchronously at all.
A mock affects the next import, not the last one. Nothing can rewrite a module the host has already evaluated and linked into its importers. Bun's engine can do that and Node's cannot, and one call meaning two things on two hosts is worse than it meaning the narrower one on both.
Registering or removing a mock starts a new module epoch. A module that
imported the mocked one computed its own exports from it, so it is evaluated
again on the next import too. Modules reached by a path (./client.js) are;
packages are not, because a second copy of @uniflowed/test would be a second
registry and a second set of spies.
The bindings
| Binding | What it does |
|---|---|
uft.mock(specifier, factory) | Register what the module exports. The factory runs once, immediately; it may be async |
uft.mock(specifier) | Register an automatic stand-in: functions become spies that record and return undefined, arrays are emptied, objects are followed, primitives are kept |
uft.doMock | uft.mock. Vitest's two names differ only in hoisting, and uf hoists neither |
uft.unmock(specifier) | Stop standing in, for imports that begin after the call |
uft.doUnmock | uft.unmock, for the same reason |
uft.importActual(specifier) | The real module, even while it is mocked — this is how a partial mock keeps the exports it did not name. It reaches past the stand-in for the module it names and for no other, so the modules that one imports are still mocked |
uft.importMock(specifier) | The automatic stand-in, handed back rather than registered |
uft.resetModules() | Evaluate path-imported modules again on the next import. It does not run a mock's factory again — register the mock again for that |
A partial mock is importActual and a spread:
await uft.mock<ClientModule>("./client.js", async () => ({
...(await uft.importActual<ClientModule>("./client.js")),
send: uft.fn(() => ({ total: 420 })),
}));
uft.clearAllMocks, resetAllMocks and restoreAllMocks reach the spies a
factory put in a module's exports, and none of them unmocks anything:
clearAllMocks forgets the calls, resetAllMocks forgets the implementations
too, restoreAllMocks puts back what uft.spyOn took. uft.unmock is the one
that puts the module back.
The factory is type checked
import typeof * as ClientModule from "./client.js" names the module's shape,
and uft.mock<ClientModule> checks the factory against it. A stand-in whose
BASE is a number, for a module whose BASE is a string, is an error at the
call rather than a TypeError three tests later:
error[incompatible-type]: Cannot call uft.mock with function bound to factory
because in the return value: Either in property BASE: 42 [1] is incompatible
with string [2].
The factory is checked as a Partial of the module, so naming a subset of the
exports is fine and misspelling one of them is not.
Which hosts
Node only. Interception needs synchronous, in-thread module customization hooks
— node:module's registerHooks — because a mock is a value the test built and
a loader running on a thread of its own cannot see it. Bun's node:module has
no such hook and Deno has no uf loader at all
(#246), so on either host all
seven bindings raise UnsupportedError naming the host, the hook and what to do
instead. None of them silently does nothing, and on Bun that is checked by
starting a real Bun and asking.
Bun's plugin API is not a second route to this, which is worth saying because it
looks like one:
#419 has what a Bun 1.1
onResolve does with each of the three places a stand-in could be given an
identity of its own, and none of them survives an import declaration — which
is the case the feature exists for. Bun's own mock.module survives one, and it
maintains live bindings: it changes a module somebody has already imported,
which is a different promise from the one above rather than the same one on a
second host.
Running them
uf test PASS src/cart.test.js 12 tests 4ms
PASS src/router.test.js 31 tests 11ms
FAIL src/format.test.js 8 tests 3ms
format > wraps long lines
src/format.test.js:24
22 | it("wraps long lines", () => {
23 | const out = format(input, { width: 40 });
> 24 | expect(out.split("\n").length).toBe(3);
| ^
25 | });
expected 3
received 4
51 tests, 50 passed, 1 failed in 0.21s
The frame points at the Flow source: the transform emits a source map, the worker runs with source maps on, and the runner's own stack frames are trimmed out so the top frame is yours.
Flags worth knowing
| Flag | What it does |
|---|---|
-t PATTERN | Only tests whose full name contains PATTERN |
PATH... | Only files whose path contains one of these |
--watch | Re-run what the change affected |
--bail[=N] | Stop after N failures; N defaults to 1 |
--retry N | Re-run a failing test up to N more times |
-j N | Run at most N files at once; defaults to one per core |
--list | Print what would run without running it |
--json | The whole report, machine-readable |
--coverage | Measure which Flow lines the suite executed |
--reporter junit --reporter-outfile FILE | Write the results as JUnit XML |
Coverage
uf test --coverage reports coverage against the Flow you wrote, not
against the JavaScript uf printed for it.
coverage
file lines functions branches uncovered
src/badge.js 85.71% (12/14) 66.67% (2/3) 50.00% (1/2) 17-18
lines 85.71% (12/14)
functions 66.67% (2/3)
branches 50.00% (1/2)
wrote coverage/lcov.info
Nothing is instrumented. V8 counts execution on its own, Node writes those counts out when a worker exits, and uf maps them back through the same source map the transform already produces. So the code the suite ran is the code you shipped, byte for byte — which is not true of any tool that rewrites your source to count it.
What the three numbers mean
Every tool means something slightly different by "branch", so here is uf's, in full. One rule decides all three: a position in the output that maps to nothing you wrote is not counted at all.
| Metric | Counted | Covered |
|---|---|---|
| Lines | An author line that at least one generated position maps back to | The innermost V8 range over one of those positions ran at least once |
| Functions | A generated function whose body holds at least one mapped position, attributed to the first one | It was entered |
| Branches | A V8 block — an if or ternary arm, a && right operand, a loop body, a catch — whose first mapped position exists | It was entered |
The rule is what keeps the compiler out of your numbers. A type alias
generates nothing, so it is not a missed line. A match lowers to a chain of
tests you did not write, and those tests are not branches you failed to cover.
The enum runtime and the React Compiler's memo blocks are not functions you
forgot to test. None of them is reported as covered either — they are simply not
in the denominator, because they are not your program.
A file the suite never loaded is named rather than scored: it has no measured
line to divide by, and 0/0 — which every other ratio here calls a hundred per
cent — would make a file nobody imports the best-covered file in the project.
Failing a build on it
Thresholds live in uf.config.js, not on the command line, because the number
CI fails on has to be the number a laptop fails on.
export default defineConfig({
test: {
coverage: {
reporters: ["text", "lcov"],
thresholds: { lines: 80, branches: 70 },
perFileThresholds: { lines: 50 },
},
},
});
They are checked whenever coverage was collected, however it was asked for, and a threshold that is not reached fails the run with a non-zero exit code and a line per miss. A metric nobody names is not checked.
The files CI reads
| Reporter | File | Who reads it |
|---|---|---|
text | — | The terminal table above |
lcov | coverage/lcov.info | Codecov, Coveralls, genhtml, the GitHub and GitLab coverage widgets |
cobertura | coverage/cobertura-coverage.xml | Azure Pipelines, Jenkins |
--reporter junit --reporter-outfile junit.xml writes the results — one
testsuite per file, one testcase per declaration — which is what turns a
failing test into an annotation on a pull request. It needs an outfile because
uf test --json already owns stdout for a machine.
uf test --json grows a coverage object when a run measured, and it is absent
rather than empty when it did not: a check reading the document has to be able
to tell "not measured" from "measured, and bad".
What it does not do yet
Coverage is Node only. It is V8's own count, written through
NODE_V8_COVERAGE and mapped through the source map Node's loader attached;
Bun implements neither, and Deno has no Flow loader at all. uf test --coverage
on either says so rather than reporting zeroes.
--watch and --coverage cannot be combined: watch mode re-runs the files an
edit affected, so its coverage would be a number about three files wearing the
project's name.
What it gets right
A file that throws while importing is a file failure, not "0 tests". Reporting a module that could not load as having no failing tests would be a lie.
A file that registers nothing is a file failure too. uf test reads
describe, it and test out of your source to decide what to schedule, and
the worker reports what those calls actually registered. When the first number
is positive and the second is zero, none of the file ran — the bindings were
another runner's, or the file's own, or the registrations were inside a branch
this run did not take — and the run says so instead of counting the file as
done. A file that deliberately runs nothing writes it.skip, which registers
and is reported as a skip.
Another runner's test is not a test uf can run, and saying so is the
run's job. test("...") is the same eight characters whoever exports the
binding, so uf test reads the import it came from. A name bound by
node:test, vitest, jest or their peers is listed under unsupported
declarations, is not counted as runnable by --list, and makes the run
non-zero — node:test especially, because it accepts the registration and
keeps it for a runner that is never started, so the file loads, reports
nothing, and would otherwise pass. Only a runner that can be named is treated
as one: import { it } from "../support/setup.js" re-exporting uf's own it
is an ordinary thing to write and runs as it always did.
it.each(...) is listed there too and does not fail the run. It is uf's own
it in a form discovery cannot expand, so --list cannot name the cases ahead
of time — but the worker runs them and the report counts them, which is the
whole difference.
A test that never settles is failed, not hung. The worker races each case against its budget, and Rust keeps its own wall-clock deadline and kills the process — because a wedged event loop would never have run the worker's timer either.
A retry re-runs the file, filtered to one case. A case cannot be re-entered without re-importing its module, and a retry has to see the module state a first run would.
.only is decided after the module body runs, since a file's .only can
appear below the tests it excludes.
One worker serves many files, and each one starts from the same place.
Between files the worker clears the registry, puts every stubEnv and
stubGlobal back, restores the real clock, and drops the module stand-ins a
mock installed. Spies and mocks live in state the runner owns; a stub and a
fake clock are writes to something the whole process shares, so without this a
file that called uft.useFakeTimers() and forgot to restore it would leave the
next file's setTimeout never firing — a hang, with nothing on screen,
blamed on the file that waited rather than the file that stopped time. Which
files follow which changes with the timings cache, so a suite that depended on
it would fail differently every run.
Where it stands
50 files, 1,000 tests, 2,000 assertions, on an 8-core M-series Mac, best of five, each runner on its own idiomatic input:
| Runner | Time |
|---|---|
bun test | 0.06 s |
uf test, warm transform cache | 0.20 s |
uf test, cold | 0.30 s |
vitest run | 1.96 s |
About nine times faster than Vitest. About three times slower than Bun, which is the bar uf is aiming at and does not clear yet.
The gap is process start-up and inter-process messaging. Bun runs everything in
one process with the runner built into the engine; uf spawns a worker per core
and each one loads the test API before it can do anything. That is also why
-j 4 beats -j 8 on this suite — past a point, another worker costs more to
start than the work it takes. Closing the gap needs a worker pool that survives
between runs and a pre-bundled worker. Neither exists yet.
Not there yet
- No concurrent cases within a single file, and no
--shardfor splitting a suite across machines. - Coverage is Node-only, and a worker that has to be killed — a file that timed out, a worker that crashed — takes its counts with it, because they are written when the process exits.
- Module mocking is Node-only; see "Which hosts" above.
@uniflowed/react-testingneeds a DOM and says so when called; component tests need one supplied.- Deno has no Flow loader, so
uf testrefuses it with a message rather than failing later on syntax it cannot read.