Readiness: Implemented
The toolchain
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.
What you will be able to do: write tests beside the code they test, query accessibility and React Native trees, replace a module, run and narrow a suite, use browser mode and coverage, and know where the runner stands against Bun.
What you need first: a project with its dependencies installed —
Dependencies. Without them, a run stops at
@uniflowed/host.
The performance model
The runner is split so the expensive and unstable work is isolated. Rust owns the project walk, declaration discovery, scheduling, retries, reporters and coverage thresholds. A JavaScript host owns module evaluation and the test body. That gives uf a place to keep improving the loop without changing the API a test imports.
| Phase | Runs in | What costs time | What the report exposes |
|---|---|---|---|
| Project discovery | Rust | walking files, applying ignore rules, reading timings | path-filter errors, file counts |
| Test discovery | Rust | parsing Flow and finding declarations | uf test --list statuses |
| Scheduling | Rust | sorting by recorded duration or file size | warm and cold schedule counts |
| Execution | Node, Bun or Deno | importing transformed files and running cases | per-file duration, attempts, host |
| Reporting | Rust | JSON, JUnit, coverage summaries and terminal output | run versus total time |
An empty schedule is not a JavaScript problem. If a path filter, name filter or
list-only run leaves no test files to execute, uf returns before resolving or
starting a JS host. That makes mistakes cheap and makes host failures easier to
read: a missing Node binary should not be able to break uf test --list over
zero selected files.
When a run feels slow, first decide which number moved:
| Symptom | First thing to inspect |
|---|---|
total is high and run is low | project discovery, host resolution, reporters or coverage setup |
| One worker is still busy after the rest finished | .uf/test-timings.json and the slowest-files table |
| A tiny suite is slower with all cores | lower -j; worker startup can dominate short runs |
| The selected suite is empty | uf test --list <path> -t <name> before running the host |
| Browser mode is slow | reduce -j; each worker is a browser process |
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.
it.skipBecause(name, reason, body?) is the explicit skip for a case that
belongs in the suite but cannot run on this host yet. expect has 42 matchers,
.not, .resolves, .rejects, and fn() for spies.
A hook runs under the budget of the case it sets up for unless it names its
own, as it does — beforeAll(startServer, { timeout: 30_000 }), or the bare
number Jest and Vitest take. When a beforeAll fails, every case it was setting
up for fails with its error, and none of their bodies runs.
Use skipBecause when the skipped case is a contract, not a TODO:
// @flow
import { expect, it } from "@uniflowed/test";
it("runs the portable half", () => {
expect(21 * 2).toBe(42);
});
it.skipBecause(
"reads Node's coverage switch",
"Deno counts coverage in a format of its own, so NODE_V8_COVERAGE is not there to read.",
);
The reason is carried through the terminal report, uf test --json as
skipReason, and JUnit as the <skipped message>. That matters for ecosystem
work: a Deno or Bun run can stay green while still saying which Node-only
contract it deliberately did not execute.
A test beside the code it tests
A three-line test for a three-line function does not always want a file of its
own. import.meta.uf.test is a block that lives in the module it tests, the way
import.meta.vitest does:
// @flow
import { expect, it } from "@uniflowed/test";
export function slugify(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
if (import.meta.uf.test) {
it("collapses runs of punctuation", () => {
expect(slugify("Hello, world!")).toBe("hello-world");
});
}
uf test finds it through the same discovery that finds a .test.js file —
uf test --list shows it, -t filters it, and it is scheduled and reported
like any other case. Nothing has to be configured, and there is no separate
"include" pattern: a file that declares a test is a test file.
The block does not reach a build. uf compiles import.meta.uf.test to
void 0 in every transform except the ones uf test starts, so the bundler is
handed if (void 0) { … }, removes the branch, and then removes the import that
only the branch used — @uniflowed/test declares sideEffects: false so it
can. That is asserted against a real uf build rather than assumed: the fixture
in crates/uf_cli/tests/fixtures/rsc-split-app carries a string that only the
block contains, and the test reads the emitted bundle back looking for it in the
same file the module's other marker has to be present in.
The bindings come from an ordinary import, which is what makes them typed. The
marker's value is uf's test API, so const { it, expect } = import.meta.uf.test
works too and is what somebody arriving from Vitest will write — it is untyped,
because Flow has no typeof import("…") for a library definition to use.
Two things worth knowing:
- A source file with a block is an ordinary module, so a
.test.jsfile that imports it for the function evaluates the block a second time. It does not register a second time: the marker answers with the API only for the module the run is currently reporting on, and withundefinedfor every other. - Coverage counts the whole file, block included.
test.coverage.excludematches on paths, and an in-source test's path is the source file's.
Accessibility
expect(container).toHaveNoAxeViolations() runs axe-core over a rendered tree:
// @flow
import { expect, it } from "@uniflowed/test";
import { render } from "@uniflowed/react-testing";
import { Invoice } from "./Invoice.js";
it("is accessible", async () => {
const { container } = render(<Invoice total={420} />);
await expect(container).toHaveNoAxeViolations();
});
It is the one matcher that must be awaited — axe has no synchronous entry
point — and the published Matchers type says so, so forgetting the await
is a checker error rather than a test that passes by accident.
axe-core is an optional dependency: uf does not make a project that never
audits carry a megabyte of rule definitions. Add it with uf add -D axe-core
and the matcher starts working; write the matcher without it and you get one
sentence saying so.
The rule set is configured once, for the project:
// uf.config.js
export default defineConfig({
accessibility: {
axe: {
// `color-contrast` needs layout, and the DOM a Node worker renders into
// has none — axe reports it as *incomplete* rather than as a violation.
disabledRules: ["color-contrast"],
minImpact: "serious",
},
},
});
An assertion may narrow further — toHaveNoAxeViolations({ tags: ["wcag2a"] })
— and what it passes replaces the project's answer for that one call.
uf dev runs the same audit against the page on screen. It reports through
the diagnostic channel, so a finding arrives in the terminal beside everything
else uf dev has to say, rather than as an overlay drawn over the markup it is
complaining about. It runs once the page settles and again after the DOM stops
changing, it is skipped while the tab is hidden, and it says nothing when
nothing changed. accessibility.devAudit: false turns it off; a project with no
axe-core never has it at all, and no script is injected.
This is not the same thing as uf lint's a11y/* rules, and neither replaces
the other. The linter reads JSX that was never rendered and can see that an
<img> has no alt; the audit reads what React produced and can see that the
heading levels the component actually emitted skip a step.
React Native test trees
@uniflowed/react-native-testing retains its renderer-independent JSON tree
queries. For actual React Native components, use Native components
and the /native entry point below. It can also mount
through React Test Renderer when the suite installs the optional
react-test-renderer peer from the same React 19.3 release line declared by
the package. That adapter is
useful for component-level checks, but it is still JSON, not a native host
config, layout engine, device, Expo app or React Navigation runtime.
When another host, app-owned harness or renderer already produced the tree, pass that JSON directly:
// @flow
import { createNativeScreen } from "@uniflowed/react-native-testing";
import { expect, it } from "@uniflowed/test";
const tree = {
type: "ScrollView",
props: { testID: "settings-screen" },
children: [
{
type: "Pressable",
props: {
accessibilityLabel: "Save changes",
accessibilityState: { busy: true, disabled: true },
testID: "save",
},
children: [{ type: "Text", props: {}, children: ["Save"] }],
},
{
type: "View",
props: {
accessibilityLabel: "Upload photo",
accessibilityRole: "progressbar",
accessibilityValue: { min: 0, max: 100, now: 75, text: "75 percent" },
},
children: [],
},
{
type: "View",
props: {
accessibilityLabel: "Volume",
accessibilityRole: "adjustable",
accessibilityValue: { min: 0, max: 10, now: 6, text: "6 of 10" },
},
children: [],
},
{
type: "TextInput",
props: { accessibilityLabel: "Search settings", disabled: false },
children: [],
},
],
};
it("queries the native tree by role, state and value", () => {
const screen = createNativeScreen(tree);
const save = screen.getByRole("button", {
name: "Save changes",
busy: true,
disabled: true,
});
expect(save.props?.testID).toBe("save");
expect(screen.getByLabelText("Save changes").props?.testID).toBe("save");
expect(screen.queryByRole("button", { disabled: false })).toBe(null);
expect(screen.getByRole("textbox", { name: /Search/ }).type).toBe("TextInput");
expect(
screen.getByRole("progressbar", {
name: "Upload photo",
value: { min: 0, max: 100, now: 75, text: /percent/ },
}).type,
).toBe("View");
expect(
screen.getByRole("adjustable", {
name: "Volume",
value: { now: 6, text: "6 of 10" },
}).type,
).toBe("View");
expect(screen.queryByRole("progressbar", { value: { now: 25 } })).toBe(null);
});
When the matching React 19.3 react-test-renderer peer is present, render(ui)
mounts the element through that JSON renderer and returns the same queries plus
rerender, unmount and toJSON:
// @flow
import * as React from "react";
import { render } from "@uniflowed/react-native-testing";
import { expect, it } from "@uniflowed/test";
it("queries a rendered native component", () => {
const screen = render(
React.createElement(
"Pressable",
{
accessibilityLabel: "Save changes",
accessibilityState: { disabled: true },
testID: "save",
},
React.createElement("Text", null, "Save"),
),
);
expect(
screen.getByRole("button", {
name: "Save changes",
disabled: true,
}).props?.testID,
).toBe("save");
screen.rerender(React.createElement("Text", null, "Saved"));
expect(screen.queryByText("Save")).toBe(null);
expect(screen.getByText("Saved").type).toBe("Text");
screen.unmount();
});
render() deliberately refuses to hide the optional peer boundary. Without
react-test-renderer, it raises NativeTestingUnsupportedError and asks the
suite to install that peer or pass an already-produced tree to
createNativeScreen(). Device-only behavior, such as native layout or
platform permissions, still needs a device test:
// @flow
import { it } from "@uniflowed/test";
it.skipBecause(
"renders the settings screen on the native host",
"This test needs platform permissions on an actual native device.",
);
The query contract is deliberately narrower than Testing Library's DOM contract, because the input is a React Native JSON tree rather than a document:
createNativeScreen(root)andwithin(root)take one root or a fragment-shaped array of roots. They do not mount, flush effects, compute layout, hide nodes or inspect native platform views.getBy*returns exactly one match and throws on zero or many.queryBy*returnsnullon zero and still throws on many.getAllBy*throws on zero.- Text queries read React Native text content and avoid the duplicate
parent-and-child match: a
Textnode whose child already matches the string is not reported a second time for the same visible text. - Label queries read the accessible name directly, so
getByLabelText("Save changes")finds the same node thatgetByRole("button", { name: "Save changes" })narrows to, and still works when the role is not what the test is about. They take onlyexact, just like text queries. A label query includes nodes with an explicitaccessibilityLabeland nodes with a native role; a plainViewwhose name only came from a childTextis not reported again beside that child. - Role queries use the small default map uf can defend today:
PressableandButtonarebutton,Switchisswitch,TextInputistextbox,Imageisimage, andTextistext.accessibilityRoleorrolewins over that default. - The accessible name is
accessibilityLabelfirst, then descendant text. Matchers may be strings, regular expressions or predicates.
getByRole takes the state filters React Native exposes through
accessibilityState: busy, checked, disabled, expanded and selected.
disabled also reads the top-level disabled prop, because native components
commonly put it there. A missing state reads as false, not as "unknown", so
queryByRole("button", { disabled: false }) is the way to ask for an enabled
button.
It also takes value filters for accessibilityValue: min, max, now and
text. Those are exact numeric comparisons for the numbers and the same string,
regular expression or predicate matching for text, so progress bars,
steppers, sliders and other adjustable controls can be narrowed by the value a
native accessibility tree reports. A missing value field reads as null, which
means value: { now: 0 } finds a real zero and not "no value reported".
Unknown options are refused. pressed is not silently ignored on a native role
query, and value: { percentage: 75 } is refused too, because a test that asks
for a pressed button or a percentage and gets any matching role back has already
lost the thing it meant to assert. Add the state or value field to the helper
first, or assert on the returned props yourself.
That shows up as a skipped test with the reason in the terminal report,
uf test --json and JUnit. The JSON-level tests that can run on Node stay
green, and the native-host contract is still visible instead of being translated
into a TODO comment or a DOM-rendered substitute.
What a spy records
A spy's mock has the same fields as Vitest's, holding the same shapes, so an
assertion carried over from Vitest or Jest reads what it read there:
| field | one entry per call |
|---|---|
calls | the arguments, as an array: spy.mock.calls[0][0] is the first call's first argument |
results | { type: "return" | "throw" | "incomplete", value } |
settledResults | { type: "fulfilled" | "rejected" | "incomplete", value }, once a returned promise settles |
contexts | the call's this |
instances | the call's this, or what new constructed |
invocationCallOrder | a number counted across every spy, so two spies say which was first |
lastCall is the last call's arguments, or undefined before the first call.
Earlier releases kept { args, returned } objects in calls. Write
spy.mock.calls[0] where that code wrote spy.mock.calls[0].args, and read
what a call returned from spy.mock.results[0].value.
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, and Deno from 2.8. 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.
Deno implemented that hook in 2.8, which is also what its Flow loader is built
on, so uft.mock works there the way it does on Node
(#246). Bun's node:module
has no such hook, so there all seven bindings raise UnsupportedError naming
the host, the hook and what to do instead. None of them silently does nothing,
and both answers are checked by starting a real Bun and a real Deno 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 testuf test · shop
──────────────
✓ src/router.test.js 11ms 31 passed
✓ src/cart.test.js 4.1ms 12 passed
✗ src/format.test.js 3.2ms 7 passed · 1 failed
✗ format > wraps long lines
error: expected 4 to be 3
--> src/format.test.js:24:36
│
24 │ expect(out.split("\n").length).toBe(3);
│ ^
expected 3
received 4
⠹ src/search.test.js 18/40 › search > ranks exact matches first
51 cases · 3/4 files · 1 failed · 0.2s
Each file is reported the moment it finishes, not when the run does: its mark,
how long it took, and its counts. A passing test is counted on its file's line
rather than given one of its own — uf test --json names every test — so what
is left to read is what went wrong. A failure is drawn under its file with a
code frame at the assertion, what the matcher expected and received, and
anything the test printed, while the rest of the suite is still running.
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.
Under the finished files is what is still running: a row per file a worker has
in hand, counting its cases as they finish out of the ones it declares and
naming the one that just did, and a last row with the cases finished so far,
files finished out of the total, how many failed, and how long the run has
taken. A file of five hundred cases is visibly getting through them rather than
silent until it ends. The region is redrawn in place on stderr and taken away
before the summary, and it is only drawn on an interactive terminal with
neither CI nor NO_COLOR set. A pipe or a CI log
gets the same per-file lines as they finish, as plain text, and nothing is
redrawn; --json gets one document at the end and nothing else.
Once the run is over, the failures are named again — file:line name, so the
last screen says what broke without scrolling back — followed by what passing
tests printed, the slowest files, and the summary:
failed
✗ src/format.test.js:22 format > wraps long lines
slowest files
file duration
src/search.test.js 312ms
src/router.test.js 11ms
src/cart.test.js 4.1ms
src/format.test.js 3.2ms
run ························ 318.4ms
total ························ 352.9ms
passed 59
failed 1
skipped 0
todo 0
files 4
workers 4
schedule 4 recorded, 0 by size
timings .uf/test-timings.json
host node
✗ 59 passed, 1 failed in 352.9ms
run is the suite itself. total includes the surrounding work: reading the
project, resolving the host, loading timings, writing reporters, and drawing the
report. A gap between the two says "the tests were not the slow part". The
schedule row tells you whether this was a warm schedule from recorded
durations or a cold one from file size, and host names where the test bodies
actually ran.
What runs where
uf test keeps orchestration in Rust and execution in JavaScript:
- Rust reads the project and scans Flow source for declarations without importing the modules.
- It applies path filters,
-t,.only,.skipand.todo, then sorts files longest-expected-first. - It starts at most one worker per core, or the number from
-j. Once a run has recorded durations, it starts only as many workers as the suite can keep busy for longer than one takes to boot — see Timings and scheduling. - Each worker imports a file through the same
uf transformpath the build uses, runs the declarations, and streams structured events back. - Rust owns retries, bail, file timeouts, the report, JUnit, JSON and coverage threshold failures.
That split is why a syntax or transform bug fails in the same place in tests and
builds, while scheduling and reporting stay deterministic enough to compare in
CI. A worker may serve more than one file, but every file starts from the same
test state: spies are cleared, fake timers are restored, module stand-ins are
dropped, and stubEnv / stubGlobal writes are put back.
Every file also gets its own copy of the project's modules. Each module a test
file reaches — its own imports, theirs, and any import() they make later — is
evaluated afresh for that file, so state a module keeps (a React context's
current value, a cache, a counter) cannot reach the next file in the worker.
Two kinds of module stay shared, one instance per worker: installed packages
under node_modules (React, react-dom, the DOM shim), because evaluating them
again for every file is what a worker exists to avoid, and @uniflowed/test
itself, whose registry the file's cases are registered in. Two files that need
to share something at run time share it through globalThis, not through a
module. On Node and Deno this holds for every file; on Bun, whose loader is a
plugin the rule does not reach yet, project modules are still shared between
the files one worker runs. It costs about 16% more CPU on this repository's
suite (57 → 66 CPU-s), measured on a 12-core laptop under load.
Keeping the loop fast
The fast local loop is usually three commands:
$ uf test --list
$ uf test app/cart -t "discount"
$ uf test --watch app/cart
--list is not a dry run of the worker; it is the discovery view. It tells you
which declarations are runnable, skipped, filtered, unsupported, or from
another runner before a host is started. That is the cheap way to catch a bad
path, a misspelled filter, or a node:test import that would otherwise look
like "nothing ran".
Path arguments are substring filters over files, and -t is a substring filter
over the fully qualified name, including describe parents. Named paths
override .gitignore on purpose, so an ignored fixture directory can still be
tested by name; a path that matches no project file is refused, because a green
run over a typo is worse than a red one.
--watch re-runs what the changed file invalidated. It is for answering "did
my edit break the nearby tests?", not for whole-project proof, so it cannot be
combined with --json or --coverage: both promise a single report about one
complete suite.
A watch session keeps its workers between runs, so a rerun costs the files it
runs and the modules the edit touched rather than a host start and the whole
dependency graph. Before each rerun a kept worker is told which files changed,
and it imports those, and every module it loaded that reaches one, afresh; the
rest keep the instance they have, as they do between two files in one worker.
A worker that cannot promise that — a host without an in-thread loader, or an
edit that reaches a module require() loaded — is replaced with a fresh one.
Without --watch-interval the session asks the operating system to say when a
file changes — FSEvents on macOS, inotify on Linux — so a save is heard as it
happens and an idle session costs nothing. Before relying on that it writes a
probe file under .uf/ and waits up to two seconds to hear about it: a sandbox
that denies the file-event service, and many network and container file
systems, accept the watch and then report nothing. Where the probe is not
heard, the announcement says why and the session polls instead, as often as
costs about 2% of a core — every 50 ms on a few hundred files, backing off
towards 250 ms as the project grows; on this repository, whose 1,169 source
files take 4.4 ms to stat, that is about every 220 ms. --watch-interval MS
asks for polling at that interval.
From saving a file to its result on this repository, with polling (the numbers were taken in a sandbox that denies file events, so the event path is not among them):
| save | before | kept workers, adaptive polling | kept workers, --watch-interval 50 |
|---|---|---|---|
| a small test file | 435 ms | 148 ms | 59 ms |
| a small React test file | 756 ms | 194 ms | 61 ms |
a npm/ui module several tests import | 1,768 ms | 309 ms | — |
Medians of six to ten saves on a 12-core Apple Silicon laptop under heavy load from other work (load average 22–28), Node 24.14.
--changed REF asks the same question of a branch, once, which is what a pull
request's CI wants from a suite too large to run on every push:
$ uf test --changed origin/main
changed 3 files since origin/main (merge base 1f0c2a9e4b7d) · 2 of 140 test files reach them
A change is every file that differs from the commit where this branch's history
left REF — committed since, staged, or not yet staged — and every untracked
file git does not ignore. Starting from that commit rather than from REF
itself means work that landed on REF after the branch was cut is not counted
as this branch's. A test file runs when it imports a changed file, directly or
through any number of modules: the graph --watch uses. A deleted file reaches
the tests that still import it, because those are the tests it breaks, and a
changed snapshot reaches the test that took it.
Some files change results without any test importing them: uf.config.js, a
package.json or lockfile, and the .env files. A change to one of those runs
the whole suite, and the opening line names the file. The graph follows
relative imports, so a module a test reaches through a package name (a
workspace package included) and a file a test reads from disk are outside what
--changed can see; keep a full run on the branch you merge into. In CI, fetch
enough history for REF and HEAD to meet (fetch-depth: 0 for
actions/checkout), or git has no commit to measure from and the run is
refused. --changed cannot be combined with --watch, which already follows
changes, or with --coverage, for the reason watch mode cannot; a project that
turns coverage on in uf.config.js collects none on a --changed run.
Timings and scheduling
After a run, uf records completed file durations in
.uf/test-timings.json. The next run uses those numbers to put expensive files
first, because the slowest worker decides when a parallel suite finishes. A new
file, or a run with no timings yet, falls back to source size. The summary says
how many files were scheduled from each source.
The same file records what one worker cost to start, and the next run uses it to
decide how many workers to start, not only in what order to feed them. A
worker is a process that has to boot and load the test API before it runs a
line, so a suite of two-millisecond files is finished by two workers before a
third has booted — and on a laptop whose cores are half efficiency cores, the
extra workers take cores away from the ones doing the work. uf adds workers one
at a time while the next one would shorten the run by more than it costs to
start, so a short suite gets two or three and a suite with seconds of work gets
every core. A cold run, with nothing recorded, starts one per core as it always
has; -j is always taken exactly as written. The summary's workers row is
the number that ran.
A file's recorded duration is the file's own. The first file a worker is given used to be charged for the worker booting, which made one file per worker look tens of milliseconds slower than it was, in the slowest-files table and in the numbers the next run scheduled from.
The timings file is a cache, not an input you have to curate. If it is missing, corrupt, too large, or contains a path or duration uf will not believe, the run schedules cold and says so. It is rewritten with only files still in the project, and failure to write it is a note rather than a failed test run.
-j is still worth typing when you know something the recorded durations do
not:
$ uf test -j 1 # serial, easiest to debug ordering-sensitive failures
$ uf test -j 8 # every core, whatever the last run recorded
$ uf test --browser -j 2
Browser workers are full browser processes with scratch profiles, so -j 2 may
be the right CI value even on an eight-core runner. The terminal summary tells
you how many workers actually ran and which files dominated the wall clock.
Host choice and skips
The host is the first of these that says something: test.runtime, the
runtime test.runner brings, then runtime. test: { runtime: "bun@1.4" }
runs the suite on the Bun 1.4 release uf.lock locks, installed into the store
the first time a run needs it — see Environments. With none of
them, the host is the project's Capability JS Host:
app.runtime.capabilityJsHost.default, with auto-detection if the project kept
that on. uf explain test names the host, the key that chose it and the
release it is locked at, and is the fuller answer when a permission, loader or
runtime question is involved.
Those hosts are source-level JavaScript runtimes: Node, Bun and Deno. A
Cloudflare Worker, workerd or the edge adapter is a deployment target, not a
place uf test runs test files today. Test files still need uf's Flow loader at
run time, and the test worker still speaks to Rust over stdin and stdout; a
Worker has neither. Edge confidence comes from the built output instead:
uf build --adapter edge, then CI's tools/ci/edge-worker-smoke.sh, which runs
the generated Worker under wrangler dev --local. The host matrix has the
longer note on
why uf test does not run on workerd.
A missing host, a host without the requested capability, or a feature that would be silently partial is a command refusal. A test case that belongs in the suite but cannot run on the current host is a skip:
it.skipBecause(
"collects coverage on Bun",
"coverage is V8 data written by Node today; keep the Bun contract visible until the host can provide it.",
);
That distinction is the useful one in ecosystem work. Refusals say "this run
would lie"; skipBecause says "this known contract is still absent here", and
the reason survives in the terminal report, --json and JUnit.
Reports for CI
CI usually wants two outputs:
$ uf test --json > test-results.json
$ uf test --reporter junit --reporter-outfile junit.xml
--json is uf's full document. It includes the resolved host, whether that host
loads Flow and collects coverage, warm/cold scheduling counts, file statuses,
case statuses, attempts, durations, captured output and unsupported
declarations. Durations live in durationMicros fields so a program can ignore
only those when comparing two runs.
JUnit is intentionally narrower: one testsuite per file, one testcase per
declaration, failures as <failure>, import/time-out/host failures as
<error>, and skips with the skip reason as the <skipped message>. Use it
for pull request annotations and CI test tabs; use --json when a job needs to
make a policy decision.
For coverage in CI, keep the project policy in uf.config.js and let the
command only choose whether to measure:
$ uf test --coverage --coverage-reporter lcov --coverage-reporter cobertura
Threshold misses fail with the same non-zero exit as a failing test. LCOV and
Cobertura are files for other services; the JSON report grows a coverage
object only when coverage was actually collected.
Splitting a suite across machines
--shard INDEX/COUNT runs one part of a suite, and --merge-shards reports the
parts as one run:
# .github/workflows/test.yml, abridged
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3]
steps:
# check out, install, and restore .uf/test-timings.json from a cache
- run: uf test --shard ${{ matrix.shard }}/3
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-shard-${{ matrix.shard }}
path: .uf/test-shards
include-hidden-files: true
report:
needs: test
if: always()
steps:
# check out and install
- uses: actions/download-artifact@v4
with:
pattern: test-shard-*
path: shards
- run: uf test --merge-shards shards --reporter junit --reporter-outfile junit.xml
# save .uf/test-timings.json to the cache
Every shard cuts the same partition without talking to the others. Test files
are handed out heaviest first, each to the shard with the least expected time
so far, so the shards finish together rather than holding the same number of
files. The expected time is what .uf/test-timings.json recorded for a file,
or its size when nothing was recorded. A shard leaves that file as it found it,
and the merge records the whole suite's durations: save it after the merge and
restore it before the shards, and the next split is balanced by real times.
Each shard writes .uf/test-shards/INDEX-of-COUNT.json holding its report and,
under --coverage, what it measured. A shard reports its own results, but its
coverage is only recorded: the coverage reports and thresholds describe the
whole suite, so the merge writes them. --merge-shards DIR reads the records in
DIR and in the directories directly inside it, which is where a CI system
puts artefacts from several jobs. DIR defaults to .uf/test-shards.
The merge refuses records that would misreport the suite:
- a shard missing, or recorded twice;
- shards that disagree about how many there are;
- a file two shards both ran;
- a record written by a uf that writes them differently;
- shards cut from different timings, which put some files in two shards and others in none. Every record carries a fingerprint of the partition it was cut from, and the refusal names the fingerprints that disagree.
What a merge reports is what one run over the suite reports: the same files,
cases, statuses and counts, and a JUnit document that differs only in its
times. Coverage adds up the counts, the way one run adds up its workers', so
every ratio is the same. One count can differ: a line that runs when a module
is loaded is counted once for each process that loaded it, and that already
changes with -j in a single run.
--shard combines with --changed, whose selection is cut into shards, and
with --list, which shows one shard's files. It is refused with --watch and
under a Bun runner.
Benchmarks
bench declares a benchmark beside the tests, with .only, .skip and .todo
like it:
// @flow
import { bench } from "@uniflowed/test";
import { total } from "./cart.js";
const items = Array.from({ length: 1000 }, (_, index) => ({ price: index }));
bench("totals a thousand line items", () => {
total(items);
});
uf test reports a benchmark skipped, with the reason bench, so a suite does
not pay for timing one. uf test --bench runs the benchmarks in place of the
tests, and reports the tests skipped as not-bench. It runs one file at a time
unless -j says otherwise, because a benchmark timed beside another worker's
file is timed against it. A benchmark calls its body warmup times and throws
those times away, then times iterations calls, awaiting the body on each:
5 and 50 unless bench(name, body, { warmup, iterations }) says. timeout is
one call's budget, and the benchmark as a whole gets it once per call. The table
shows each median, 75th percentile and fastest call; --json adds the mean,
the 99th percentile and the slowest call.
A run of the benchmarks is compared with a baseline:
$ uf test --bench --save-baseline # where the baseline is kept
$ uf test --bench # on a change
--save-baseline writes each benchmark's median to .uf/bench-baseline.json,
or to the file --baseline names. A later uf test --bench compares every
median with that file. A median more than --bench-threshold per cent over its
baseline's, 20 unless given, fails the run and names the benchmark; one that
improved by as much is named too. A benchmark the baseline has no entry for is
new, and one the baseline has that did not run is listed. A baseline compares
only with runs on a machine like the one that wrote it, which is why the default
lives in .uf: to gate CI on one, save it in CI and restore it before --bench,
the way the timings file is restored before --shard.
--bench is refused with --watch, --shard, --coverage and --browser,
each of which would change what is timed or where. Under a Bun runner bench
refuses by name, because bun:test has no benchmarks.
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 |
--watch-interval MS | Poll for watched changes at this interval (default: adapts, 50–250 ms) |
--changed REF | Only the test files a change since REF reaches |
--shard INDEX/COUNT | Run one part of a suite split across machines |
--merge-shards [DIR] | Report the shards' records as one run |
--bench | Run the benchmarks in place of the tests |
--save-baseline | Save this run's benchmark medians as the baseline |
--baseline FILE | Compare with FILE instead of .uf/bench-baseline.json |
--bench-threshold PERCENT | How far past its baseline a median may go; 20 unless given |
--bail[=N] | Stop after N failures; N defaults to 1 |
--retry N | Re-run a failing test up to N more times |
-j N, --threads 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 |
--mode MODE | Load .env.<mode> instead of .env.test |
--browser | Run every file in a real browser instead of a DOM shim |
--coverage | Measure which Flow lines the suite executed |
--coverage-reporter FORMAT | Write text, lcov or cobertura; repeat for more than one |
--coverage-dir DIR | Put coverage files somewhere other than coverage/ |
--reporter junit --reporter-outfile FILE | Write the results as JUnit XML |
Browser mode
uf test --browser@uniflowed/react-testing renders into happy-dom, which is fast, runs anywhere,
and computes no layout at all. That is fine until a component's behaviour
depends on its size, and then it is not fine in a particular way:
| Asked of a half-width box | happy-dom | a browser |
|---|---|---|
getComputedStyle(el).width | "50%" | "300px" |
el.getBoundingClientRect().width | 0 | 300 |
el.offsetWidth | 0 | 300 |
an IntersectionObserver on it | never fires | fires |
The first row is the expensive one. happy-dom does not decline to answer — it resolves the cascade and hands back the declared value under the name of the computed one. A test asserting that a panel is half its container's width passes against a string that says nothing about width, and keeps passing after the component stops being half its container's width.
--browser runs the same files, with the same describe / it / expect, in
a page. tests/library/browser-layout.test.js in this repository is the table
above written as assertions, and it runs in both modes.
What it is
A host, not a second runner. Node and Bun are hosts uf test fans files out
to; the browser is a third. uf still discovers the files, orders them
longest-first from recorded timings, decides how many run at once, bounds each
one with a wall clock it will not negotiate, retries what the retry policy says
to retry, and assembles the report. None of that knows a browser is involved.
What it starts is a small Node process that serves the page its modules —
transformed by the same uf transform a build uses — and shuttles JSON between
a pipe and a socket. A page cannot read a pipe; that is the whole reason there
is a process in the middle.
One page load per file. This is stronger isolation than Node mode gets. A
Node worker serves many files out of one process, so everything two files could
share has to be put back between them by hand. A page reloads: new realm, new
document, new module registry, and a setInterval the last file abandoned is
gone rather than merely ignored.
One browser per worker. -j means what it always meant — how many files run
at once — and in browser mode each of those is a browser process with a scratch
profile of its own. That is a few hundred megabytes each, so -j 2 is worth
typing on a small CI container; nothing about the flag changes, but what it
costs does.
Not every test file belongs here. --browser runs the files it is given,
and a file that imports node:fs, spawns a process or reads a fixture off disk
cannot run in a page — the import is refused by name, and the file is reported
as failing to load. That is the right answer rather than a bug: point
--browser at the component tests, which is the half of a suite it is for.
What it depends on
A browser you already have. uf does not download one, does not vendor one,
and does not pin a version. It looks on PATH for chromium, chrome,
google-chrome, google-chrome-stable, microsoft-edge and brave-browser,
then for an installed Google Chrome, Chromium or Microsoft Edge; UF_BROWSER
names one outright and is never second-guessed.
With none of those, the run is refused, and the message lists everything it looked for. It does not fall back to the shim: you asked for a browser because you stopped believing the shim's answer, and a green run on it would be an answer to a question nobody asked.
The list is Chromium-shaped and the reason is not favouritism. A run needs to tell a browser from a command line to open a page headlessly, keep it open, and take a scratch profile. Firefox and Safari each need a driver protocol for that, and adopting one would make the test runner own a browser automation library.
There is no Playwright here and no Puppeteer. The browser is started with a URL and left alone; everything uf learns comes back over HTTP from the page's own code.
The browser driver uses Chrome DevTools Protocol over a private pipe. Closing that pipe terminates Chromium even when a worker times out. It opens no debugging port. The harness stays in its own page while the pages a test creates navigate.
Testing Server Components
Testing Server Components and actions is the whole story: a server action through its endpoint, a client component calling one over the wire, and a production build answering in the test's own process, none of which needs a port or a browser. This section is the level above those, for hydration and client interaction.
An async Server Component can fetch data during rendering; it needs no loader.
To test its React Flight stream and its client boundaries in a browser, start
the real application with createTestApp. This uses the same configuration, RSC analysis,
Vite environments and request scope as uf dev.
// @flow
import { it, expect } from "@uniflowed/test";
import { createTestApp } from "@uniflowed/test/app";
import { createBrowser } from "@uniflowed/test/browser";
it("renders server data and hydrates the counter", async () => {
const app = await createTestApp({ root: new URL("./", import.meta.url) });
let page;
try {
const html = await (await app.render("/products")).text();
expect(html).toContain("Products");
const flight = await app.flight("/products");
expect(flight.headers.get("content-type")).toContain("text/x-component");
await flight.text();
page = await createBrowser();
await page.visit(app.origin() + "/products");
await page.waitFor('[data-hydrated="yes"]');
await page.click("#counter");
expect(await page.text("#counter")).toBe("1");
} finally {
await page?.close();
await app.close();
}
}, { timeout: 60000 });
Run this integration test with uf test. For createBrowser in a Node worker,
set UF_BROWSER to a Chromium executable. The application needs its normal RSC
peers, including react-server-dom-parcel. root names the directory containing
uf.config.js; put the test there or pass the appropriate path. app.render
requests HTML, app.flight returns the real streaming Response, and app.fetch
is a plain request for a BFF handler. Each accepts request headers, including
cookies, so concurrent request isolation can be tested. app.events() returns
the most recent dev events. Startup and request-header waits default to 30
seconds; timeoutMs changes that budget. The test's own timeout bounds streaming
and browser work. close is idempotent; loss of the owning worker also closes
the dev process through its parent pipe.
This covers nested async components, Suspense and "use client" references in
the framework's actual renderer. DOM component tests continue to use
@uniflowed/react-testing. Calling an async component as a JavaScript function
alone does not exercise those RSC boundaries. The helper currently targets the
web development pipeline; it does not claim production-build or native coverage.
Trusted input and visual comparisons
createBrowser also works inside uf test --browser. It creates a separate
page, so navigating the application does not unload the test. click, fill,
press and tap use Chromium's input commands and produce trusted events.
waitFor waits for a selector; select a hydration or application-readiness marker
when the next action depends on client code. viewport sets dimensions and an
optional device scale. text, value, url and events inspect the page.
const page = await createBrowser();
try {
await page.viewport({ width: 1440, height: 1000 });
await page.visit("http://127.0.0.1:5173/");
await page.waitFor("#ready");
await page.fill("input[name=search]", "Flow");
await page.press("Enter");
await page.screenshot("home-desktop");
} finally {
await page.close();
}
A screenshot assertion waits for fonts and two animation frames, captures the
viewport, and compares it with <vrt.baselines>/<name>.png using pixelmatch.
Names must be unique within that directory. vrt.threshold is the allowed
number of different pixels, zero by default; a dimension change always fails.
page.screenshot(name, { threshold, baselines }) can override those two settings.
Baselines and failure artifacts stay inside the test project.
setContent(html) replaces the page's document with a string instead of
navigating to a URL, which is how a test screenshots markup it rendered
itself — for example renderStoryToHtml's output, as the
Mocks and stories guide does. It does not navigate, so the
page keeps its current URL (about:blank for a new page) and relative URLs in
the markup resolve against it; inline the styles the markup needs, or visit
a server that serves them.
const page = await createBrowser();
try {
await page.viewport({ width: 800, height: 600 });
await page.setContent('<main id="ready"><h1>Hello</h1></main>');
await page.waitFor("#ready");
await page.screenshot("hello");
} finally {
await page.close();
}
Missing baselines fail. Record or deliberately replace them with
uf test --browser -u; normal runs never update them. A mismatch writes
<name>.actual.png and <name>.diff.png and fails with their location. Keep
viewport, device scale, fonts and browser version fixed for reproducible CI.
uf's integration gate pins Chrome for Testing 153.0.8010.52 and exercises missing,
matching, changed and explicitly updated baselines.
The published browser API is @uniflowed/test/browser. The private workspace
@uniflowed/vrt package uses that API for visual regression plans.
VRT plans accept a callback that resolves each story and viewport to a ready
page, compare each snapshot and close the page. No story renderer is inferred.
Network interception, throttling and general Playwright API compatibility are
not provided by this interface.
Browser-mode boundaries
Four more, each refused by name rather than half-done:
- Snapshots.
toMatchSnapshotwrites a file beside the test that took it, and that text-snapshot API has no filesystem in a page. Record text snapshots on Node and check them in the browser;--browser -uupdates visual baselines.toMatchInlineSnapshotneeds no file and works. - Module mocking.
uft.mockintercepts module resolution, which isnode:module's synchronous hooks on Node and nothing at all in a page. - Coverage. V8 is counting in the renderer exactly as it counts in Node, but
NODE_V8_COVERAGEis Node's own switch and the only way to ask a page for its profile requires a coverage implementation that this driver does not yet have.--browser --coverageis refused before the browser is even looked for, because installing one would not fix it. permissions. A declared set describes what a process may reach, and the process the tests run in is a browser uf started rather than a host uf configured. Sandboxing the driver instead would produce a run that reads as sandboxed and a renderer with the whole machine's network, so the run is refused instead.
Anything a page cannot have — node:fs, a synchronous require — throws a
sentence naming what is missing and what to do instead, at the moment it is
reached. None of them answers plausibly and moves on.
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 today. It is V8's own count, written through
NODE_V8_COVERAGE and mapped through the source-map cache Node writes beside
the transformed script. That is the one report uf can honestly map back to the
Flow source.
Bun can run a suite, and Bun's own runner can count the JavaScript it executed,
but that is not the same report: it is coverage of the JavaScript the preload
produced, not of the Flow lines uf maps for Node. Deno has --coverage, but it
writes a profile format of its own and does not write the source-map cache this
reader needs. So uf test --coverage on Bun or Deno refuses before reporting
zeroes in a table that looks official. The host notes keep the short version for
Bun and
Deno.
--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. When a file rendered, the window goes back too: the
properties a test assigned on it (a removed matchMedia is the usual one), the
attributes on <html> and <head>, and the globals a render installed — so a
file that never renders gets Node's own FormData and no document at all,
exactly as in a fresh worker, and the next render puts the same window back.
The URL is not reset, because the router keeps its own state in step with it;
a test that depends on the address sets the one it needs. An axe
audit a timed-out case abandoned is waited for rather than failing the next
file's. 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.
Choosing a runner
uf test runs a suite with uf's own runner unless uf.config.js names another:
export default defineConfig({
test: { runner: "bun" }, // or "bun@1.4"
});
"bun" is the bun on PATH. "bun@1.4" is locked in uf.lock and installed
into uf's store the first time uf test needs it, as test: { runtime: "bun@1.4" } would be. A test.runtime that names anything but Bun is refused,
because bun test runs on Bun alone.
With runner: "bun", uf still discovers the test files — the same files its own
runner would run — and hands exactly those to bun test, with uf's Flow preload
loaded, so a test file written in Flow imports. The test files do not change:
they keep importing @uniflowed/test, and uf starts Bun with an export
condition under which that package is bun:test. uf explain test prints the
exact command.
What maps. describe, it and test with .only, .skip, .todo and
.each (rows are handed to the body whole, as uf's own runner does), the four
hooks, fn, spyOn, and uft's clock and mock-reset members are Bun's.
expect is Bun's too, so its own matchers work — and its failure messages are
Bun's.
What refuses. Anything with no Bun equivalent raises UnsupportedError at
the moment it is used, naming what was called: it.skipBecause (Bun's skips
carry no reason into a report), the DOM matchers and toHaveNoAxeViolations,
the snapshot matchers (the two runners key snapshots differently, so a snapshot
would be compared against, or rewritten over, one uf did not write),
uft.stubEnv and uft.stubGlobal, module mocking, bench, and
uft.advanceTimersByTimeAsync. uf test itself refuses, before Bun starts, a
project that declares permissions or coverage thresholds, and in-source tests
— each is a promise uf keeps for its own runner and cannot keep through Bun.
Flags. Path filters, -t (still a substring: uf escapes it for Bun's
regular expression), --watch, --bail, --retry, -j, --coverage with
text or lcov, and --reporter junit --reporter-outfile mean the same thing
to Bun and pass through. --json, --list, --browser, --watch-interval,
-u and a Cobertura report do not, and are refused by name rather than dropped.
Coverage under Bun is Bun's own count of the JavaScript the preload produced,
not uf's mapping back to your Flow lines. --changed is uf's own to answer:
Bun is handed only the test files the change reaches.
What uf still checks. Bun reports a file whose registrations went somewhere it could not see as "0 tests" and exits 0. So uf always has Bun write a JUnit report, reads it back, and fails the run when a file its discovery says declares tests produced no case at all — the rule uf's own runner enforces for the same reason.
The difference, measured. These numbers are relative, and only relative. They come from 12 rounds on a shared 8-core M3 at a load average of about 3, with the runs alternating, so the ratios carry over and the milliseconds may not. Absolute numbers on a quiet machine belong to the benchmark in #945. The suite is 50 Flow files and 1,000 cases, run with the transform cache warm:
| Run | Wall, median (p25–p75) | CPU |
|---|---|---|
uf test, runner: "uf" | 79 ms (78–80) | 100 ms |
uf test, runner: "bun" | 61 ms (60–61) | 70 ms |
the bun test command that starts, alone | 56 ms (55–57) | 60 ms |
bun test on the same suite as plain JavaScript | 33 ms (33–33) | 30 ms |
Around bun test, uf test reads the config, discovers the files and reads
the report back. Here that adds 5 ms. Bun's preload reads compiled Flow from
.uf/cache/transform, the same cache uf's own runner uses
(#944). A warm run therefore
compiles nothing under either runner, and on this suite the Bun runner is the
faster of the two. The rest of the gap to plain bun test is spent inside
Bun, loading the preload and the compiled modules. It has not been broken down
further.
Keep the default for what only uf's runner does — module mocking, DOM matchers and accessibility audits, coverage against your Flow source, permissions, browser mode, and skip reasons that survive into reports. Choose Bun for a suite that already relies on Bun's own matchers or behaviour.
Where it stands
50 files, 1,000 tests, 2,000 assertions, each runner on its own idiomatic copy of the same suite. Apple M3, 8 cores (4 performance + 4 efficiency), Node 26.8.1, Bun 1.3.13, Vitest 5.0.1, and uf in a release build. 25 timed rounds with the four runs alternating in a rotating order, 3 warm-up rounds discarded, on a machine shared with other work — so read the ratios, not the milliseconds:
| Runner | Wall, median (p25–p75) | Best | CPU |
|---|---|---|---|
bun test | 58 ms (56–60) | 50 ms | 70 ms |
uf test, warm transform cache | 125 ms (119–128) | 105 ms | 360 ms |
uf test, cold | 429 ms (407–440) | 354 ms | 1,650 ms |
vitest run | 1,310 ms (1,275–1,326) | 1,107 ms | 6,750 ms |
About ten times faster than Vitest, and about 2.2 times slower than Bun — the bar uf aims at, and does not clear yet.
Reproduce it. uf run bench:testing writes the three copies; the three
commands are yours to run, because Vitest is not a dependency of this
repository and Bun is not a dependency of anything:
$ uf run bench:testing /tmp/uf-bench-testing
$ cd /tmp/uf-bench-testing/uf && uf test # twice: the first run fills the cache
$ cd /tmp/uf-bench-testing/bun && bun test
$ cd /tmp/uf-bench-testing/vitest && vitest run
Every run has to report 1,000 passed, 0 failed and 0 skipped. A suite that
got quicker by running less of itself is the easiest way to get this table
wrong, and bun test prints the expect() count, so the 2,000 is checked
rather than claimed.
uf run bench:toolchain -- --tools uf,bun,vitest --stages test runs the same
three copies itself, cold and warm, refuses a run that does not report 1,000
passes, and records CPU time beside the wall clock. It is not interleaved the
way the table above was: each runner's runs are consecutive. The nightly
result, measured on a CI runner, is on Benchmarks as the
fixture called suite.
Where the remaining time goes. Not transforms, and not discovery. With the
cache warm a worker reads its modules back instead of compiling them, and uf's
entire front half — config, scan, discovery and scheduling, all before any
worker exists — is about 7 ms of the 125. What is left is worker start-up, and
it is paid once per worker: Node reaches the first line of a program in about
15 ms, the Flow loader adds about 5 ms, and @uniflowed/test's own module
graph adds 8 to 25 ms, so a worker is 30–55 ms old before it can run its first
case. Each of the 50 files then costs about 1 ms. uf measures that start-up
itself and records it as workerStartMicros — 49 ms on this machine — and
sizes the pool from it, which is why a warm run of this suite starts two
workers rather than eight. Bun pays none of this: its runner is in the engine,
so its 50 ms is mostly the work itself. The difference shows far more starkly
in CPU than on the wall clock — 360 ms against 70 ms — because a start-up is
paid per worker, while the wall clock hides it behind whichever cores were
free. Closing what is left means a worker that is already running when the run
begins, or one whose imports are already bundled; neither exists yet.
Not there yet
- No concurrent cases within a single file.
- 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 works on Node and on Deno 2.8 or newer, and raises
UnsupportedErroron Bun; see "Which hosts" above. - The accessibility audit is only as good as the DOM under it. On a Node worker
that is happy-dom, which computes no layout, so
color-contrastand the rules about overlapping targets come back incomplete rather than decided.uf dev's audit runs in a real browser and answers them. - Deno needs 2.8 or newer, the release with the
registerHooksits Flow loader is built on; an older Deno is refused by version rather than failing later on syntax it cannot read. And on Deno a test that imports a package with a native addon fails, because Deno cannotrequire()one while a module hook is installed — which is Deno's to fix.
Where to go next
Mocks and stories is next: answering a test's requests instead of stubbing its client, and naming the states a component is tested in.
Native components
Set test.target to "react-native" and test.runtime to "node". uf uses
React Native's own @react-native/jest-preset setup and module mocks. Rust
still discovers and schedules the files, applies timeouts and reports results;
Jest's module environment supplies the upstream native setup, not a second test
runner or a copied set of NativeModules mocks. Each file gets a fresh native
module environment, including when a worker runs several files.
Add jest, jest-runtime, jest-config, @jest/transform, @jest/expect
and babel-jest from the 30.x release line to the app's devDependencies,
along with @babel/plugin-transform-dynamic-import 7.x,
@react-native/jest-preset, @react-native/babel-preset,
@testing-library/react-native 14.x and test-renderer 1.x. Then run uf install.
Use preset versions matching the app's React Native release. The published
@uniflowed/react-native package provides the native test transformer; install
it along with @uniflowed/test. No Jest or Babel config file is needed. App
Flow syntax and StyleX go through the same uf transform as Metro; the upstream
React Native preset handles native dependencies and mock hoisting.
// uf.config.js
export default { test: { target: "react-native", runtime: "node" } };
import { test, expect } from "@uniflowed/test";
import { Text, Pressable } from "react-native";
import { render, screen, press } from "@uniflowed/react-native-testing/native";
component Save(onSave: () => void) {
return <Pressable accessibilityRole="button" onPress={onSave}><Text>Save</Text></Pressable>;
}
test("saves from a native press", async () => {
let saved = false;
await render(<Save onSave={() => { saved = true; }} />);
await press(screen.getByRole("button"));
expect(saved).toBe(true);
});
The /native entry point re-exports React Native Testing Library 14, which uses
Test Renderer rather than the deprecated react-test-renderer. It adds press,
changeText and scroll aliases for the asynchronous fireEvent operations.
Importing @testing-library/react-native directly works too; an existing RNTL
suite can retain that import and its Jest globals. This is separate from the
legacy synchronous JSON adapter at the package root.
Differences from running a Jest suite:
- Test discovery, filters, retries, worker scheduling and reports belong to uf.
Project
jest.config.*files and Jest CLI options are not read. - In this target, imported
expectuses Jest's matcher implementation, including RNTL's matchers. Usejestfrom@jest/globalsfor module mocks and fake timers; uf's module-mock namespace does not control the isolated native graph. - Await RNTL 14's
render, interactions, rerender and cleanup. Native module setup stays in the application's test imports; follow each library's own setup guidance for additional device modules. - Node is required. Native coverage, snapshot updates, benchmarks and browser mode are explicitly refused. Device layout, OS gestures and platform permissions still require device tests.
- As with Metro, the uf and Babel source maps are not yet composed; native stack locations can refer to lowered Flow output.
CI installs packed uf packages in a separate consumer directory, generates the
route modules and mounts a real NavigationContainer with native-stack and
bottom-tabs. It verifies press/text/scroll events, shared StyleX objects,
params, push/replace/back and claimed cold/warm links in one-worker and
multiple-worker runs.
Edit this pagedocs/app/guide/testing/$page.mdx