Skip to content

The fixture model

StableSince v0.1.0

A fixture is a typed value that a test needs, built by the framework instead of by the test. bun-test-utils composes fixtures explicitly with test.extend(), and a test asks for the ones it needs by destructuring them. The engine then builds each fixture once per its scope, injects the values, and unwinds everything in reverse order — including when a test fails.

This page explains the model. Scopes and teardown covers lifetimes in depth, and Composition and overrides covers how to organise several runners.

The definition contract

Runnable — complete sample — the docs check runs it against the built package

test.ts
import { test as base } from "@archont561/bun-test-utils";
export const test = base.extend({
greeting: {
setup: async (use) => {
await use("hello");
},
},
});
FieldRequiredMeaning
setupYes(use, context) => void | Promise<void>. Builds the value, hands it to use(value), then runs teardown.
scopeNo"session", "file", or "test" (the default). How long the value lives.
depsNoExplicit dependency list. Normally omitted — dependencies come from the destructured second parameter of setup.

use is not a return value: it is the hand-off point. Everything before the await use(value) call is setup, the argument is the value the test receives, and everything after the call is teardown. A setup function that never calls use fails with FIXTURE_USE_NOT_CALLED naming the fixture.

How a test asks for a fixture

Destructuring the test’s first parameter declares the dependency:

Runnable — complete sample — the docs check runs it against the built package

greeting.test.ts
import { expect } from "@archont561/bun-test-utils";
import { test } from "./test";
test("provides a fixture", async ({ greeting }) => {
expect(greeting).toBe("hello");
});

The type of greeting flows from what the fixture’s setup passed to use, so greeting is a string here with no annotation and no cast.

A test only sees fixtures that its imported test.extend() chain carries, plus the built-ins on the root runner. When a fixture is needed only for its side effect — a server that must be listening, a guard that must be armed — name it in options instead of destructuring it:

Partial — one step of a longer file or workflow — surrounding lines are omitted

test("the API accepts a payload", async ({ testServer }) => {
testServer.handle(() => Response.json({ ok: true }));
// ...
});
test(
"the guard is armed",
async () => {
// networkGuard is built and torn down around this test, but not used here
},
{ fixtures: ["networkGuard"] },
);

The option object is the third argument of test(name, fn, options): fixtures lists names to build, timeout sets a per-test timeout in milliseconds, and iterate defers test-scoped fixtures to ctx.iterate(fn) for tests that re-run their body (see the Core runner and fixture API).

The lifecycle, in one picture

bun test
│
├─ session fixture setup ──────────────────────────────────┐ built once per run
│ │
│ file: users.test.ts │
│ ├─ file fixture setup ───────────────────────────┐ │ built once per file
│ │ │ │
│ │ test: "creates a user" │ │
│ │ ├─ test fixture setup ────────────────┐ │ │ built per test
│ │ │ value injected into the test body │ │ │
│ │ ├─ teardown (LIFO) ───────────────────┘ │ │ runs even on failure
│ │ │ │ │
│ │ test: "deletes a user" │ │
│ │ ├─ test fixture setup ────────────┐ │ │ fresh instance
│ │ ├─ teardown ──────────────────────┘ │ │
│ │ │ │ │
│ │ └─ teardown: file-scoped fixtures ────────────┘ │ after the file's last test
│ │ │
│ file: orders.test.ts (same shape) │
│ │ │
│ └─ teardown: session-scoped fixtures ───────────────────┘ after the last file

Within one test, teardown order is the strict reverse of setup order, and a fixture’s teardown never starts before the teardown of every fixture that depends on it has finished.

Dependencies are declared, not inferred

A fixture names its dependencies by destructuring them from setup’s second parameter:

Partial — one step of a longer file or workflow — surrounding lines are omitted

export const test = base.extend({
database: {
scope: "file",
setup: async (use) => {
const database = await createDatabase();
await use(database);
await database.close();
},
},
user: {
setup: async (use, { database }) => {
await use(await database.createUser("Ada"));
},
},
});

user depends on database, so database is built first even though neither is mentioned in the test signature until the test asks for user. The engine resolves the graph topologically, and a cycle fails with CIRCULAR_DEPENDENCY and the cycle printed.

A fixture may depend only on fixtures with an equal or longer lifetime: a session fixture cannot depend on a test-scoped one. The engine checks this when the test is declared, before any body runs, and raises SCOPE_MISMATCH naming both fixtures and both scopes.

Manual setup versus a fixture

The alternative to a fixture is setup inside the test (or in beforeEach). It works, and for one or two tests it is shorter:

Illustrative — a complete sample the check cannot run, or one that calls helpers it does not define

manual.test.ts
import { afterEach, beforeEach, expect, test } from "bun:test";
let database: Database;
beforeEach(async () => {
database = await createDatabase(); // createDatabase and Database are yours
});
afterEach(async () => {
await database.close();
});
test("the user is stored", async () => {
const user = await database.createUser("Ada");
expect(await database.findUser(user.id)).toBeDefined();
});

The fixture version of the same test states the dependency in the test signature, so a reader sees what the test needs without scrolling:

Illustrative — a complete sample the check cannot run, or one that calls helpers it does not define

fixture.test.ts
import { expect } from "@archont561/bun-test-utils";
import { test } from "./test";
test("the user is stored", async ({ database }) => {
const user = await database.createUser("Ada");
expect(await database.findUser(user.id)).toBeDefined();
});

The differences that matter as a suite grows:

Manual setupFixtures
What a test needsImplicit: shared let bindings and hooksExplicit: the destructured parameter
Nested resourcesOrdering is up to beforeEach/afterEach blocksBuilt in dependency order, torn down in reverse
Sharing across a file or runA module-level singleton, managed by handscope: "file" or scope: "session"
Reuse in another suiteCopy the hooksImport the runner, or extend it
Failure behaviourTeardown runs only if the hook is reachedTeardown runs for everything already built, including on failure

Fixtures are not free — they add a definition and an import. Use them where a resource is created, shared, or torn down; a plain const inside the test is still the right answer for a value that needs no lifecycle.

What ships built in

The root test contributes nineteen fixture names. They are ordinary members of the same namespace, and any of them can be overridden by an extension.

AreaFixturesPrerequisite
Standardtmpdir, env, stdio, clock, seed, networkGuardNone
DOMwindow, document, pagehappy-dom
Either backendwebPagehappy-dom, or playwright with BUN_TEST_UTILS_WEB_ENV=browser
Browser (experimental)browser, browserContext, browserPage, browserHttpMockplaywright, installed Chromium
Server and HTTPtestServer, serverUrl, httpMockNone
CassettescassetteNone
SnapshotssnapshotNone

Capability index summarises what each one is for, and the Built-in fixture API lists the members each one provides.

The two callable fixtures

cassette and snapshot are fixtures that are also callable. Both are assertion-shaped helpers, and they solve different problems:

FormWhat each call doesIdentity
await snapshot(fn, name)Runs and awaits fn, matches the result under name, and returns that result.name is required; this is an assertion, never a cache.
await cassette(fn)Replays the recorded result when one exists; otherwise records according to cassette mode.Replay is keyed by the callback’s source and the mode.

When the model says no

SymptomErrorWhere to look
A test asks for a name its chain does not provideUNKNOWN_FIXTUREComposition and overrides
A short-lived fixture is requested by a longer-lived oneSCOPE_MISMATCHScopes and teardown
Two fixtures depend on each otherCIRCULAR_DEPENDENCYDebugging fixture failures
setup returned without calling useFIXTURE_USE_NOT_CALLEDDebugging fixture failures

Next steps

Docs for bun-test-utils v0.1.0 Release notes Report a docs issue