Why Fabricator?
Every test needs data to run against. There are three typical ways to get it, but each comes with downsides. Fabricator exists to solve test data generation for any need.
The trouble with hand-written fixtures
const product = {
name: "Test Product",
price: 30,
inStock: true,
createdAt: new Date("2024-01-01"),
tags: ["widget", "sale"],
};Readable, obvious, and functional — until you want variations.
A fixture is a second definition of a shape that is already defined somewhere else. Add a field to the real type and every fixture standing in for it turns into mechanical work to account for the change. TypeScript will point at the ones that were annotated, otherwise type errors will surface elsewhere, if they even do at all.
The quieter cost is that a literal pins every field to one value permanently. The object above passes for price: 30. It has never run against price: 0, a name at the 25-character limit, an empty tags, or an absent optional field — and covering those situations requires many hand-write variants with no guarantee of proper coverage.
The trouble with factories
The usual answer to unmaintainable fixtures is a factory: define the model once, define each variation as a change to it. That does fix the churn, but the values are still constants, and making them shared turns a local problem into one that can affect the entire suite.
The pattern's archetype is Ruby's factory_bot, where a variation is a child factory that redefines part of its parent:
FactoryBot.define do
factory :user do
name { "Jane Doe" }
age { 30 }
role { "member" }
created_at { Time.utc(2024, 1, 1) }
factory :admin do
role { "admin" }
end
end
endJavaScript factory libraries reproduce the same shape with their own spelling.
The :admin factory is meant to say exactly one thing: this user is an administrator. It redefines role, and it also inherits name, age, and created_at — the same three values every other user in the suite gets. A variation is defined by what it overrides, so everything it doesn't override quietly becomes a constant shared by every test that touches the model.
Those constants harden into invariants the suite depends on without ever saying so. A test asserting a formatted name comes out "Jane Doe" passes because of a line in a factory file, not because of anything the test set up. A sort comes out stable because every record happens to share one created_at. An off-by-one at an age boundary is never reached, because the age is always 30.
None of these dependencies are written down, and none of them fail while they hold. The coupling stays invisible until someone edits the factory — at which point tests break for reasons that have nothing to do with what they were written to check, and the failure gives no hint that a constant several files away was the thing holding them up.
The trouble with plain random data
Generating values instead — faker, Math.random(), a helper of your own — fixes the specificity and breaks something else: the run that fails is the run you can't repeat. A test that goes red once in CI and green everywhere else is worse than a test pinned to a single value, because now there's a failure and no way back to it.
Pin the generator's seed globally and another problem appears. Most generators draw from one shared sequence, so inserting a field in the middle of a schema shifts every draw after it, and unrelated fixtures and snapshots churn on an edit that had nothing to do with them.
One shared sequence also means a test's values depend on which tests drew before it. Run the whole suite and the tenth test to draw gets the tenth set of values; run that test alone, or after a reordering, or as one shard of a parallel run, and it gets a different set of values. The seed didn't change but the data still did.
That is what makes the original failure so hard to chase. Reproducing it means reproducing the entire run — same tests, same order, same shard boundaries — because the obvious first move, running the failing test by itself, is guaranteed to hand it different data.
What fabricator does instead
Describe the shape anywhere once, as a Schema. Build it into a Fabricator where you need it. And call .fabricate() to produce a value. Mental model covers the three layers in full; the rest of this page is what that arrangement buys.
The type comes from the schema
import { } from "@ghostry/fabricator";
const { , } = ();
const = .({
: ..({ : { : 1, : 25 } }),
: ..({ : 1, : 500 }),
: .,
});
const product = new ().();
There's no second definition to keep in sync and no type annotation to write. Change the schema and every place consuming the fabricated value re-checks against the new shape automatically.
When a type already exists elsewhere — an API response, a domain entity, a generated client — .satisfies<T>() checks that the schema still produces that shape. Drift fails at the schema rather than in a fixture. The type still comes from the schema; this is an optional check, not a second definition.
Every value can be replayed
An instance's data is a function of its clock — the instant captured when initialize() ran — and of a salt, if one was given. No salt is required: the clock differs on every run, which is what makes runs vary, and it's readable afterwards, so a failing run can be turned back into a repeatable one:
const { , , } = ();
const = new (..({ : { : 12 } }));
.();
.; // the instant this run resolved "now" against
.; // where this specific fabricator's randomness came fromFor an unsalted instance that one number is the whole reproducibility unit: log context.clock and the run replays. Adding a salt doesn't buy more variation, only a second value to record and pass back, so it's only worth doing when something specific asks for it, such as pairing it with clock: "derived" and FABRICATOR_SALT=repro-1234 bun test to drop the wall-clock dependency entirely and pin a salt for a suite from outside. It's worth skipping otherwise.
.trace goes finer than the run: it's the exact tuple one field's stream was hashed from, and passing it back to new Fabricator(schema, trace) replays that field alone — useful when the thing you want to re-examine is one leaf inside a large fabricated object. See .trace.
Construction order still counts: an instance has one construction counter, so a construction added or skipped ahead of another shifts it. fork and wrap create fresh counters, letting a test integration partition each test from its neighbors while preserving the same replay model. See Construction order.
A test depends on what it pins, and nothing else
Fabricating varies every field that wasn't pinned, so a variation is expressed as an override on an otherwise-moving background rather than as a new set of constants:
const = .({ : "admin" });The override list is the test's stated dependency, and it is the whole of it. A test that had quietly leaned on the name being "Jane Doe" fails the first time it runs — which is the point. The coupling was already there; varying the rest is what makes it visible now, in the test that has it, rather than months later in an unrelated diff.
The usual objection to varying everything is flakiness, and it's a fair objection to unseeded randomness. It doesn't apply here: a failing run is replayable from its clock, so a surfaced coupling arrives as a reproducible failure rather than a red build nobody can explain.
A schema edit doesn't disturb unrelated data
Each field draws from its own stream, keyed by its structural position in the schema — its field name, its index in a tuple — never by how many fields happened to be dispatched before it.
Adding, removing, renaming, or reordering a field therefore changes that field's data and nothing else's. This is the property that makes randomized data survivable in a repository: the diff from a schema change stays the size of the change. See Which field gets which randomness.
Absence is not one thing
Code may distinguish between a key that isn't present, a key present holding undefined, and a key holding null. Most generators flatten all three into "sometimes missing."
T.object({
nickname: T.omittable(T.string.whereby({ length: { max: 12 } })), // key may be absent
deletedAt: T.nullable(T.date.past), // value may be null
note: T.optional(T.string.whereby({ length: { max: 40 } })), // omitted, undefined, or a present value
});Seven primitives cover the combinations, split along two axes — whether the key exists, and what the value is once it does. The full table is in Absence. It matters because the bug you're hunting usually lives in exactly one of those cases, and a generator that can't tell them apart can't reach it.
Covering the output space
Randomness samples a space; it doesn't cover it. Two functions enumerate it instead:
const = .({
: ..(["pending", "shipped", "delivered"]),
: .,
: .(..({ : { : 20 } })),
});
[...()].; // 3
[...()].; // 18coverage(schema) yields the smallest set of values in which every option of every enumerable node appears at least once. The width is that of the widest single axis rather than the product of all of them, with narrower axes cycling to fill it. Three values here are enough for all three statuses, both booleans, and all three of optional's outcomes to each show up.
combinatorial(schema) yields the full cartesian product — 3 × 2 × 3 — when you want to test with every output combination. It throws eagerly if the count would exceed limits.combinatorial (1024 by default), so a schema that explodes will loudly fail before producing anything.
Both are lazy and re-iterable, and each pass draws fresh randomness for whatever the enumeration didn't pin. See Instance.
Data distributed like production
A uniform draw across a range rarely resembles real data. Latencies cluster low with a long tail; ages cluster in the middle. distribution shapes the draw inside its bounds:
T.number.whereby({
min: 0,
max: 1000,
distribution: { kind: "normal", mean: 500, spread: 100 },
});Normal, skew, triangular, logarithmic, and weighted blends of those are available — see Distributions.
Next
The next page covers comparison with other libraries in this space.
If you want to skip that, see installation, then the quick start for the whole loop in one page, and Mental model for why Registry, Schema, and Fabricator are three separate things.
