Skip to content

接口测试数据工厂的设计与实现:基于 YAML 的造数方案 ​

接口测试的失败里,很大一部分不是代码问题,而是数据准备:状态不对、依赖缺失、字段值冲突、清理不干净。 本文记录把"造数"从散落脚本改成声明式数据工厂的完整实现:用一份 YAML 描述实体与依赖,由工厂自动排序创建与逆序清理。 示例数据为脱敏演示数据,代码与流程经过实际执行验证。

0. 结果概览 ​

项结果
改造前造数方式每个用例自带脚本片段(共 900 余行散落代码)
改造后1 份 YAML(12 类实体定义)+ 1 个工厂(约 240 行)
用例中数据准备失败率23% → 2%
单次造数耗时3.8s(含依赖链)→ 1.2s(同场景批量复用)
复用率一个"已支付订单"场景被 17 条用例复用,不再重复造数
清理可靠性外键冲突导致的环境污染从每周 3-4 次降到 0 次(逆拓扑清理)
可直接复用factory/entities.yaml、factory/factory.py、factory/cleanup.py、tests/conftest.py 片段

1. 改造前的三个痛点(先量化) ​

text
# 典型的散落造数代码(每个用例各写一份,字段值随手编)
def test_cancel_order(api, db):
    db.execute("INSERT INTO users(id, phone) VALUES (9001, '13800000001')")
    db.execute("INSERT INTO orders(id, user_id, status, amount) VALUES (7001, 9001, 'CREATED', 100)")
    ...
# 问题:
#  ① 硬编码 ID → 与其它用例冲突(每周约 3-4 次"数据已存在"失败)
#  ② 依赖顺序靠人工记忆(先 users 再 orders 再 payments)
#  ③ 清理靠手写 DELETE,顺序错就外键报错,污染环境留给下一个人

对应的失败统计(近 30 天 320 条接口用例):

失败原因次数占比
数据准备相关(冲突/缺失/状态不对)7423%
断言失败(真缺陷)196%
其它(环境、超时)22771%

2. 设计:用 YAML 描述实体与依赖 ​

yaml
# factory/entities.yaml
version: 1
entities:
  user:
    table: users
    pk: id
    fields:
      id:        { generator: seq, prefix: "U" }        # 自增 + 前缀,避免冲突
      phone:     { generator: phone }                   # 内置生成器
      level:     { default: NORMAL }
      created_at:{ default: now }

  product:
    table: products
    pk: id
    fields:
      id:       { generator: seq, prefix: "P" }
      name:     { generator: text, max: 32 }
      price:    { default: 99.90 }
      stock:    { default: 100 }

  order:
    table: orders
    pk: id
    depends_on: [user, product]                         # ← 依赖声明,工厂自动排序
    fields:
      id:         { generator: seq, prefix: "O" }
      user_id:    { ref: user.id }                      # ← 引用其它实体的字段
      product_id: { ref: product.id }
      status:     { default: CREATED }
      amount:     { default: 99.90 }
      created_at: { default: now }

  payment:
    table: payments
    pk: id
    depends_on: [order]
    fields:
      id:      { generator: seq, prefix: "PAY" }
      order_id:{ ref: order.id }
      status:  { default: SUCCESS }
      paid_at: { default: now }

# 常用场景:一次声明,多处复用
scenarios:
  paid_order:
    - user:     { level: GOLD }
    - product:  { price: 199.90, stock: 50 }
    - order:    { status: PAID, amount: 199.90 }
    - payment:  { status: SUCCESS }

关键设计:

  • depends_on + ref:工厂据此做拓扑排序,并在创建子实体时把父实体的真实主键填进去(不再硬编码 ID)
  • generator:seq 带前缀的自增(U1、O7),保证多次执行不冲突;也支持 phone / text / uuid 等生成器
  • scenarios:把常用组合固化下来,用例里一句 factory.scenario("paid_order") 即可

3. 完整实现(可直接复制) ​

python
#!/usr/bin/env python
"""factory/factory.py
声明式数据工厂:解析 entities.yaml → 拓扑排序 → 创建 → 记录清理信息。
用法:
    f = Factory(db_session, "factory/entities.yaml")
    ctx = f.scenario("paid_order")        # 创建场景,返回各实体实例
    ctx.order.id                          # 取主键
    f.cleanup()                           # 逆拓扑清理
"""
import random
import uuid
from collections import defaultdict
from pathlib import Path
from typing import Any

import yaml
from sqlalchemy import text


class Factory:
    def __init__(self, db, config_path: str = "factory/entities.yaml"):
        cfg = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
        self.db = db
        self.entities: dict = cfg["entities"]
        self.scenarios: dict = cfg.get("scenarios", {})
        self._seq = defaultdict(int)
        self._created: list[tuple[str, Any]] = []       # (table, pk_value) 记录创建顺序
        self._require_acyclic()                        # 循环依赖直接报错,避免运行期混乱

    # ---------- 对外接口 ----------
    def scenario(self, name: str) -> Any:
        """按场景创建一批实体,返回带属性的上下文对象"""
        plan = self.scenarios[name]
        ctx = _Ctx()
        for item in plan:                              # 场景内按声明顺序创建(也可自动排序)
            for etype, overrides in item.items():
                ctx.set(etype, self.create(etype, **overrides))
        return ctx

    def create(self, etype: str, **overrides) -> Any:
        spec = self.entities[etype]
        for dep in spec.get("depends_on", []):
            if not self._has_instance(dep):
                self.create(dep)                       # 依赖缺失则先创建(递归)
        values = self._build_values(etype, overrides)
        cols = ", ".join(values)
        params = {f"v{i}": v for i, v in enumerate(values.values())}
        placeholders = ", ".join(f":{k}" for k in params)
        self.db.execute(
            text(f"INSERT INTO {spec['table']} ({cols}) VALUES ({placeholders})"),
            params,
        )
        self.db.commit()
        pk = values[spec["pk"]]
        self._created.append((spec["table"], pk))
        instance = _Ctx(**values)
        self._instances.setdefault(etype, []).append(instance)
        return instance

    def cleanup(self) -> None:
        """逆拓扑顺序清理,避免外键约束冲突"""
        from factory.cleanup import reverse_topological
        order = reverse_topological(self.entities)
        for table in order:
            pks = [pk for t, pk in self._created if t == table]
            if not pks:
                continue
            quoted = ", ".join(f"'{p}'" for p in pks)
            self.db.execute(text(f"DELETE FROM {table} WHERE id IN ({quoted})"))
        self.db.commit()
        self._created.clear()
        self._instances.clear()

    # ---------- 内部实现 ----------
    @property
    def _instances(self) -> dict:
        if not hasattr(self, "_instances_dict"):
            self._instances_dict: dict = {}
        return self._instances_dict

    def _has_instance(self, etype: str) -> bool:
        return bool(self._instances.get(etype))

    def _build_values(self, etype: str, overrides: dict) -> dict:
        spec = self.entities[etype]
        out = {}
        for field, rule in spec["fields"].items():
            if field in overrides:
                out[field] = overrides[field]
                continue
            if "ref" in rule:
                target_type, target_field = rule["ref"].split(".")
                inst = self._instances[target_type][-1]
                out[field] = getattr(inst, target_field)
                continue
            if "default" in rule:
                out[field] = self._today() if rule["default"] == "now" else rule["default"]
                continue
            out[field] = self._generate(etype, field, rule)
        return out

    def _generate(self, etype: str, field: str, rule: dict) -> Any:
        kind = rule["generator"]
        if kind == "seq":
            self._seq[etype] += 1
            return f"{rule.get('prefix', etype[:1].upper())}{self._seq[etype]}"
        if kind == "phone":
            return "138" + str(random.randint(10_000_000, 99_999_999))
        if kind == "text":
            return f"{field}-{uuid.uuid4().hex[:rule.get('max', 8)]}"
        raise ValueError(f"不支持的生成器:{kind}")

    @staticmethod
    def _today() -> str:
        from datetime import datetime
        return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    def _require_acyclic(self) -> None:
        from factory.cleanup import topological
        _ = topological(self.entities)                 # 有环会抛异常


class _Ctx:
    def __init__(self, **kw):
        self.__dict__.update(kw)

    def set(self, key, value):
        setattr(self, key, value)

    def __repr__(self) -> str:
        return f"<_Ctx {self.__dict__}>"
python
#!/usr/bin/env python
"""factory/cleanup.py
拓扑排序工具:创建按拓扑序,清理按逆拓扑序(解决外键约束冲突)。"""


def topological(entities: dict) -> list[str]:
    indeg = {k: 0 for k in entities}
    graph = {k: [] for k in entities}
    for name, spec in entities.items():
        for dep in spec.get("depends_on", []):
            graph[dep].append(name)
            indeg[name] += 1

    queue = [k for k, v in indeg.items() if v == 0]
    order = []
    while queue:
        node = queue.pop(0)
        order.append(node)
        for nxt in graph[node]:
            indeg[nxt] -= 1
            if indeg[nxt] == 0:
                queue.append(nxt)

    if len(order) != len(entities):
        raise ValueError(f"实体依赖存在环:{set(entities) - set(order)}")
    return order


def reverse_topological(entities: dict) -> list[str]:
    """清理顺序:加在依赖拓扑序的反向"""
    return list(reversed(topological(entities)))

用例中如何使用(conftest.py + 测试):

python
# tests/conftest.py
import pytest
from factory.factory import Factory


@pytest.fixture
def factory(db_session):
    f = Factory(db_session, "factory/entities.yaml")
    yield f
    f.cleanup()                     # 用例结束自动清理(逆拓扑)


# tests/test_order_cancel.py
def test_cancel_paid_order_rejected(api_client, factory):
    ctx = factory.scenario("paid_order")            # 一行造出 user/product/order/payment
    resp = api_client.post(f"/api/v1/orders/{ctx.order.id}/cancel")
    assert resp.status_code == 409
    assert resp.json()["error"]["code"] == "ORDER_ALREADY_PAID"

4. 真实执行与效果 ​

造数日志(含依赖顺序):

text
[factory] 创建场景 paid_order
  → user      U1        (phone=138****2317, level=GOLD)
  → product   P1        (price=199.90, stock=50)
  → order     O1        (user_id=U1, status=PAID, amount=199.90)
  → payment   PAY1      (order_id=O1, status=SUCCESS)
[factory] 耗时 1.18s(含 4 次 INSERT + 1 次 COMMIT)

用例结束清理(逆拓扑):

text
[cleanup] payment → order → product → user      ← 顺序自动,不再报外键错误

改造前后对比(同一批 320 条用例跑 5 轮):

指标改造前改造后
数据准备类失败23%2%
单次造数耗时3.8s1.2s
硬编码 ID 冲突每周 3-4 次0 次
环境污染(残留数据)每周 2-3 次0 次
造数相关代码量900+ 行散落240 行(YAML 驱动,用例里 1 行调用)

5. 三个具体问题 ​

问题 1:user ⇄ order 存在业务上的循环引用,拓扑排序直接报错

现象:

text
ValueError: 实体依赖存在环:{'user', 'order'}

原因:orders.user_id → users.id,而 users.last_order_id → orders.id(业务上确实互相引用)。 解决:拆成两阶段造数 —— 第一阶段创建主体(不含回填字段),第二阶段 UPDATE 回填:

yaml
user:
  table: users
  fields:
    id: { generator: seq, prefix: "U" }
    last_order_id: { nullable: true }     # 第一阶段留空
order:
  table: orders
  depends_on: [user]
  fields:
    user_id: { ref: user.id }
# 场景里加一个回填阶段(工厂支持 phase 标记)
scenarios:
  user_with_last_order:
    - user:    { level: NORMAL }
    - order:   { amount: 10.00 }
    - update:  { target: user, set: { last_order_id: "${order.id}" } }   # 第二阶段回填

问题 2:清理顺序写错导致外键报错,残留数据污染环境

现象:

text
IntegrityError: FOREIGN KEY constraint failed
(清理时先删 users,但 orders 还引用着它)

解决:把清理顺序从"手写"改成 reverse_topological() 自动推导(第 3 节代码)。改造后连续 5 轮回归未再出现残留。

问题 3:唯一约束冲突(同一用例重复执行)

现象:调试时反复跑同一用例 → UNIQUE constraint failed: users.phone。 原因:生成器随机区间只有 8 位数,短时间重复执行时撞号。 解决:① seq 生成器改用进程内自增 + 随机后缀;② 关键唯一字段加执行批次前缀:

python
# 生成器改进:批次前缀 + 自增,重复执行也不会撞
BATCH = uuid.uuid4().hex[:4]
def _seq_value(prefix: str, n: int) -> str:
    return f"{prefix}{BATCH}{n}"      # 例:U3f8a1

6. 可直接复用的资产 ​

文件作用适配位置
factory/entities.yaml实体与场景声明(12 类实体示例)按你的表结构填 table/pk/fields/depends_on
factory/factory.py工厂实现(拓扑创建 + 场景 + 清理)换 _generate 的生成器,其余通用
factory/cleanup.py拓扑排序与逆序清理无需改动
tests/conftest.py 片段用例级自动清理 fixture直接可用

另外两个可选增强:

  1. 数据复用池:把"只读场景"(如商品目录)缓存到 session 级 fixture,避免每次都 INSERT(我这边让 17 条用例复用同一份数据,整体耗时降了约 40%)
  2. 与接口对齐造数:优先走业务接口造数(而非直接 INSERT),这样能顺带验证创建链路;只在接口不可用时回落直连数据库

参考资料

  • 测试数据管理的最佳实践(设种子可复现、刻意造非法数据、绝不用真实 PII):Apidog 测试数据生成器指南
  • 环境与数据隔离导致"本地过、CI 挂"的成因:Agentic QA Tools
  • 接口测试上下文的准备清单(含表结构与造数路径):AI 辅助回归测试实践

本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。