fixture-gen
v1.3.1
Published
Schema-agnostic, deterministic test fixtures for any Standard Schema validator.
Downloads
503
Maintainers
Readme
fixture-gen
The deterministic fixture compiler for TypeScript teams who want realistic, invariant-safe test data from contracts.
Generate realistic, validating mock data from your existing schemas — no adapter code, no manual factories.
✅ Deterministic — same seed → same output, every run, every machine
✅ Schema-driven — works with Zod, Valibot, ArkType, TypeBox out of the box
✅ Zero boilerplate — pass your schema; get back valid data
✅ Nestable & relational — objects, arrays, and cross-table FK linking
✅ Overrideable — pin specific fields without rebuilding the whole fixture
// Before: hand-written, brittle, out of sync with your schema
const user = { id: 'abc', name: 'John', email: '[email protected]', role: 'admin' }
// After: always valid, always in sync
const user = generate(UserSchema)
const admin = generate(UserSchema, { overrides: { role: 'admin' } })The Standard Schema initiative unified the validation layer so frameworks can accept Zod, Valibot, or ArkType through one interface. fixture-gen extends that to fixtures: point it at any Standard Schema-compliant object and get reproducible, constraint-aware test data — no per-library glue needed.
Features
- 🔌 Standard Schema native — works with Zod, Valibot, and ArkType through the shared
~standardinterface, and understands TypeBox schemas directly. No adapter code for you to write — point it at your existing schemas. - 🎲 Seeded determinism — pass a
seedand the same schema always produces the same data, so snapshots and assertions stay stable across runs and machines. - 🔗 Relational generation — generate connected record sets where child rows reference real parent keys (matching foreign keys across tables).
- 🧰 Custom generators — pin exact fields with
overrides, or compute field and schema-wide values with deterministic hooks. - 🪶 Minimal runtime — pure TypeScript, zero binary dependencies. Runs on Node.js, Bun, Deno, and edge runtimes.
- 🎭 Scenario-first — named, intent-bearing test cases:
happy-path,empty-state,boundary-min,boundary-max,invalid,missing-subtree. Define project-specific cases withdefineScenario. - 🔒 Advanced constraints — schema-wide uniqueness (
unique: ['email']), cross-field invariants (refine), and business-rule hooks (rules) acrossgenerateMany/generateRelational. - 🧩 Fully typed — output is inferred from your schema, so fixtures match the types you already validate against.
Planned (see docs/ROADMAP.md for the full post-1.0 plan):
- 🌐 JSON Schema & OpenAPI bridge (Phase 10) — import OpenAPI specs, export Standard Schemas as JSON Schema, bridge AI structured-output contracts
- 🔧 Ecosystem plugins (Phase 11) —
@fixture-gen/vitest,@fixture-gen/jest,@fixture-gen/playwright,@fixture-gen/db(Prisma + Drizzle)
Install
npm install -D fixture-gen
# pnpm add -D fixture-gen
# yarn add -D fixture-gen
# bun add -d fixture-genDeno:
import { generate } from 'npm:fixture-gen'Quick start
import { generate } from 'fixture-gen'
import { z } from 'zod'
const User = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
age: z.number().int().min(18).max(99),
})
const user = generate(User, { seed: 42 })
// {
// id: '1f8c...-uuid',
// name: 'Felicia Bartell',
// email: '[email protected]',
// age: 37,
// }Because fixture-gen only depends on the Standard Schema interface, the exact same call works with any compliant validator:
import * as v from 'valibot'
import { generate } from 'fixture-gen'
const User = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.string(),
})
const user = generate(User, { seed: 42 }) // ✅ no adapter neededimport { type } from 'arktype'
import { generate } from 'fixture-gen'
const User = type({ id: 'string.uuid', name: 'string' })
const user = generate(User, { seed: 42 }) // ✅ works the sameReal-world usage
React component test (Vitest + Testing Library)
import { render, screen } from '@testing-library/react'
import { generate } from 'fixture-gen'
import { UserSchema } from './schemas'
import { UserCard } from './UserCard'
test('renders user name', () => {
const user = generate(UserSchema, { seed: 1 })
render(<UserCard user={user} />)
expect(screen.getByText(user.name)).toBeInTheDocument()
})API handler test
import { generate } from 'fixture-gen'
import { UserSchema } from './schemas'
import { createUser } from './api'
test('creates a user', async () => {
const payload = generate(UserSchema, { seed: 1 })
const result = await createUser(payload)
expect(result.id).toBeDefined()
})Storybook story
import { generate } from 'fixture-gen'
import { UserSchema } from './schemas'
import { UserCard } from './UserCard'
export const Default = {
args: {
user: generate(UserSchema, { seed: 1 }),
},
}
export const Admin = {
args: {
user: generate(UserSchema, { seed: 1, overrides: { role: 'admin' } }),
},
}Deterministic generation
The same seed always yields identical output — ideal for snapshot tests and reproducible CI:
const a = generate(User, { seed: 7 })
const b = generate(User, { seed: 7 })
// a deep-equals b ✅
const c = generate(User, { seed: 8 })
// c differs from a ✅Need a batch? Use generateMany:
import { generateMany } from 'fixture-gen'
const users = generateMany(User, 10, { seed: 42 })
// User[] of length 10, deterministic for the given seedOverride specific fields when a test needs a known value:
const admin = generate(User, {
seed: 42,
overrides: { name: 'Ada Lovelace', age: 36 },
})Use custom generators when a field needs a computed value instead of a fixed one:
const user = generate(User, {
seed: 42,
generators: {
'profile.slug': ({ prng }) => `slug-${prng.string(6)}`,
},
generator: ({ node, pathKey }) => {
if (node.kind === 'string' && pathKey === 'tag') return 'schema-wide'
return undefined
},
})Scenario-first generation
Pass a scenario to get named, intent-bearing test data instead of random values:
import { generate, generateMany, defineScenario } from 'fixture-gen'
// Built-in scenarios
generate(User, { scenario: 'happy-path' }) // valid, representative data
generate(User, { scenario: 'empty-state' }) // optionals absent, arrays []
generate(User, { scenario: 'boundary-min' }) // numbers/strings at min constraints
generate(User, { scenario: 'boundary-max' }) // numbers/strings at max constraints
generate(User, { scenario: 'invalid' }) // wrong-type root — fails validation
generate(User, { scenario: 'missing-subtree' }) // nested objects → nullDefine project-specific cases with defineScenario:
// Overrides object
defineScenario('admin-user', { role: 'admin', active: true })
// Factory function
defineScenario<User>('premium-user', (value) => ({ ...value, plan: 'premium' }))
// Inherit from a built-in, then patch
defineScenario('empty-admin', { extends: 'empty-state', role: 'admin' })Scenarios propagate through generateMany and generateRelational. See docs/scenarios.md for the full guide.
Relational generation
generateRelational builds multiple record sets at once and wires child records to real parent keys, so foreign keys actually resolve:
import { generateRelational } from 'fixture-gen'
import { z } from 'zod'
const User = z.object({
id: z.string().uuid(),
name: z.string(),
})
const Post = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
title: z.string(),
})
const { users, posts } = generateRelational(
{ users: User, posts: Post },
{
seed: 42,
counts: { users: 3, posts: 10 },
relations: {
'posts.userId': 'users.id', // every post.userId is one of the generated users[].id
},
},
)
// posts.every(p => users.some(u => u.id === p.userId)) === true ✅Advanced constraints
Cross-record uniqueness
Pass unique to generateMany to guarantee a field is distinct across every record — no two records share the same value:
import { generateMany } from 'fixture-gen'
// 1000 users, no two share an email
const users = generateMany(UserSchema, 1000, { seed: 42, unique: ['email'] })
// Unique on multiple fields (each is independently unique)
const users2 = generateMany(UserSchema, 500, { seed: 0, unique: ['id', 'email'] })
// Dot notation for nested paths
const records = generateMany(schema, 20, { unique: ['user.email'] })TypeBox arrays marked uniqueItems: true and Zod z.set() schemas are automatically deduplicated at the item level.
Cross-field invariants (refine)
The refine hook runs after each record is generated and may return field overrides to enforce invariants that span multiple fields:
import { generate, generateMany } from 'fixture-gen'
const product = generate(ProductSchema, {
seed: 1,
refine: (r) => {
if (r.discountedPrice >= r.price) {
return { discountedPrice: r.price * 0.8 }
}
},
})
// refine applies to every record in generateMany too
const products = generateMany(ProductSchema, 100, {
seed: 1,
refine: (r) => {
if (r.discountedPrice >= r.price) return { discountedPrice: r.price * 0.8 }
},
})refine composes with unique, overrides, scenario, and custom generators.
Business-rule hooks (rules in generateRelational)
Pass a rules array to generateRelational to enforce cross-table invariants after FK linking:
import { generateRelational } from 'fixture-gen'
const { users, invoices } = generateRelational(
{ users: UserSchema, invoices: InvoiceSchema },
{
seed: 1,
counts: { users: 10, invoices: 50 },
relations: { 'invoices.userId': 'users.id' },
rules: [
(tables) => {
const freeIds = new Set(
(tables.users as Array<{ id: string; plan: string }>)
.filter((u) => u.plan === 'free')
.map((u) => u.id),
)
for (const inv of tables.invoices as Array<{ userId: string; amount: number }>) {
if (freeIds.has(inv.userId)) inv.amount = 0
}
},
],
},
)See docs/advanced-constraints.md for the full guide.
API
Full reference: docs/API.md
generate(schema, options?)
Generate a single fixture from a Standard Schema.
function generate<T>(schema: StandardSchemaV1<unknown, T>, options?: GenerateOptions<T>): TgenerateMany(schema, count, options?)
Generate an array of count fixtures.
function generateMany<T>(
schema: StandardSchemaV1<unknown, T>,
count: number,
options?: GenerateOptions<T>,
): T[]generateRelational(schemas, options)
Generate multiple named record sets with foreign-key relationships resolved between them.
function generateRelational<S extends Record<string, StandardSchemaV1>>(
schemas: S,
options: RelationalOptions<S>,
): { [K in keyof S]: InferOutput<S[K]>[] }Options
interface GenerateOptions<T> {
/** Seed for deterministic output. Same seed → same data. */
seed?: number
/** Force specific top-level field values, bypassing generation. */
overrides?: Partial<T>
/** Field-path keyed custom generators. `*` matches one path segment. */
generators?: Record<string, CustomGenerator>
/** Schema-wide hook that can override any node. */
generator?: CustomGenerator
/** Named scenario controlling generation behavior. */
scenario?: BuiltinScenario | string
/** Field paths (dot-separated) that must be unique across all generateMany records. */
unique?: string[]
/** Post-generation hook: return field overrides to enforce cross-field invariants. */
refine?: (record: T) => Partial<T> | undefined
}
interface GenerateContext {
path: readonly string[]
pathKey: string
node: IntrospectedNode
seed: number
prng: Prng
}
type CustomGenerator = (context: GenerateContext) => unknown
interface RelationalOptions<S> {
seed?: number
/** How many records to generate per schema key. */
counts: { [K in keyof S]?: number }
/** Map `"childTable.field": "parentTable.field"` to link foreign keys. */
relations?: Record<string, string>
/** Named scenario applied to all tables during generation. */
scenario?: BuiltinScenario | string
/** Post-generation hooks: each receives the full row set and may mutate records. */
rules?: Array<(tables: Record<string, unknown[]>) => void>
}defineScenario(name, input)
Register a named scenario for use with generate({ scenario: name }).
type BuiltinScenario =
| 'happy-path' | 'empty-state'
| 'boundary-min' | 'boundary-max'
| 'invalid' | 'missing-subtree'
// Overrides object (may include `extends` to inherit from another scenario)
defineScenario('admin-user', { role: 'admin' })
defineScenario('empty-admin', { extends: 'empty-state', role: 'admin' })
// Factory function
defineScenario<User>('premium-user', (value) => ({ ...value, plan: 'premium' }))Use clearScenarios() in test teardown to avoid cross-test pollution:
import { clearScenarios } from 'fixture-gen'
afterEach(() => clearScenarios())Comparison
| | fixture-gen | zod-fixture / @anatine/zod-mock | faker.js | fast-check | test-data-bot |
| --- | :---: | :---: | :---: | :---: | :---: |
| Schema-agnostic | ✅ | ❌ (Zod only) | ➖ (no schema layer) | ❌ | ❌ |
| Standard Schema native | ✅ | ❌ | ❌ | ❌ | ❌ |
| Seeded determinism | ✅ | ➖ varies | ✅ | ✅ | ❌ |
| Relational / FK generation | ✅ | ❌ | ❌ (manual) | ❌ | ❌ |
| Maps schema → mock automatically | ✅ | ✅ (Zod) | ❌ (write it yourself) | ❌ (write arbitraries) | ❌ (write factories) |
| Field overrides | ✅ | ❌ | ❌ | ❌ | ✅ |
| Runtime dependencies | none | Zod | none | none | none |
| Named scenarios (happy-path, etc.) | ✅ | ❌ | ❌ | ❌ | ❌ |
| Cross-record uniqueness / refine hooks | ✅ | ❌ | ❌ | ❌ | ❌ |
| CLI + drift detection | ✅ | ❌ | ❌ | ❌ | ❌ |
| JSON Schema / OpenAPI import-export | 🔵 Phase 10 | ❌ | ❌ | ❌ | ❌ |
🔵 = planned — see docs/ROADMAP.md
Supported runtimes
Node.js · Bun · Deno · edge runtimes (Cloudflare Workers, Vercel Edge, etc.). Ships ESM with type definitions; no native bindings.
CLI quick-start
Install
npm install -D fixture-gen
# or globally:
npm install -g fixture-genCommands
Generate a fixture to stdout:
fixture-gen generate path/to/schema.js --seed 42
fixture-gen generate path/to/schema.js --format ts --out fixtures/user.tsSave a snapshot to disk (for drift detection):
fixture-gen snapshot path/to/schema.js --dir fixtures/ --seed 42Detect fixture drift:
fixture-gen diff path/to/schema.js --dir fixtures/ --seed 42
# exits 0 if output matches snapshot, 1 if drift detected
# Machine-readable JSON diff (for PR bots):
fixture-gen diff path/to/schema.js --dir fixtures/ --format jsonWatch for schema changes during development:
fixture-gen watch path/to/schema.js --seed 42
# prints changed fields as you save the schema fileOutput formats
| Flag | Output |
|------|--------|
| --format json | Pretty-printed JSON (default) |
| --format jsonl | Single-line JSON (for piping) |
| --format ts | export const fixture = {...} as const |
Schema file format
The CLI expects a .js or .mjs file exporting a Standard Schema validator as its default export:
// schemas/user.js
import { z } from 'zod'
export default z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
})TypeScript schemas: Compile with
tscfirst, or run the CLI viatsx/bun:bunx fixture-gen generate schemas/user.ts --seed 1
CI integration — drift detection
Add fixture drift detection to your CI pipeline so schema changes that silently alter fixture shape are caught before merge:
# .github/workflows/ci.yml
- name: Check fixture drift
run: |
fixture-gen diff schemas/user.js --dir fixtures/ --seed 0
fixture-gen diff schemas/post.js --dir fixtures/ --seed 0When a schema changes in a way that alters generated output, fixture-gen diff exits non-zero and prints what changed. Update the stored snapshot intentionally with fixture-gen snapshot and commit the updated .json files to document the change.
Full example workflow:
- Commit:
fixture-gen snapshot schemas/user.js --dir fixtures/→ commitfixtures/user-default-seed0.json - CI:
fixture-gen diff schemas/user.js --dir fixtures/runs on every PR - Schema change detected: CI fails, developer runs
fixture-gen difflocally to review, updates snapshot, commits
FAQ
Does it support custom field generators?
Yes — use overrides for fixed top-level values, generators for field-path keyed computed values, and generator for schema-wide hooks.
How does it pick realistic values?
It inspects the schema's type and constraints (formats like uuid/email, min/max, length, enums, patterns) and generates values that satisfy them — seeded so they stay reproducible.
What happens with a schema type it doesn't understand?
Unsupported or opaque types fall back to a constraint-satisfying placeholder. You can always pin those fields with overrides, and unknown formats surface a warning so they're easy to spot.
What about TypeBox formats?
TypeBox schemas work directly, but its runtime checker only validates registered formats. If you use format: "uuid" or similar, register the matching predicate in TypeBox's FormatRegistry before validating generated values.
Will generated data pass my validator?
That's the goal: output is produced to satisfy the same schema you validate against, so schema.parse(generate(schema)) succeeds.
Contributing
Issues and pull requests are welcome. Please open an issue to discuss substantial changes before submitting a PR.
