Factory Functions vs. Fixtures in Vitest
Most teams reach for a static JSON fixture the first time a test needs an object, and it works — until the second test needs almost the same object with one field changed, and the third needs it with a different field changed, and six months later a single user.json is imported by forty specs and cannot be touched. This guide is for engineers writing Vitest 3.x suites in TypeScript who need a principled rule for when to build data with a factory function and when to load a static fixture, and how to keep the two from silently drifting. The short answer is that factories are the default for anything you assert on or vary, fixtures are for large, stable, externally-defined payloads you treat as opaque — and the failure that pushes teams toward factories is almost always shared mutable state. We will build both, show where each earns its place, and wire the factory into Vitest’s own test.extend fixture system so setup travels with the test.
Root Cause Analysis
The pain of static fixtures comes from a single property: they are shared and, in JavaScript, shared object references are mutable. When twenty tests import user from './user.json', they all receive the same object. One test that does user.role = 'admin' to set up its scenario has now changed the object every other test sees, and because the mutation happens at runtime in one file while the assertion fails in another, the failure is order-dependent and nearly impossible to attribute. This is the same class of bug that a deliberate approach to resetting state between tests without slowing CI addresses at the database tier, except here the shared state is a plain object in module scope.
The deeper root cause is that a fixture couples every consumer to the entire shape of the data, not just the part under test. A test that only cares about role still transitively depends on email, createdAt, and every other field, so when the schema gains a required field, every fixture-based test must be updated even though none of them assert on it. A factory inverts this: it owns the definition of a complete, valid default and exposes overrides, so each test declares only the field it cares about and inherits the rest. The result is tests that read as specifications — makeUser({ role: 'admin' }) says exactly what matters — and that survive schema growth without edits. This mirrors the reproducibility discipline in deterministic seeding for test data in Vitest: make the intent explicit and the incidental implicit.
Reproducible Setup
Create a minimal project with a typed domain and both approaches side by side.
npm install -D vitest typescript
// src/types.ts
export interface User {
id: string;
email: string;
role: 'member' | 'admin';
createdAt: Date;
}
export interface Post {
id: string;
authorId: string;
title: string;
published: boolean;
}
// fixtures/legacy-user.json — a static fixture we will contrast with a factory
{
"id": "u-legacy-1",
"email": "legacy@example.test",
"role": "member",
"createdAt": "2026-01-01T00:00:00.000Z"
}
Implementation
Step 1 — Write a factory with a complete, valid default and shallow overrides. The default must be a fully valid entity so a test that overrides nothing still gets something the code accepts. A monotonically increasing sequence keeps ids unique without randomness.
// factories/user.ts
import type { User } from '../src/types';
let seq = 0;
export function makeUser(overrides: Partial<User> = {}): User {
seq += 1;
return {
id: `user-${seq}`,
email: `user-${seq}@example.test`,
role: 'member',
createdAt: new Date('2026-01-01T00:00:00Z'),
...overrides,
};
}
Step 2 — Compose factories to express relationships. A post belongs to a user, so the post factory builds a user by default but lets the caller pass one in. Composition keeps referential data valid without hand-wiring ids.
// factories/post.ts
import type { Post, User } from '../src/types';
import { makeUser } from './user';
export function makePost(
overrides: Partial<Post> = {},
author: User = makeUser(),
): Post {
return {
id: `post-${author.id}`,
authorId: author.id,
title: 'Untitled',
published: false,
...overrides,
};
}
Step 3 — Keep a fixture only for large, opaque, externally-owned payloads. A 400-line API response you assert nothing structural about is a fixture, not a factory. Load it with assert { type: 'json' } and, crucially, clone it on read so consumers cannot mutate the shared module.
// fixtures/index.ts
import raw from './legacy-user.json' with { type: 'json' };
import type { User } from '../src/types';
// structuredClone prevents the shared-reference mutation trap
export function loadLegacyUser(): User {
const copy = structuredClone(raw);
return { ...copy, createdAt: new Date(copy.createdAt) };
}
Step 4 — Promote the factory into a Vitest fixture with test.extend. When a suite repeatedly needs the same constructed object, expose it through Vitest’s fixture API so it is created lazily and torn down automatically. This is the bridge into sharing test fixtures across a Vitest workspace.
// tests/fixtures.ts
import { test as base } from 'vitest';
import { makeUser } from '../factories/user';
import { makePost } from '../factories/post';
import type { Post, User } from '../src/types';
export const test = base.extend<{ user: User; post: Post }>({
user: async ({}, use) => {
await use(makeUser());
},
post: async ({ user }, use) => {
await use(makePost({}, user));
},
});
Step 5 — Consume the fixture in a spec. The test names only what it needs; Vitest injects the constructed objects and disposes of them after the test body.
// tests/post.test.ts
import { expect } from 'vitest';
import { test } from './fixtures';
test('a post references its author', ({ post, user }) => {
expect(post.authorId).toBe(user.id);
expect(post.published).toBe(false);
});
Verification
Prove the factory isolates instances and the fixture cannot leak. The isolation test mutates one factory result and confirms a second result is unaffected; the fixture test mutates a loaded copy and confirms a second load is pristine.
// tests/isolation.test.ts
import { describe, it, expect } from 'vitest';
import { makeUser } from '../factories/user';
import { loadLegacyUser } from '../fixtures';
describe('data isolation', () => {
it('factory returns independent instances', () => {
const a = makeUser();
a.role = 'admin';
const b = makeUser();
expect(b.role).toBe('member');
});
it('cloned fixture never leaks a mutation', () => {
const first = loadLegacyUser();
first.email = 'mutated@example.test';
const second = loadLegacyUser();
expect(second.email).toBe('legacy@example.test');
});
});
Run npx vitest run and confirm both pass. Then run the suite with --sequence.shuffle several times; a factory-based suite is invariant to order, which is the real proof that no shared mutable state remains. If any spec fails only under shuffle, a shared reference is still in play.
Troubleshooting
Symptom: a test passes alone but fails in the full run. Diagnosis: a shared object is being mutated across tests, almost always a fixture imported directly rather than cloned, or a module-scope object reused between specs. Fix: route every consumer through a factory or a structuredClone-ing loader so each test gets a fresh instance, and never export a mutable object from a fixture module.
Symptom: adding a required field breaks dozens of tests at once. Diagnosis: those tests depend on static fixtures that must each be edited to add the field. Fix: replace the fixtures with a factory that owns the default for the new field; consumers that do not assert on it need no change, because the factory supplies it.
Symptom: test.extend fixtures are undefined in the test body. Diagnosis: the spec imported test from vitest directly instead of from your extended module, so the custom fixtures were never registered. Fix: import test from ./fixtures (your base.extend export) everywhere you rely on the injected values, and never mix the two imports in one file.
FAQ
Should I ever use a static fixture at all?
Yes, for large payloads you treat as opaque and do not vary — a recorded third-party API response, a sample document, a binary blob’s metadata. The rule is that a fixture is appropriate when no test asserts on its internal structure and no test needs a variant of it. The moment a test wants “the same but with one field changed”, that is the signal to switch to a factory, because expressing variants is exactly what factories do well and fixtures do badly.
How do factories differ from Vitest’s test.extend fixtures?
They operate at different layers and compose. A factory is a plain function that builds a value with no framework involvement, usable anywhere including production-adjacent scripts. A test.extend fixture is Vitest’s mechanism for injecting a value into a test with automatic setup and teardown. The idiomatic pattern is to build the value with a factory inside a test.extend fixture, so the factory owns construction and the fixture owns lifecycle. One does not replace the other.
Do factories work with faker for realistic data?
They compose cleanly. Keep the factory as the single construction point and let a seeded faker supply the leaf values, so you get realistic variety without giving up reproducibility. This is covered in depth in the guide on generating realistic fake data with faker; the key is seeding faker per file so the generated values are the same on every run.
How do I keep factory output and my real schema in sync?
Type the factory’s return against your domain type — makeUser(): User — so TypeScript fails the build if the factory ever produces a shape that no longer matches. This turns schema drift into a compile error at the factory definition, a single place, rather than a runtime failure scattered across specs. For database-backed shapes, derive the type from your schema definition so the factory and the table cannot diverge silently.
Related
- Back to Test Data Management
- Sharing test fixtures across a Vitest workspace — expose these factories as workspace-wide
test.extendfixtures. - Generating realistic fake data with faker — feed factory leaves from a seeded generator.
- Seeding a test database for integration tests — persist factory output into a real database.
- Deterministic seeding for test data in Vitest — the reproducibility principle behind these factories.