Generating Mock Data from JSON Schema
Your fixtures were written by hand from the schema, and three of them now violate it — a missing required field, an enum value that was renamed, a number outside its range. This page generates the fixtures from the schema instead, so they are valid by construction and change automatically when the contract does.
Context: hand-written fixtures encode beliefs
A fixture written by a person records what that person understood the schema to say at the moment they wrote it. Every subsequent schema change leaves it behind, silently, because nothing checks the two against each other.
Generation inverts the relationship. The schema becomes the single source of truth and the fixtures a derived artefact, so a contract change produces a fixture diff in the same commit. That is the same property validating mock responses against OpenAPI enforces from the other direction.
The tension is realism. A naive generator satisfies the schema and produces unreadable output — "name": "string1", "city": "string2" — which makes every fixture-driven screenshot useless. The work is in mapping the schema’s own hints to generators that produce plausible values.
Solution
1. Walk the schema
// src/fixtures/from-schema.ts
import { faker } from './faker';
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
required?: string[];
items?: JsonSchema;
enum?: unknown[];
format?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
minItems?: number;
maxItems?: number;
}
/** Include an optional field roughly this often, deterministically. */
const OPTIONAL_RATE = 0.7;
export function generate(schema: JsonSchema, fieldName = ''): unknown {
if (schema.enum?.length) return faker.helpers.arrayElement(schema.enum);
switch (schema.type) {
case 'object': {
const out: Record<string, unknown> = {};
const required = new Set(schema.required ?? []);
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
// Optional fields appear sometimes, so both branches of the UI are exercised.
if (!required.has(key) && faker.number.float({ min: 0, max: 1 }) > OPTIONAL_RATE) continue;
out[key] = generate(sub, key);
}
return out;
}
case 'array': {
const min = schema.minItems ?? 1;
const max = schema.maxItems ?? Math.max(min + 2, 3);
const n = faker.number.int({ min, max });
return Array.from({ length: n }, () => generate(schema.items ?? {}, fieldName));
}
case 'integer':
return faker.number.int({ min: schema.minimum ?? 0, max: schema.maximum ?? 10_000 });
case 'number':
return Number(
faker.number.float({ min: schema.minimum ?? 0, max: schema.maximum ?? 10_000, fractionDigits: 2 })
);
case 'boolean':
return faker.datatype.boolean();
case 'null':
return null;
default:
return generateString(schema, fieldName);
}
}
Respecting minimum/maximum is not pedantry. A generator that emits -1 for a field the schema bounds at zero produces a fixture that renders a negative total, and the resulting bug report blames the component.
2. Map formats and field names to real generators
// src/fixtures/from-schema.ts (continued)
const BY_FORMAT: Record<string, () => string> = {
email: () => `${faker.string.alphanumeric({ length: 8, casing: 'lower' })}@example.invalid`,
uri: () => faker.internet.url(),
uuid: () => faker.string.uuid(),
'date': () => faker.date.between({ from: '2026-01-01', to: '2026-12-31' }).toISOString().slice(0, 10),
'date-time': () => faker.date.between({ from: '2026-01-01', to: '2026-12-31' }).toISOString(),
ipv4: () => faker.internet.ipv4(),
hostname: () => faker.internet.domainName(),
};
const BY_NAME: Array<[RegExp, () => string]> = [
[/^(first|given)Name$/i, () => faker.person.firstName()],
[/^(last|family|sur)Name$/i, () => faker.person.lastName()],
[/name$/i, () => faker.person.fullName()],
[/^(city|town)$/i, () => faker.location.city()],
[/^(postcode|postalCode|zip)$/i, () => faker.location.zipCode()],
[/^country/i, () => faker.location.country()],
[/^currency/i, () => faker.helpers.arrayElement(['GBP', 'EUR', 'USD'])],
[/^(description|summary|note)s?$/i, () => faker.lorem.sentence()],
[/^(phone|tel)/i, () => `+44 7700 9${faker.string.numeric(5)}`],
];
function generateString(schema: JsonSchema, fieldName: string): string {
if (schema.format && BY_FORMAT[schema.format]) return BY_FORMAT[schema.format]();
for (const [pattern, gen] of BY_NAME) if (pattern.test(fieldName)) return gen();
const min = schema.minLength ?? 4;
const max = schema.maxLength ?? Math.max(min + 8, 12);
return faker.string.alpha({ length: { min, max } });
}
The BY_NAME table is where readability comes from. format alone covers a handful of cases; most fields are plain strings whose meaning is only in their name, and matching on it is what turns a wall of noise into a fixture you can read.
Order matters in that table: the specific firstName pattern must precede the general name$ one, or every name field becomes a full name.
3. Validate the output back against the schema
Generation is only trustworthy if it is checked. Ajv closes the loop:
// scripts/build-fixtures.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { faker } from '../src/fixtures/faker';
import { generate, type JsonSchema } from '../src/fixtures/from-schema';
const schema = JSON.parse(readFileSync('schemas/order.schema.json', 'utf8')) as JsonSchema;
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const validate = ajv.compile(schema);
faker.seed(20260731);
const records = Array.from({ length: 50 }, () => generate(schema));
let invalid = 0;
for (const [i, record] of records.entries()) {
if (!validate(record)) {
invalid += 1;
console.error(`record ${i} is invalid:`, ajv.errorsText(validate.errors, { separator: '\n ' }));
}
}
if (invalid) {
console.error(`\nbuild-fixtures: ${invalid}/${records.length} generated record(s) violate the schema.`);
process.exit(1);
}
mkdirSync('src/fixtures/generated', { recursive: true });
writeFileSync('src/fixtures/generated/orders.json', JSON.stringify(records, null, 2) + '\n');
console.log(`build-fixtures: ${records.length} valid record(s)`);
This is the step that makes the whole approach safe. When someone adds a keyword the generator does not understand — pattern, oneOf, additionalProperties: false — the validator fails the build immediately rather than letting subtly wrong fixtures spread.
Verification
npx tsx scripts/build-fixtures.ts # exits non-zero on any invalid record
npx tsx scripts/build-fixtures.ts && git diff --exit-code src/fixtures/generated
The second command proves the output is deterministic and that the committed fixtures are current. A diff here means either a seed problem or a schema change nobody regenerated for.
Gotchas and edge cases
-
patternis not satisfied by a generic string. A schema constraining a field to^ORD-[0-9]{6}$will reject anything the default string generator produces, so the validator fails and the build stops — which is correct, but the fix is apattern-aware branch, not a looser validator. Add explicit generators for the patterns your schemas actually use. -
oneOfandanyOfneed a deliberate choice. Picking the first branch every time means the other variants are never rendered. Choose among them with the seeded generator so every variant appears across a fixture set, and the union-handling code gets exercised. -
additionalProperties: falsemakes the name-hint table dangerous. If a hint adds a property the schema does not declare, validation fails. Only ever generate declared properties — the hint table should decide how to fill a field, never whether the field exists.
Treating the hint table as domain knowledge
The generator is generic; the hint table is not. It encodes what your fields mean, and that makes it the part worth maintaining carefully and the part a newcomer cannot reconstruct from the schema.
A field named reference might be an order reference, a payment reference or a free-text customer note, and the schema says only that it is a string. Whoever writes the hint decides, and that decision is domain knowledge that exists nowhere else in the codebase.
Three practices keep the table trustworthy.
Comment the non-obvious entries. A rule mapping code to a currency code rather than a product code deserves a line explaining why, or the next person will change it and quietly alter dozens of fixtures.
Order from specific to general. Patterns are evaluated in order, so a rule for firstName must precede a rule for anything ending in Name. Getting this backwards produces plausible-looking output that is subtly wrong everywhere, which is harder to notice than an obvious failure.
Review it when the schema changes. A new field arrives with no hint and falls through to the generic string generator, which is valid and unreadable. Because the classification map already fails the build on unclassified fields, adding the same discipline to hints — fail on a string field with no matching rule — keeps the two in step.
There is a pleasant side effect of maintaining the table well: it becomes a compact description of the domain’s vocabulary. Reading it tells you which fields exist across the API, what they mean and which ones are related — which is often clearer than reading the schema itself, and considerably shorter.
Related
- Generating Mock Data from an OpenAPI Spec — the same technique applied to a whole API surface
- Validating Mock Responses Against OpenAPI — closing the loop from the response side
- Seeding Faker for Reproducible Test Data — making the generated output identical run to run
← Back to Schema-Driven Data Generation