Skip to content
Chapter 87Lesson 2

Factories over shared fixtures

Build test data with factory functions that return a fresh, valid domain object on every call, the Vitest pattern that keeps unit tests isolated and self-documenting.

Three tests in the same file need the same setup: an Organization on the pro plan and a User whose role is admin. Arrange should be one line, so the obvious move is to lift that setup out of the tests. You hoist a const org = { ... } and a const admin = { ... } to the top of the file and let all three share them. Less typing, one place to read the shape, done.

That reflex is one of the most common test-data mistakes there is, and it has a name and a cost.

The previous lesson set a rule: when arrange grows past three lines, the fixture moves to a factory. By the end of this one you’ll write buildUser({ role: 'admin' }) and read it as “a user that’s an admin,” and you’ll know when to reach for a factory, when to reach for a static fixture instead, and why a seed is neither.

Here is the file you’d actually write: a User shape with a handful of fields, hoisted to the top and shared by every test below it.

src/lib/access.test.ts
const baseUser = {
id: 'usr_1',
email: 'test@example.com',
role: 'member',
orgId: 'org_1',
};
it('lists a member in the directory', () => {
expect(canSeeBilling(baseUser)).toBe(false);
});
it('grants billing access to admins', () => {
baseUser.role = 'admin';
expect(canSeeBilling(baseUser)).toBe(true);
});

The second test needs an admin, so it does the natural thing and sets baseUser.role = 'admin' on the shared object. Both tests pass, the file runs green, and you ship it.

Then someone reorders the file, or Vitest runs the tests in a different order. Now the first test sees role: 'admin', a value it never set and that contradicts its own name, and it fails. Whoever picks up that failure finds nothing wrong with the test, re-runs it, watches it pass, and writes it off as flaky. It is not flaky. The two tests share one mutable object and one of them writes to it, so the first test’s result depends on whether the second ran first. That is run-order coupling .

it('lists a member') canSeeBilling(baseUser) expects false → FAILS
const baseUser one shared object role: 'member'
it('grants admin access') baseUser.role = 'admin' expects true → passes
Both tests point at the one shared object. The admin test writes to .role; the member test reads that mutated value and fails — even though it never touched baseUser. The failure depends on which test ran first: that is run-order coupling.

You might spot the mutation and try to dodge it: the next test needs a plain member, so rather than touch the shared object you copy the literal, paste it, and tweak the one field you care about. Now two near-identical literals drift apart in the same file. When the User shape gains a field, you update one and forget the other. And a reader facing a fifteen-field literal can’t tell which fields the test depends on and which are only there to make the object valid; the data buries the intent.

The problem was never the data, since every test legitimately needs a user. The problem is that the data is shared and mutable. You could deep-clone it on every read, but that is noisy, easy to forget, and does nothing for readability. Better to stop storing the data and start producing it, with a function that returns a brand-new instance on every call. That function is a factory, and it solves both problems at once: every test gets its own object, and every test names only the field it cares about.

A factory is a function that returns a fresh domain object. The pattern is three rules, each fixing something that breaks without it.

Rule one: every required field gets a valid default. Start with the simplest buildUser: no arguments, a function that returns a fully formed User.

src/test/factories/users.ts
const buildUser = (): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
});

The word that matters is valid. email: 'test@example.com' is a real, schema-passing address, not a placeholder like 'TODO'. The moment a test exercises validation, a placeholder gives you a false negative : the test passes, the validator is broken, and nobody finds out. Every default has to be a value your production code would actually accept, so buildUser never produces an address that fails validation, just as buildInvoice later won’t produce negative line totals unless you ask for them.

Rule two: overrides is the last spread. Right now buildUser() always returns a member, and the test that needs an admin can’t get one without mutating the result, the problem the previous section described. So let the caller pass the fields it cares about, spread in last.

src/test/factories/users.ts
const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});

Partial<User> is any subset of User’s fields, all optional, so the caller can override one field or ten. Because ...overrides comes last, anything the caller passes wins over the matching default. The payoff is at the call site:

const admin = buildUser({ role: 'admin' });

That line says one thing: a user that is an admin. The id, email, orgId, and createdAt are all valid, all present, and all irrelevant here, so the test documents exactly what it depends on. The copy-pasted literal could never do that.

Rule three: a fresh object every call. This rule is about what you don’t do. The object literal sits inside the function body, so every call evaluates it again and returns a new object. Two buildUser() calls share no memory, which closes the mutation bug at the root: there’s no shared object left to mutate.

The anti-pattern to watch for is a “factory” that builds one object at module load and hands back that same reference every call.

const cached = { id: 'usr_test', email: 'test@example.com', role: 'member' };
const buildUser = (overrides: Partial<User> = {}) => {
Object.assign(cached, overrides);
return cached;
};

That isn’t a factory. It’s the shared mutable fixture in a function’s clothes: every caller mutates and receives the same object. Fresh-object-per-call is the entire reason the pattern works, so don’t optimize it away.

Here is the finished factory.

import type { InferSelectModel } from 'drizzle-orm';
import { users } from '@/db/schema';
import { instant } from '@/lib/temporal';
type User = InferSelectModel<typeof users>;
export const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});

The signature is the contract: an optional subset of User in, a complete User out. The = {} default lets you call buildUser() with no arguments.

import type { InferSelectModel } from 'drizzle-orm';
import { users } from '@/db/schema';
import { instant } from '@/lib/temporal';
type User = InferSelectModel<typeof users>;
export const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});

Every required field gets a valid default: a real email, role, and instant, never a placeholder, because each has to be a value production code would accept.

import type { InferSelectModel } from 'drizzle-orm';
import { users } from '@/db/schema';
import { instant } from '@/lib/temporal';
type User = InferSelectModel<typeof users>;
export const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});

The override spread comes last, so the caller’s fields win. This is the line that lets a test name only the field it’s testing.

import type { InferSelectModel } from 'drizzle-orm';
import { users } from '@/db/schema';
import { instant } from '@/lib/temporal';
type User = InferSelectModel<typeof users>;
export const buildUser = (overrides: Partial<User> = {}): User => ({
id: 'usr_test',
email: 'test@example.com',
role: 'member',
orgId: 'org_test',
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});

The User type is derived from the Drizzle schema, so the factory and the database agree by construction. The next payoff comes from here.

1 / 1

The return type isn’t a hand-written User interface. It’s InferSelectModel<typeof users> , the type Drizzle infers from your schema. You met it in the database unit: the schema is the single source of truth, and the row type flows out of it. Typing buildUser off that same inferred type wires the factory directly to the schema.

Watch what happens on a migration. You add a required timeZone column to users, and every place that constructs a User is now missing a field, including buildUser. TypeScript fails the build inside the factory, at one line. You add timeZone: 'UTC' to the defaults once, and every test that calls buildUser keeps compiling. Compare that to fifty test files each holding their own user literal: one migration, fifty compile errors, fifty edits. A factory absorbs the schema change in one place, and that payoff lands again on every migration after.

Put the two shapes side by side and the difference is clear.

const baseUser = { id: 'usr_1', email: 'test@example.com', role: 'member', orgId: 'org_1' };
it('grants admin access', () => {
baseUser.role = 'admin';
expect(canSeeBilling(baseUser)).toBe(true);
});

Collides under reorder. The mutation leaks into every other test that touches baseUser, and the reader can’t tell which of its four fields the test depends on.

A factory is shared test infrastructure, so the course gives it one canonical home: src/test/factories/, one file per entity, the export named for the file. No guessing where buildInvoice lives.

  • Directorysrc/
    • Directorytest/
      • Directoryfactories/
        • users.ts buildUser
        • orgs.ts buildOrg
        • invoices.ts buildInvoice
      • Directorymatchers/ custom Result matchers, from the previous lesson
      • Directoryfixtures/ static external payloads, covered below
      • clock.ts the frozen-time seam, next lesson

Why not colocate, the way the last lesson put each test beside the file it tests? Colocation pairs a test with its one source file so they move and delete together. A factory has no single source file: the same buildUser is imported by /lib unit tests today and by integration tests next chapter, so it’s cross-cutting infrastructure, not a test of any one module. That kind of support lives under src/test/, alongside the matchers from the last lesson.

One rule carries over: dependency direction. Factories import domain types from @/db or @/lib, never from app/**. Test support should depend on the domain, not on the application surface, and reaching up into route handlers or pages also trips the same no-restricted-paths lint rule that guards your /lib tests. Point the imports down toward the schema.

An Invoice has a customer. Rather than make every invoice test hand-build a User and pass it in, buildInvoice defaults its customer to buildUser():

src/test/factories/invoices.ts
export const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice => ({
id: 'inv_test',
status: 'draft',
customer: buildUser(),
total: { amount: 1000, currency: 'USD' },
createdAt: instant('2026-01-01T00:00:00Z'),
...overrides,
});
const paid = buildInvoice({ status: 'paid' });
const vipInvoice = buildInvoice({ customer: buildUser({ email: 'vip@acme.test' }) });

The call sites now read as a tree of overrides. buildInvoice({ status: 'paid' }) gets a valid customer for free, and never mentions it. When a test does care, it reaches one level down: buildInvoice({ customer: buildUser({ email: 'vip@acme.test' }) }). Each layer names only what it needs.

Partial<User> covers flat objects, which is nearly always the case. When you genuinely need to override a nested field, give that one factory a hand-written shape for it, { customer?: Partial<User> }, rather than reaching for a recursive DeepPartial<T>. Stay flat until a real case forces your hand.

Deterministic defaults: sequences, not randomness

Section titled “Deterministic defaults: sequences, not randomness”

A tempting move is to reach for Math.random() or crypto.randomUUID() inside a factory default to make each user “more realistic.” Don’t. A random default means a failing test can’t be reproduced: the run that failed had a value you’ll never see again, so you re-run, get a different value, watch the test pass, and learn nothing. That is the source of “flaky, can’t repro” bug reports. A fixed email: 'test@example.com' debugs far better than a faker-generated wilma.lakin@example.org that changes every run. A factory default is a fixed, known value on purpose.

Some tests do need distinct values. The integration databases of the next chapter reject duplicate emails, so a test inserting three users needs three different addresses. But the answer to uniqueness is not randomness: it’s a sequence helper, a counter that hands out the next number each time you ask.

const sequence = () => {
let n = 0;
return {
next: () => {
n += 1;
return n;
},
};
};
const userSeq = sequence();
const a = buildUser({ email: `user-${userSeq.next()}@test.com` });
const b = buildUser({ email: `user-${userSeq.next()}@test.com` });

Because the sequence is monotonic , the values are unique; because it always starts from zero, they’re reproducible, so run one and every run after both get user-1, user-2. Unique and deterministic, which is what randomness can’t give you. The one discipline: reset the sequence per test or per worker, never as global module state. A counter shared across the whole module is shared mutable state by another name, and it reintroduces the run-order coupling from the start of the lesson, where test order decides which numbers a test sees.

The same principle is why the factory’s createdAt is a hardcoded instant and its id is a fixed string, not a live Temporal.Now.instant() or a fresh UUID. Time and IDs in a factory default are literals or pulled from a seam your tests control, never a live call to the wall clock or the UUID generator. Pinning them across a whole test, with fake timers and an ID seam, is the next lesson’s subject. For now: factory defaults are frozen, not live.

@faker-js/faker has a real job, generating realistic seed data for a development database, which you met in the database unit. It is not a unit-testing tool. Unseeded faker in a unit test is exactly the flake this section warns against. Use faker for dev seeds, literals and sequences for tests.

The three words factory, fixture, and seed often get used interchangeably, but they name three different tools.

Factory a function Fixture static data Seed a script
What it is Returns a fresh in-memory instance with overrides. Static data, usually a JSON file, used verbatim. Code that populates a database with realistic volume and distribution.
Lives where src/test/factories/ src/test/fixtures/ scripts/seed.ts
Reach for it when You need a per-test domain entity. You need an external payload whose exact shape you don't author and can't safely paraphrase. You need a populated dev database or an integration-test baseline.

You’ve been writing factories all lesson: every user, org, and invoice. You met seeds back in the database unit, built with drizzle-seed in scripts/seed.ts. The line between a seed and a factory is scale, not kind: a seed produces datasets and distributions, a factory produces one row for one test. Fixtures are the case the rest of this section is about.

Conflating the words matters because each confusion is a bug you’ve already seen. A “fixture” that gets mutated is the shared mutable object from the start of this lesson; it should have been a factory. A “seed” reused as a per-test fixture is shared mutable state across tests, which is run-order coupling. The wrong word leads you to the wrong tool.

Reach for a static fixture when the payload comes from outside your system and its exact shape is the thing under test.

A webhook is the clearest case. To test how you handle a Stripe checkout.session.completed event, you need a byte-realistic copy of what Stripe actually sends. Capture it once from Stripe’s test dashboard, save it to src/test/fixtures/stripe/checkout-completed.json, and import it in the test. A buildStripeEvent factory would be wrong here: signature verification, which you’ll add in the next chapter, hashes the raw bytes, so a paraphrased payload would verify differently from the real one. You don’t author Stripe’s schema and shouldn’t approximate it. The same goes for signed JWTs, Resend bounce webhooks, and S3 event notifications: capture the real thing and use it verbatim.

The same principle gives you the maintenance rule. When a fixture goes stale, re-capture it from the source rather than hand-editing the one field your test cares about. A hand-drifted fixture quietly misrepresents the real payload, and a test against a misrepresentation is worse than no test.

One name to retire: the object mother pattern, a function like anExpiredInvoice() that returns a ready-made entity. It’s a factory with an extra layer of indirection. buildInvoice({ status: 'expired' }) reads just as clearly without a new named function per variant, so the override-spread factory already does the object mother’s job. You’ll recognize the term when someone uses it, but it’s nothing new to learn.

Sort each item by the tool you’d reach for: a per-test domain entity is a factory, a captured external payload is a fixture, and bulk database data is a seed.

Each item is something a test suite needs. Sort it by the tool you'd reach for. Drag each item into the bucket it belongs to, then press Check.

Factory A function returning a fresh instance
Fixture Static data used verbatim
Seed Bulk data for a database
One paid invoice for this test’s assertion
An admin user for this auth test
A captured Stripe checkout.session.completed payload
A captured Resend bounce webhook body
50 invoices for the dev list page
A weighted status distribution for screenshots

Implement buildUser so it satisfies all three rules: valid defaults, an overrides-last spread, and a fresh object on every call. The tests check each rule directly, and the fresh-object check is not optional.

Implement buildUser so a no-argument call returns valid defaults, a single override changes only that field, and every call returns a brand-new object. Construct the object literal inside the function body. (In your real file this is typed buildUser(overrides: Partial<User> = {}): User — here it runs untyped so the bundler can execute it.)

    The third test is the one that matters most. If you build the object once and return it from a closure, the first two tests pass and this one fails: mutating a changes b, because they are the same object. That is the cached-object anti-pattern caught in the act, and a factory that can’t be mutated into a shared-state bug is doing its job.

    Libraries like fishery and rosie give you a builder DSL, but for a 2026 web app the right call is to hand-roll it: a factory is about twenty lines, and a library is a dependency plus a DSL to learn for something you can write in a function. Reach for one only when your factories have grown into a genuine domain DSL, which is rare. Resist the chained-builder shape too, buildUser().withRole('admin').build(): that is three calls and a fluent interface to say what buildUser({ role: 'admin' }) says in one.

    Every factory here returned a plain in-memory object, because a /lib function under test takes plain objects as input; factories that insert into a real database arrive in the next chapter, and the next lesson pins time, IDs, and randomness behind seams your tests control.