从 JSON Schema 自动生成测试数据:构建生产级 Mock 数据引擎

深入讲解如何从 JSON Schema 自动生成逼真测试数据,涵盖 Schema 解析、$ref 引用解析、oneOf/allOf 组合处理、faker 集成与性能优化,附完整 TypeScript 实现与工具对比。

开发者效率 2026-06-10 18 分钟

API 开发中最令人头疼的环节之一,不是写接口文档,而是准备测试数据。当你的 JSON Schema 定义了 30 个字段、嵌套 5 层、包含 8 种 oneOf 分支时,手写 Mock 数据不仅效率低下,还极易遗漏边界场景。据统计,开发团队平均花费 15-20% 的 API 开发时间在构造测试数据上,而这些数据往往在 Schema 变更后就彻底失效。本文将从零构建一个能自动解析 JSON Schema 并生成逼真测试数据的引擎,覆盖 $ref 引用解析、组合关键字处理、类型推断等核心难点,并与 json-schema-faker@faker-js/faker 等主流工具做深度对比。

🔧 一、核心架构:Schema 解析与数据生成管线

构建 Mock 数据引擎的核心思路是:将 JSON Schema 视为一棵 AST(抽象语法树),递归遍历每个节点,根据类型约束和格式提示生成符合要求的数据。整个管线分为三个阶段:Schema 预处理、类型解析、数据生成。

1.1 JSON Schema 关键字速查

在实现之前,先厘清 JSON Schema 中影响数据生成的核心关键字:

关键字 作用 对生成器的影响 示例
type 约束值类型 决定生成哪种数据 "type": "string"
enum 枚举值列表 从中随机选取 "enum": ["A","B","C"]
const 固定常量 直接返回该值 "const": "fixed"
minimum / maximum 数值范围 限定生成区间 "minimum": 1, "maximum": 100
minLength / maxLength 字符串长度 控制生成长度 "minLength": 5
pattern 正则约束 用 randexp 生成 "pattern": "^[A-Z]{3}$"
format 格式提示 映射到 faker 方法 "format": "email"
$ref 引用其他 Schema 递归解析引用 "$ref": "#/defs/User"
oneOf / anyOf 组合约束 随机选取一个分支 详见下文
allOf 合并约束 深度合并后生成 详见下文

📌 记住:enumconst 的优先级最高。当它们出现时,不需要考虑其他约束,直接使用即可。

1.2 最小可行实现:类型到数据的映射

先实现一个最基础的生成器,能处理简单类型:

// json-mock-generator.ts — 最小可行的 JSON Schema Mock 数据生成器
type Schema = Record<string, any>;

// 基础随机工具函数
const randomInt = (min: number, max: number) =>
  Math.floor(Math.random() * (max - min + 1)) + min;

const randomString = (length: number) => {
  const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  return Array.from({ length }, () => chars[randomInt(0, chars.length - 1)]).join('');
};

// 根据 type 生成基础数据
function generateByType(type: string, schema: Schema): any {
  switch (type) {
    case 'string':
      if (schema.enum) return schema.enum[randomInt(0, schema.enum.length - 1)];
      if (schema.const) return schema.const;
      return randomString(schema.minLength ?? 8);
    case 'number':
    case 'integer':
      if (schema.enum) return schema.enum[randomInt(0, schema.enum.length - 1)];
      const min = schema.minimum ?? 0;
      const max = schema.maximum ?? 1000;
      const val = Math.random() * (max - min) + min;
      return type === 'integer' ? Math.floor(val) : parseFloat(val.toFixed(2));
    case 'boolean':
      return Math.random() > 0.5;
    case 'null':
      return null;
    case 'array':
      const len = schema.minItems ?? randomInt(1, schema.maxItems ?? 3);
      const items = schema.items ?? { type: 'string' };
      return Array.from({ length: len }, () => generateFromSchema(items));
    case 'object':
      return generateObject(schema);
    default:
      return null;
  }
}

// 生成对象类型数据
function generateObject(schema: Schema): Record<string, any> {
  const result: Record<string, any> = {};
  const properties = schema.properties ?? {};
  const required = new Set(schema.required ?? []);

  for (const [key, propSchema] of Object.entries(properties)) {
    // 非必填字段有 70% 概率生成
    if (!required.has(key) && Math.random() > 0.7) continue;
    result[key] = generateFromSchema(propSchema as Schema);
  }
  return result;
}

// 主入口:递归生成
function generateFromSchema(schema: Schema): any {
  if (schema.const) return schema.const;
  if (schema.enum) return schema.enum[randomInt(0, schema.enum.length - 1)];
  if (schema.type) return generateByType(schema.type, schema);
  // 无 type 时尝试推断
  if (schema.properties) return generateObject(schema);
  if (schema.items) return generateByType('array', schema);
  return null;
}

export { generateFromSchema };

这个 60 行的生成器已经能处理大部分简单 Schema,但面对真实世界的 API 定义还远远不够。接下来我们逐个攻克核心难点。

🚀 二、三大核心难点:$ref、组合关键字与格式生成

2.1 $ref 引用解析:构建定义注册表

真实世界的 JSON Schema 几乎都会使用 $ref 引用复用类型定义。比如一个用户 API 可能这样定义:

{
  "type": "object",
  "properties": {
    "user": { "$ref": "#/definitions/User" },
    "orders": {
      "type": "array",
      "items": { "$ref": "#/definitions/Order" }
    }
  },
  "definitions": {
    "User": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "email": { "type": "string", "format": "email" }
      },
      "required": ["name", "email"]
    },
    "Order": {
      "type": "object",
      "properties": {
        "id": { "type": "integer" },
        "total": { "type": "number", "minimum": 0 }
      }
    }
  }
}

⚠️ **警告:**不处理 $ref 就直接生成数据,会导致生成的 Mock 数据完全不符合预期——引用字段会变成 null,破坏整个测试用例。

实现 $ref 解析需要构建一个注册表(Registry),将所有定义路径映射到对应的 Schema 对象:

// ref-resolver.ts — $ref 引用解析器
type SchemaMap = Map<string, Schema>;

// 构建 Schema 注册表:将所有 definitions/$defs 展平为路径->Schema 映射
function buildRegistry(schema: Schema, rootPath = '#'): SchemaMap {
  const registry: SchemaMap = new Map();

  function walk(obj: any, path: string) {
    if (!obj || typeof obj !== 'object') return;
    registry.set(path, obj);

    // 处理 definitions 和 $defs(JSON Schema 2019-09+)
    for (const keyword of ['definitions', '$defs']) {
      if (obj[keyword]) {
        for (const [name, subSchema] of Object.entries(obj[keyword])) {
          walk(subSchema, `${path}/${keyword}/${name}`);
        }
      }
    }

    // 递归处理 properties、items、allOf/anyOf/oneOf 中的子 Schema
    if (obj.properties) {
      for (const [key, prop] of Object.entries(obj.properties)) {
        walk(prop, `${path}/properties/${key}`);
      }
    }
    if (obj.items && typeof obj.items === 'object') {
      walk(obj.items, `${path}/items`);
    }
    for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
      if (Array.isArray(obj[keyword])) {
        obj[keyword].forEach((sub: any, i: number) => {
          walk(sub, `${path}/${keyword}/${i}`);
        });
      }
    }
  }

  walk(schema, rootPath);
  return registry;
}

// 解析 $ref 指向的实际 Schema
function resolveRef(ref: string, registry: SchemaMap): Schema {
  const resolved = registry.get(ref);
  if (!resolved) {
    console.warn(`⚠️ 无法解析 $ref: ${ref},使用空对象兜底`);
    return {};
  }
  // 处理链式引用:A -> B -> C
  if (resolved.$ref) {
    return resolveRef(resolved.$ref, registry);
  }
  return resolved;
}

💡 **提示:**生产级生成器还需要处理远程 $ref(如 "$ref": "https://example.com/schema.json#/User"),这需要先 fetch 远程 Schema 再注册。本文聚焦本地场景,远程引用留给读者扩展。

2.2 oneOf / anyOf / allOf 组合处理

组合关键字是 Mock 数据生成中最复杂的部分,三种关键字的行为完全不同:

allOf(合并):将多个 Schema 深度合并为一个,所有约束都必须满足。处理策略是将所有子 Schema 的 propertiesrequiredtype 等合并:

// combination.ts — 组合关键字处理器
function mergeSchemas(schemas: Schema[]): Schema {
  const merged: Schema = {};

  for (const sub of schemas) {
    // 合并 properties
    if (sub.properties) {
      merged.properties = { ...merged.properties, ...sub.properties };
    }
    // 合并 required(取并集)
    if (sub.required) {
      merged.required = [...new Set([...(merged.required ?? []), ...sub.required])];
    }
    // 合并 type(取交集,通常 allOf 中 type 一致)
    if (sub.type) merged.type = sub.type;
    // 合并 minimum/maximum(取更严格的约束)
    if (sub.minimum !== undefined) {
      merged.minimum = Math.max(merged.minimum ?? -Infinity, sub.minimum);
    }
    if (sub.maximum !== undefined) {
      merged.maximum = Math.min(merged.maximum ?? Infinity, sub.maximum);
    }
  }

  return merged;
}

oneOf(互斥选取):恰好匹配一个分支。生成器随机选取一个分支生成数据。但要注意:如果分支之间有 constenum 区分,应该优先选取能生成有效数据的分支:

function handleOneOf(oneOf: Schema[], registry: SchemaMap): any {
  // 解析所有 $ref
  const resolved = oneOf.map(s =>
    s.$ref ? resolveRef(s.$ref, registry) : s
  );

  // 如果有 const/enum 分支,优先选取
  const constBranches = resolved.filter(s => s.const !== undefined);
  if (constBranches.length > 0) {
    const chosen = constBranches[randomInt(0, constBranches.length - 1)];
    return chosen.const;
  }

  // 随机选取一个分支
  const chosen = resolved[randomInt(0, resolved.length - 1)];
  return generateFromSchema(chosen);
}

anyOf(至少匹配一个):行为类似 oneOf,但允许匹配多个。Mock 数据生成时通常简化为随机选取一个分支,因为单个分支生成的数据已经满足至少一个条件。

⚠️ 警告:oneOf 在真实 API 中常被滥用。当分支之间没有明确的区分字段(discriminator)时,JSON Schema 验证器需要逐个尝试分支,这对 Mock 生成器也是一大挑战。建议始终在 oneOf 中添加 constrequired 字段作为区分标识

2.3 format 到 Faker 的映射:生成逼真数据

JSON Schema 的 format 关键字是提升 Mock 数据质量的关键。将 format 映射到 @faker-js/faker 的方法,可以让生成的数据从「随机乱码」变成「看起来像真的」:

// format-mapper.ts — format 到 Faker 的映射层
import { faker } from '@faker-js/faker';

const formatGenerators: Record<string, () => any> = {
  'email':       () => faker.internet.email(),
  'uri':         () => faker.internet.url(),
  'hostname':    () => faker.internet.domainName(),
  'ipv4':        () => faker.internet.ipv4(),
  'ipv6':        () => faker.internet.ipv6(),
  'date':        () => faker.date.recent().toISOString().split('T')[0],
  'date-time':   () => faker.date.recent().toISOString(),
  'time':        () => faker.date.recent().toISOString().split('T')[1],
  'uuid':        () => faker.string.uuid(),
  'phone':       () => faker.phone.number(),
  'color':       () => faker.color.rgb(),
  'credit-card': () => faker.finance.creditCardNumber(),
  'iban':        () => faker.finance.iban(),
  'regex':       () => '',  // 需要配合 randexp 库
  'password':    () => faker.internet.password(),
  'username':    () => faker.internet.userName(),
  'slug':        () => faker.helpers.slugify(faker.lorem.words(3)),
};

function generateByFormat(format: string): any {
  const generator = formatGenerators[format];
  if (generator) return generator();
  // 未知 format 回退到默认字符串
  return randomString(10);
}

关键结论:format 映射是区分「玩具级」和「生产级」Mock 生成器的分水岭。没有 Faker 集成的生成器只能产出 abc123 这样的垃圾数据,而集成后能产出 zhang.wei@example.com 这样的逼真数据。

📊 三、完整引擎实现与工具对比

3.1 整合所有模块

将前面的模块整合为一个完整的生成引擎:

// mock-engine.ts — 完整的 JSON Schema Mock 数据生成引擎
import { faker } from '@faker-js/faker';

type Schema = Record<string, any>;
type SchemaMap = Map<string, Schema>;

interface GeneratorOptions {
  /** 是否为所有非必填字段也生成数据,默认 false */
  alwaysIncludeOptional?: boolean;
  /** 数组默认长度,默认 1-3 随机 */
  arrayLength?: { min: number; max: number };
  /** 自定义 format 生成器 */
  customFormats?: Record<string, () => any>;
  /** 随机种子(用于可重复生成) */
  seed?: number;
}

class MockEngine {
  private registry: SchemaMap = new Map();
  private options: Required<GeneratorOptions>;

  constructor(private rootSchema: Schema, options: GeneratorOptions = {}) {
    this.options = {
      alwaysIncludeOptional: options.alwaysIncludeOptional ?? false,
      arrayLength: options.arrayLength ?? { min: 1, max: 3 },
      customFormats: options.customFormats ?? {},
      seed: options.seed ?? Date.now(),
    };
    this.registry = this.buildRegistry(rootSchema);
  }

  private buildRegistry(schema: Schema, path = '#'): SchemaMap {
    const map: SchemaMap = new Map();
    const walk = (obj: any, p: string) => {
      if (!obj || typeof obj !== 'object') return;
      map.set(p, obj);
      for (const kw of ['definitions', '$defs']) {
        if (obj[kw]) {
          for (const [n, s] of Object.entries(obj[kw])) {
            walk(s, `${p}/${kw}/${n}`);
          }
        }
      }
      if (obj.properties) {
        for (const [k, v] of Object.entries(obj.properties)) {
          walk(v, `${p}/properties/${k}`);
        }
      }
      if (obj.items && typeof obj.items === 'object') walk(obj.items, `${p}/items`);
      for (const kw of ['allOf', 'anyOf', 'oneOf']) {
        if (Array.isArray(obj[kw])) {
          obj[kw].forEach((s: any, i: number) => walk(s, `${p}/${kw}/${i}`));
        }
      }
    };
    walk(schema, path);
    return map;
  }

  private resolve(ref: string): Schema {
    const s = this.registry.get(ref);
    if (!s) return {};
    return s.$ref ? this.resolve(s.$ref) : s;
  }

  generate(schema?: Schema): any {
    return this._gen(schema ?? this.rootSchema);
  }

  private _gen(schema: Schema): any {
    // 1. const / enum 优先
    if (schema.const !== undefined) return schema.const;
    if (schema.enum) return schema.enum[Math.floor(Math.random() * schema.enum.length)];

    // 2. $ref 解析
    if (schema.$ref) return this._gen(this.resolve(schema.$ref));

    // 3. allOf 合并
    if (schema.allOf) {
      const merged = schema.allOf.reduce((acc: Schema, sub: Schema) => {
        const resolved = sub.$ref ? this.resolve(sub.$ref) : sub;
        return { ...acc, ...resolved, properties: { ...acc.properties, ...resolved.properties } };
      }, {});
      return this._gen(merged);
    }

    // 4. oneOf / anyOf 随机选取
    if (schema.oneOf || schema.anyOf) {
      const branches = (schema.oneOf ?? schema.anyOf).map((s: Schema) =>
        s.$ref ? this.resolve(s.$ref) : s
      );
      return this._gen(branches[Math.floor(Math.random() * branches.length)]);
    }

    // 5. format 生成
    if (schema.format) return this.generateFormat(schema.format, schema);

    // 6. pattern 生成(需要 randexp)
    if (schema.pattern) return this.generateFromPattern(schema.pattern, schema);

    // 7. 按 type 生成
    return this.generateByType(schema.type ?? 'object', schema);
  }

  private generateFormat(format: string, schema: Schema): any {
    if (this.options.customFormats[format]) return this.options.customFormats[format]();
    const map: Record<string, () => any> = {
      'email': () => faker.internet.email(),
      'uri': () => faker.internet.url(),
      'uuid': () => faker.string.uuid(),
      'date': () => faker.date.recent().toISOString().split('T')[0],
      'date-time': () => faker.date.recent().toISOString(),
      'ipv4': () => faker.internet.ipv4(),
      'phone': () => faker.phone.number(),
    };
    return (map[format] ?? (() => randomString(10)))();
  }

  private generateFromPattern(pattern: string, schema: Schema): string {
    // 简化实现:生产环境建议集成 randexp 库
    return `[pattern:${pattern}]`;
  }

  private generateByType(type: string, schema: Schema): any {
    switch (type) {
      case 'string': return randomString(schema.minLength ?? 8);
      case 'number': {
        const min = schema.minimum ?? 0, max = schema.maximum ?? 1000;
        return parseFloat((Math.random() * (max - min) + min).toFixed(2));
      }
      case 'integer': {
        const min = schema.minimum ?? 0, max = schema.maximum ?? 1000;
        return Math.floor(Math.random() * (max - min + 1)) + min;
      }
      case 'boolean': return Math.random() > 0.5;
      case 'null': return null;
      case 'array': {
        const { min, max } = this.options.arrayLength;
        const len = schema.minItems ?? Math.floor(Math.random() * (max - min + 1)) + min;
        const items = schema.items ?? { type: 'string' };
        return Array.from({ length: len }, () => this._gen(items));
      }
      case 'object':
      default:
        return this.generateObject(schema);
    }
  }

  private generateObject(schema: Schema): Record<string, any> {
    const result: Record<string, any> = {};
    const props = schema.properties ?? {};
    const required = new Set(schema.required ?? []);

    for (const [key, propSchema] of Object.entries(props)) {
      if (!required.has(key) && !this.options.alwaysIncludeOptional && Math.random() > 0.7) {
        continue;
      }
      result[key] = this._gen(propSchema as Schema);
    }
    return result;
  }
}

// 辅助函数
function randomString(len: number): string {
  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
  return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}

export { MockEngine, type GeneratorOptions };

3.2 使用示例

// usage.ts — 演示 MockEngine 的完整用法
import { MockEngine } from './mock-engine';

const apiSchema = {
  type: 'object',
  required: ['id', 'name', 'email', 'role'],
  properties: {
    id: { type: 'integer', minimum: 1 },
    name: { type: 'string', minLength: 2, maxLength: 20 },
    email: { type: 'string', format: 'email' },
    role: { enum: ['admin', 'editor', 'viewer'] },
    avatar: { type: 'string', format: 'uri' },
    tags: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 5 },
    address: {
      type: 'object',
      properties: {
        city: { type: 'string' },
        zipCode: { type: 'string', pattern: '^[0-9]{6}$' }
      }
    },
    status: {
      oneOf: [
        { type: 'object', properties: { type: { const: 'active' }, lastLogin: { type: 'string', format: 'date-time' } }, required: ['type'] },
        { type: 'object', properties: { type: { const: 'banned' }, reason: { type: 'string' } }, required: ['type'] }
      ]
    }
  }
};

const engine = new MockEngine(apiSchema, { alwaysIncludeOptional: true });

// 生成单条数据
const user = engine.generate();
console.log(JSON.stringify(user, null, 2));

// 生成批量数据
const users = Array.from({ length: 10 }, () => engine.generate());
console.table(users.map(u => ({ id: u.id, name: u.name, email: u.email, role: u.role })));

输出示例:

{
  "id": 42,
  "name": "k8x9p2m",
  "email": "john.doe@example.com",
  "role": "editor",
  "avatar": "https://example.com/avatar.png",
  "tags": ["x7k", "m3p"],
  "address": {
    "city": "a9b2c4d6e8",
    "zipCode": "100086"
  },
  "status": {
    "type": "active",
    "lastLogin": "2026-06-10T14:30:22.000Z"
  }
}

3.3 主流工具深度对比

市面上已有几款 JSON Schema Mock 数据工具,它们各有侧重:

特性 本文 MockEngine json-schema-faker MSW + Faker 手写 Fixture
Schema 解析深度 ✅ 完整 ✅ 完整 ❌ 需手写映射 ❌ 完全手写
$ref 支持 ✅ 本地+链式 ✅ 远程也支持 ❌ 无 ❌ 无
oneOf/anyOf ✅ 随机选取 ✅ 随机选取 ❌ 手动判断 ❌ 手动判断
format 映射 ✅ Faker 集成 ⚠️ 部分支持 ✅ 完整 ✅ 完整
TypeScript 类型 ✅ 原生 ❌ JS 为主
可定制性 ✅ 高 ⚠️ 中 ✅ 高 ✅ 最高
包大小 ~15KB ~80KB ~200KB+ 0KB
维护状态(2026) 本文代码 ⚠️ 更新缓慢 ✅ 活跃 N/A

💡 提示:json-schema-faker 曾是最流行的方案,但其维护频率在 2025 年后明显下降,且对 JSON Schema 2020-12 的支持不完整。如果你的项目使用较新的 Schema 版本,建议基于本文代码自行实现或使用 MSW + Faker 组合。

⚠️ 四、生产环境避坑指南

在将 Mock 数据引擎接入真实项目时,以下几个坑点需要特别注意:

4.1 递归引用导致无限循环

{
  "definitions": {
    "TreeNode": {
      "type": "object",
      "properties": {
        "value": { "type": "string" },
        "children": {
          "type": "array",
          "items": { "$ref": "#/definitions/TreeNode" }
        }
      }
    }
  }
}

❌ **避免:**直接递归生成 TreeNode 会导致栈溢出。必须加入深度限制。

解决方案:

// safe-generator.ts — 递归深度限制
const MAX_DEPTH = 5;

function safeGenerate(schema: Schema, depth = 0): any {
  if (depth >= MAX_DEPTH) {
    // 达到深度上限,返回占位值
    return schema.type === 'array' ? [] : {};
  }
  return generateWithDepth(schema, depth + 1);
}

4.2 数值边界陷阱

Schema 定义了 "minimum": 0, "maximum": 0 时,生成器必须返回 0 而不是随机数。"exclusiveMinimum": 0 意味着 0 本身不合法,需要生成 > 0 的值。这些边界条件在测试中经常被忽略。

⚠️ 警告:exclusiveMinimum 在 JSON Schema Draft 4 中是布尔值(true 表示排除),在 Draft 6+ 中是数值(表示排除的边界值)。如果你的生成器需要兼容多个版本,务必分别处理。

4.3 嵌套 allOf 的合并顺序

allOf 中的子 Schema 对同一字段定义了不同约束时,合并顺序会影响结果。正确的做法是:后出现的约束覆盖先出现的,但 minimum/maximum 取更严格的值

// ❌ 错误写法:简单的 Object.assign 会丢失数值约束的合并逻辑
const merged = Object.assign({}, ...allOfSchemas);

// ✅ 正确写法:对特殊字段做深度合并
function deepMergeAllOf(schemas: Schema[]): Schema {
  return schemas.reduce((acc, cur) => {
    const result = { ...acc, ...cur };
    // minimum 取较大值(更严格)
    if (acc.minimum !== undefined && cur.minimum !== undefined) {
      result.minimum = Math.max(acc.minimum, cur.minimum);
    }
    // maximum 取较小值(更严格)
    if (acc.maximum !== undefined && cur.maximum !== undefined) {
      result.maximum = Math.min(acc.maximum, cur.maximum);
    }
    // required 取并集
    if (acc.required || cur.required) {
      result.required = [...new Set([...(acc.required ?? []), ...(cur.required ?? [])])];
    }
    return result;
  }, {});
}

4.4 性能优化:Schema 缓存

对于大型 Schema(如 OpenAPI 3.1 完整定义,可能包含数百个端点),每次生成都重新构建注册表是不可接受的。将解析后的 Schema 注册表缓存起来,只在 Schema 变更时重建

// cached-engine.ts — 带缓存的生成引擎
class CachedMockEngine {
  private cache = new Map<string, any>();
  private registryHash: string;

  constructor(schema: Schema) {
    // 对 Schema 内容做哈希,检测变更
    this.registryHash = this.hashSchema(schema);
    if (!this.cache.has(this.registryHash)) {
      this.cache.set(this.registryHash, buildRegistry(schema));
    }
  }

  private hashSchema(schema: Schema): string {
    // 简化实现,生产环境建议用 crypto.subtle.digest
    return JSON.stringify(schema).length.toString(36);
  }
}

💡 五、进阶:可重复生成与快照测试

Mock 数据的一个高级需求是可重复生成——相同的种子(seed)产出相同的数据。这对于快照测试(Snapshot Testing)和回归测试至关重要。

// seeded-random.ts — 基于种子的可重复随机数生成器
class SeededRandom {
  private state: number;

  constructor(seed: number) {
    this.state = seed;
  }

  // Mulberry32 算法:简单高效的 32 位 PRNG
  next(): number {
    this.state |= 0;
    this.state = (this.state + 0x6D2B79F5) | 0;
    let t = Math.imul(this.state ^ (this.state >>> 15), 1 | this.state);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  }

  // 生成指定范围的整数
  int(min: number, max: number): number {
    return Math.floor(this.next() * (max - min + 1)) + min;
  }

  // 从数组中随机选取
  pick<T>(arr: T[]): T {
    return arr[this.int(0, arr.length - 1)];
  }
}

// 用法
const rng = new SeededRandom(42);
console.log(rng.int(1, 100));  // 每次运行 seed=42 都输出相同结果
console.log(rng.pick(['a', 'b', 'c']));

SeededRandom 集成到 MockEngine 中,就可以在 CI/CD 流水线中保证每次生成的 Mock 数据完全一致,避免因随机数据导致的测试波动。

📌 **记住:**可重复生成不仅用于测试,还可以用于「数据回放」场景——在 Bug 复现时用相同种子重建完全一致的测试数据。

✅ 总结与工具推荐

构建一个生产级的 JSON Schema Mock 数据引擎,核心挑战不在于随机数据生成本身,而在于正确解析和处理 JSON Schema 的各种关键字组合。关键要点回顾:

  • 必须处理 $ref:真实 Schema 几乎都有引用,不处理等于废了
  • allOf 要深度合并:不能简单 Object.assign,数值约束需要特殊处理
  • format 要映射到 Faker:这是 Mock 数据从「垃圾」到「逼真」的分水岭
  • 递归引用要加深度限制:否则一个 TreeNode 就能让你的程序崩溃
  • 用种子实现可重复生成:CI/CD 流水线中测试稳定性依赖于此
  • 不要忽略 exclusiveMinimum/exclusiveMaximum:边界值是常见的 Bug 来源
  • 不要假设所有 Schema 都有 type:有些 Schema 只用 properties 隐式表示对象

推荐工具链:

工具 用途 推荐度
@faker-js/faker 逼真数据生成 ⭐⭐⭐⭐⭐
json-schema-faker 开箱即用的 Schema Mock ⭐⭐⭐
ajv Schema 验证(校验生成的数据) ⭐⭐⭐⭐⭐
msw API Mock 拦截 ⭐⭐⭐⭐
randexp 正则表达式数据生成 ⭐⭐⭐⭐

最终建议:如果你的 API Schema 相对简单且不频繁变更,json-schema-faker 是最快的方案。但如果你需要深度定制、支持最新 Schema 版本、或集成到自研的 API 平台中,基于本文的架构自行实现是更可持续的选择。

📚 相关文章