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.

Hand-written, naively generated, and hint-driven fixtures Three columns. Hand-written fixtures are realistic but drift from the schema and can be invalid. Naively generated fixtures are always valid but unreadable, producing values like string1 and string2. Hint-driven generation maps format and field-name hints to domain generators, producing output that is both valid and plausible while remaining derived from the schema. Hand-written Naively generated Hint-driven "email": "[email protected]" "status": "PAID" "email": "string1" "status": "paid" "email": "[email protected]" "status": "paid" realistic — yes valid — only if maintained drifts — silently realistic — no valid — always drifts — never realistic — yes valid — always drifts — never The enum row is the telling one: the hand-written fixture used the old uppercase value and nothing noticed. Generation removes the class of bug where the fixture and the schema quietly disagree.

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.

Generate, validate, commit A loop in four stages: the schema feeds the generator, which produces candidate records; the validator checks each record back against the same schema; failures report the exact keyword that was not honoured and stop the build; successes are written to the generated fixtures directory. An annotation notes that the validator is what catches an unsupported schema keyword. order.schema.json the single source of truth generate() format and name hints Ajv validate against the same schema generated/orders.json committed and reviewable FAIL — "must match pattern" · exit 1 The validator is the guard against the generator silently not understanding a keyword — pattern, oneOf, additionalProperties. Without it, an unsupported keyword produces plausible-looking fixtures that the real API would reject. Generation without validation is a more confident way of being wrong.

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

  • pattern is 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 a pattern-aware branch, not a looser validator. Add explicit generators for the patterns your schemas actually use.

  • oneOf and anyOf need 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: false makes 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.


Readable as well as valid Three levers that turn valid-but-unreadable output into data you can debug against: format keywords routed to real generators, field-name patterns mapped to domain generators, and declared examples preferred over anything generated. Each is ordered by how much signal it carries. Declared examples first somebody chose that value deliberately the strongest signal available Format keywords email, date-time, uuid, hostname standardised and reliable Field-name patterns name, city, postcode, currency covers the plain strings formats miss Without the third lever most fields are plain strings, which is why naive generation reads as noise.

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.

← Back to Schema-Driven Data Generation