Skip to content
Logo

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
end

JavaScript 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 productArrow
= 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.

Every value can be replayed

An instance's data is a function of its clock — the instant captured when initialize() ran — and of a seed, if one was given. No seed 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 from

For an unseeded instance that one number is the whole reproducibility unit: log context.clock and the run replays. Adding a seed doesn't buy more variation, only a second value to record and pass back, so it's worth doing when something specific asks for it — pairing it with clock: "seeded" to drop the wall-clock dependency entirely, or FABRICATOR_SEED=repro-1234 bun test to pin a mixer for a suite from outside — and 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.

Replay also survives the way suites are actually run. A construction is rooted at the file that made it, and each file counts its own constructions, so which other files ran — a filtered run, a different order, one shard of a parallel build — changes nothing about the data any given file produces. Running the failing file on its own reproduces what CI saw.

Position still counts within a file: a construction added or skipped ahead of another shifts it, so narrowing past a whole file, down to a single test inside it, can move the data again. A fixture that should hold still regardless of where it sits can say so with new Fabricator(schema, { seed }), which is keyed by that seed alone — no file, no position. See Where a stream is attributed and Overriding a seed per construction.

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 seed and 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
[...()].; // 18

coverage(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.

Compared to other libraries

The libraries nearest to this one fall into three groups. The differences come down to where the shape is defined, and what the generator is willing to promise about repeating itself.

Schema-derived mocks

@anatine/zod-mock generates data from a Zod schema you already have. If an application already validates with Zod, that is a genuine advantage: one definition serves both validation and test data, at no extra authoring cost.

The trade-off is that generation is inferred from a schema written for a different purpose. Values are chosen largely by matching the field's name against faker's function names — a field called email gets an email because of what it is called, not because the schema says so — and the package's own README lists ten Zod types it does not handle, ZodTuple and ZodUnion among them.

fabricator runs that relationship in the opposite direction. The schema exists to describe generation, so every primitive is generation-complete and the bounds are stated rather than guessed; validation is reached by adapting outward, to TypeBox. Reach for zod-mock when Zod schemas already exist and free test data from them is the goal. Reach for this when generation is the schema's actual job.

Factory libraries

@travelperksl/fabricator — no relation, despite the name — is a JavaScript take on Ruby's Fabrication. Each field gets a generator function you write; the library composes definitions with .extend(), keeps sequence() counters, and produces batches with .times(). Little machinery, complete control, no schema layer at all. That description covers most factory libraries in this space.

Two things follow from having no schema. Values come from whatever function you call, so the library has nothing to say about a field's bounds or distribution, and there is no seed to replay a run from. And the result type is a target you supply rather than one derived from a definition: Fabricator<User>({ id: () => 1 }) type-checks under strict, and its result is typed User, while the object actually produced is { id: 1 }.

The pattern's deeper cost — variations defined by what they override, and the constants every test then quietly inherits — is The trouble with factories, above.

Reach for a factory library when the shapes are few and the generators are things you would rather write by hand than describe.

Property-based testing

fast-check answers a different question. It generates inputs in order to falsify a property, and when it finds a counterexample it shrinks it to the smallest input that still fails. Shrinking is the whole point, and fabricator has no equivalent.

fabricator's question is what one plausible instance of a shape looks like, and how to get that instance back. The two are complements, not substitutes: if the goal is "this invariant holds for all inputs," reach for fast-check.

When not to reach for this

  • This is not a validation library. Nothing here checks a value against a schema. Fabricated data is well-shaped by construction, and validation is a different feature. You can adapt a Fabricator Schema to a validation library. There is an official adapter for TypeBox (v0) (and eventually others).
  • You need uniqueness. There's no facility that guarantees production of distinct values — not across array elements, nor T.record keys. Generate a wider pool and dedupe, or use T.number.integer.sequence.

Limitations has the full, honest list.

Next

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.