Factories over shared fixtures
Build test data with factory functions that return a fresh, valid domain object on every call, the pattern that keeps your Vitest 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. You know the AAA shape now, and you know 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 tests 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. This lesson walks you into both before fixing them.
The previous lesson set a rule: when arrange grows past three lines, the fixture moves to a factory. This lesson cashes that rule out. By the end 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.
The shared-fixture trap
Section titled “The shared-fixture trap”Here is the file you’d actually write. A User shape with a handful of fields, hoisted to the top, shared by every test below it.
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);});Read the second test. It needs an admin, so it does the natural thing: it reaches into the shared object and sets baseUser.role = 'admin'. That test passes, the first test passes, and the file runs green. So you ship it.
Now someone reorders the file, or Vitest runs the tests in a different order, or a third test lands between these two. Suddenly the first test sees role: 'admin', a value it never set and never asked for, one that contradicts its own name. It fails. Whoever picks up that failure will stare at a test called “lists a member” that’s somehow looking at an admin, find nothing wrong with the test itself, re-run it, watch it pass, and write it off as flaky. What they’re actually seeing is run-order coupling : the test’s outcome depends on whether, and in what order, other tests ran first.
It is not flaky. The last lesson made the point that flake has a structural cause, and here the cause is in plain sight: the two tests share one mutable object, and one of them writes to it. Whether the first test passes now depends on whether the second test ran first. That’s the whole bug.
canSeeBilling(baseUser) expects false → FAILS role: 'member' baseUser.role = 'admin' expects true → passes .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.
Maybe you spot the mutation and decide to dodge it. The third test needs a plain member again, so rather than risk the shared object you copy the literal, paste it, and tweak the one field you care about. Now there are two near-identical object literals drifting apart in the same file. When the User shape gains a field, you update one and forget the other. Worse, a reader looking at a fifteen-field literal can’t tell which of those fields the test actually depends on and which are only there to make the object valid. The data buries the intent.
Here is the diagnosis. The problem was never the data, since every test legitimately needs a user. The problem is that the data is shared and mutable. There are only two ways out. You can deep-clone the object on every read, which is noisy, easy to forget, and does nothing for the readability problem. Or you can stop storing the data and start producing it, with a function that hands back a brand-new instance every time you call it. That function is a factory, and it’s the better escape because it solves both problems at once: every test gets its own object, and every test names only the field it cares about.
The factory shape
Section titled “The factory shape”A factory is a function that returns a fresh domain object. The whole pattern is three rules, taken one at a time, because each one fixes something that breaks without it.
Rule one: every required field gets a valid default. Start with the simplest possible buildUser: no arguments yet, just a function that returns a fully formed User.
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 in “valid default” is valid. Look at email: 'test@example.com': that’s a real, schema-passing address. The temptation is to write email: 'TODO' or email: 'xxx' because it’s “just a test” and the email doesn’t seem to matter. Resist it. The moment a test exercises validation, a placeholder email gives you a false negative : the test passes, your validator is broken, and nobody finds out. A factory default has to be an instance your production code would actually accept. Defaults respect your domain invariants, so buildUser never produces an address that fails validation, the same way buildInvoice later won’t produce negative line totals unless you explicitly ask for them.
Rule two: overrides is the last spread. Right now buildUser() always returns a member. The test that needs an admin can’t get one without mutating the result, and the previous section showed why mutation is the problem. So we let the caller pass the fields it cares about, and we spread them in last.
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> means “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 clearly: a user that is an admin. The id, the email, the orgId, and the createdAt are all valid, all present, all irrelevant to this test, and so all invisible. The test documents its own inputs. That’s the readability win the copy-pasted literal could never give you.
Rule three: a fresh object every call. This one is subtle because it’s about what you don’t do. The object literal is constructed inside the function body, so every call evaluates it again and you get a new object every time. buildUser() and buildUser() are two different references that share no memory. That closes the bug from the last section 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 on 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 from the start of the lesson wearing a function’s clothes: every caller mutates and receives the same object. The fresh-object-per-call rule is the entire reason the pattern works, so don’t optimize it away.
Here is the finished factory. It’s small, but every part is load-bearing, so let’s walk through it.
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: it takes an optional subset of User and always returns a complete User. The = {} default lets you call buildUser() with no arguments at all.
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, a real role, a real instant. Never a placeholder, because each one has to be a value your production code would actually 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 over the defaults. This is the line that lets a test name only the one 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 straight from the Drizzle schema, so the factory and the database agree by construction. This is where the next payoff comes from.
That last step is worth dwelling on, because it’s where the factory pays for itself. Notice the return type isn’t a hand-written User interface. It’s InferSelectModel<typeof users> , the type Drizzle infers from your schema. You met this in the database unit: the schema is the single source of truth, and the row type flows out of it. By typing buildUser off that same inferred type, you’ve wired the factory directly to the schema.
Watch what happens on a migration. You add a required timeZone column to the users table. Every place that constructs a User is now missing a field, including buildUser. TypeScript fails the build inside the factory, at exactly one line. You add timeZone: 'UTC' to the defaults once, and every test that calls buildUser keeps compiling, untouched. 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. That’s the second payoff, and it’s why developers who’ve been burned reach for factories without thinking: the win lands on the third schema migration, and there’s always a third.
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 actually depends on.
it('grants admin access', () => { const user = buildUser({ role: 'admin' }); expect(canSeeBilling(user)).toBe(true);});Fresh instance, self-documenting. Nothing is shared, so nothing leaks, and the call site says exactly what this test cares about: the role.
Where factories live
Section titled “Where factories live”A factory is shared test infrastructure, and the course gives it one canonical home: src/test/factories/, one file per entity. users.ts exports buildUser, orgs.ts exports buildOrg, invoices.ts exports buildInvoice. The filename matches the entity; the export matches the file. No guessing where buildInvoice lives.
Directorysrc/
Directorytest/
Directoryfactories/
- users.ts
buildUser - orgs.ts
buildOrg - invoices.ts
buildInvoice
- users.ts
Directorymatchers/ custom
Resultmatchers, from the previous lesson- …
Directoryfixtures/ static external payloads, covered below
- …
- clock.ts the frozen-time seam, next lesson
You might wonder why factories don’t follow the colocation rule from the last lesson, the one that says a test sits right next to the file it tests. The answer is in what colocation is for. Colocation pairs a test with its one source file so they move, rename, and delete together. A factory has no single source file. The same buildUser is imported by /lib unit tests today and by integration tests in the next chapter, so it’s cross-cutting test infrastructure, not a test of any one module. Cross-cutting test support lives under src/test/. You can see its other tenants in the tree above: the toBeOkResult and toBeErrResult matchers from the last lesson live in src/test/matchers/ for the same reason, and the next two lessons fill in clock.ts and fixtures/.
One rule carries straight over from the last lesson: dependency direction. Factories import domain types from @/db or @/lib, never from app/**. A factory reaching up into your route handlers or pages reverses the dependency arrow, since test support should depend on the domain, not on the application surface. It also trips the same no-restricted-paths lint rule that guards your /lib tests. Point the imports down toward the schema, never up toward the app.
Composing factories
Section titled “Composing factories”An Invoice has a customer. You could make every invoice test build a full User first and pass it in, but that’s friction the factory should absorb. So buildInvoice defaults its customer to a call to buildUser:
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 perfectly valid customer for free: you didn’t ask about the customer, so you don’t see it. And when a test does care about the customer, it reaches one level down: buildInvoice({ customer: buildUser({ email: 'vip@acme.test' }) }). The composition runs all the way down, each layer naming only what it needs.
Composition has one trap sharp enough to earn its own warning.
A word on override depth. Partial<User> covers flat objects, which is the common case by far. 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> type. DeepPartial exists, and you’ll see it in the wild, but most factories never need it, and reaching for it early buys you a confusing type for a problem you don’t have. Stay flat until a real case forces your hand.
Deterministic defaults: sequences, not randomness
Section titled “Deterministic defaults: sequences, not randomness”Here’s a move that looks helpful and quietly hurts you: reaching 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. Unit tests want deterministic data: email: 'test@example.com' debugs far better than a faker-generated wilma.lakin@example.org that’s different every run. A factory default is a fixed, known value on purpose.
You might object that some tests need distinct values, and that’s true. Integration databases in 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` });The sequence is monotonic , meaning it only ever counts up, so the values are unique. It’s also reproducible: run one always gets user-1, user-2, and the run after gets the same. Unique and deterministic, which is exactly what randomness can’t give you. The one discipline is that the sequence resets per test or per worker, never as global module state. A counter shared across the whole module is shared mutable state by another name, which reintroduces the run-order coupling from the start of the lesson: test order would now decide which numbers a test sees.
You may have noticed 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. That’s deliberate, and it follows the same principle: time and IDs in a factory default are either literals or pulled from a seam your tests control, never a live call to the wall clock or the UUID generator. The machinery for pinning time and IDs across a whole test (fake timers, the clock module, the ID seam) is the next lesson’s entire subject. For now, hold the rule: factory defaults are frozen, not live.
One last placement note, because it sets up the next section. @faker-js/faker is a real tool with a real job: generating realistic seed data for a development database, which you met back in the database unit. It is not a unit-testing tool. Unseeded faker in a unit test is the flake source this section just spent its time avoiding. Use faker for dev seeds, and literals and sequences for tests.
Factories vs fixtures vs seeds
Section titled “Factories vs fixtures vs seeds”Faker-for-seeds points at a distinction that trips up real teams constantly. The three words factory, fixture, and seed often get used interchangeably, but they are not interchangeable. You’ll hear all three on the job, so here’s each one by what it is and when you reach for it.
| 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. |
A factory is a function that produces a fresh instance with overrides. You reach for it for per-test domain entities: every user, org, and invoice you’ve built so far.
A fixture is static data, used verbatim, typically a JSON file you load and pass straight through. You reach for it when the data is an external payload whose exact bytes matter and which you neither author nor want to paraphrase.
A seed is code that populates a database with realistic volume and distribution: fifty invoices across a spread of statuses so the dev list page looks real. You met seeds in the database unit, built with drizzle-seed in scripts/seed.ts. The boundary is clean. A seeder is for datasets and distributions, a factory is for individual per-test rows. Same conceptual building block, completely different scale and purpose.
Why does conflating them matter? Because each confusion is a bug you already know. A “fixture” that gets mutated is just the shared mutable object from the start of this lesson, and 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 again. The words name different tools for different jobs, so using the wrong word leads you to the wrong tool.
Static fixtures for external payloads
Section titled “Static fixtures for external payloads”So when is a static fixture the right call instead of a factory? Exactly when the payload comes from outside your system and its precise shape is the thing under test.
The clearest case is a webhook. To test how you handle a Stripe checkout.session.completed event, you need a byte-realistic copy of what Stripe actually sends. You capture it once from Stripe’s test dashboard, save it to src/test/fixtures/stripe/checkout-completed.json, and import it in the test. Why static and not a buildStripeEvent factory? Because the exact shape matters. Signature verification, which you’ll do 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 you don’t want to approximate it. The same logic applies to signed JWTs, Resend bounce webhooks, and S3 event notifications: capture the real thing, store it, use it verbatim.
The maintenance rule follows from the same principle. When a fixture goes stale, you update it by re-capturing from the source, not by hand-editing the single field your test is about. A hand-drifted fixture quietly misrepresents the real payload shape, and a test against a misrepresentation is worse than no test.
One last name to retire. You’ll come across the object mother pattern, a function like anExpiredInvoice() that returns a ready-made entity. It’s the same idea as a factory with one more layer of indirection: buildInvoice({ status: 'expired' }) reads just as clearly, and you don’t have to write a new named function for every variant. The override-spread factory already does the object mother’s job, so the course uses the factory. You’ll recognize the term when someone says it, and now you’ll know it’s not something new to learn.
Sort these into the right bucket. The split you want is: a per-test domain entity is a factory, a captured external payload is a fixture, and bulk realistic 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.
checkout.session.completed payloadBuild a user factory
Section titled “Build a user factory”Time to write one yourself. Implement buildUser so that it satisfies all three rules: valid defaults, an overrides-last spread, and a fresh object on every call. The tests check each rule directly, including the one that catches a cached object, so the fresh-instance rule isn’t 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 built the object once and returned it from a closure, the first two tests would pass and that one would fail: mutating a would change b, because they’d be the same object. That failure is the whole point, since it’s the cached-object anti-pattern caught in the act. A factory you can’t mutate into a shared-state bug is a factory that’s doing its job.
Where this goes next
Section titled “Where this goes next”You now have the move that recurs in every chapter from here on: test data is produced by a function that returns a fresh instance, never stored as a shared constant. You can build a typed factory, compose factories without looping, keep defaults deterministic, and pick the right one of factory, fixture, or seed for the job in front of you.
A note for when your factories get bigger. There are libraries for this, like fishery and rosie, that give you a builder DSL. For a 2026 Next.js SaaS, the right call is to hand-roll it: a factory is about twenty lines, and a library is a dependency plus a small DSL to learn for something you can write in a function. Reach for a library only when your factories have grown into a genuine domain DSL, which is rare. And resist the chained-builder shape some libraries encourage, buildUser().withRole('admin').build(): that’s three calls and a fluent interface to express what buildUser({ role: 'admin' }) says in one. The override-spread shape is enough.
Every factory in this lesson returned a plain in-memory object, with no database, no await, and nothing inserted. That’s deliberate: a /lib unit under test is a pure function, and its inputs are plain objects. The factory that inserts into a real database, the signed-in-user fixture and the rolled-back org seed, is the integration layer’s job, and it’s coming in the next chapter. Right next door, the following lesson takes the deterministic-defaults thread started here and makes it rigorous, pinning time, IDs, and randomness behind seams your tests control.
External resources
Section titled “External resources”thoughtbot's TypeScript factory library — the override-spread, sequence, and association API of this lesson, productized for when your factories outgrow twenty lines.
Martin Fowler on the named-fixture pattern this lesson retires, and why exact-data coupling pushed teams toward the override-spread factory instead.
Vitest's docs on per-file isolation and the pools that enforce it — the machinery that makes a fresh-instance factory matter at scale.