跳转到内容
123xiao | 无名键客

《自动化测试中的测试数据管理实战:构建稳定、可复用的中级测试体系》

字数: 0 阅读时长: 1 分钟

自动化测试中的测试数据管理实战:构建稳定、可复用的中级测试体系

很多团队做自动化测试时,最先卡住的不是框架,不是断言能力,甚至不是 CI/CD,而是测试数据

我见过不少项目,UI 自动化脚本写了上百条,接口自动化也铺开了,但回归一跑就开始“抽风”:

  • 账号被别人占用了
  • 订单状态不符合预期
  • 测试环境脏数据越来越多
  • 用例今天过、明天挂,排查半天发现只是数据串了
  • 新同学接手后,不知道哪些数据能复用、哪些必须新建

这些问题的根本,往往不是“某条用例没写好”,而是测试数据管理没有被当成架构问题来设计

这篇文章我会从中级测试体系建设的角度,带你搭一套能落地的思路:怎么组织测试数据、怎么生成、怎么隔离、怎么回收,以及怎么让它真正服务于自动化测试的稳定性和复用性。


背景与问题

在手工测试阶段,数据问题常常被“人工补救”掩盖了:

  • 缺个用户?临时注册一个
  • 缺个订单?手点一单
  • 状态不对?找 DBA 改一下
  • 数据脏了?大家默认“测试环境本来就这样”

但到了自动化阶段,这种做法会迅速失效。因为自动化要求的是:

  1. 可重复执行
  2. 可并发执行
  3. 可持续集成执行
  4. 跨环境一致执行

而测试数据天然会和这四点冲突。

典型症状

我们先把问题拆开看。

1. 数据耦合到用例实现

例如某条支付用例依赖固定账号 test_user_01,而这个账号必须满足:

  • 已实名
  • 有默认地址
  • 余额大于 100
  • 无未完成订单

一开始似乎很方便,但时间一长,这个账号就成了“公共厕所”:

  • A 用例改了余额
  • B 用例清了购物车
  • C 用例修改了收货地址

最后大家都说“我这边单跑没问题”。

2. 测试数据生命周期失控

常见情况:

  • 每次运行都插入新数据,但从不清理
  • 固定数据被修改后不回滚
  • 同一套环境被多条流水线共享

结果就是环境越跑越脏,失败率越来越高。

3. 数据构造成本高

如果一条用例前置依赖 5 个对象:用户、商品、优惠券、订单、支付单,那么靠 UI 点出来几乎不可接受;靠 SQL 直插虽然快,但又容易绕过业务逻辑,导致结果“不真实”。

4. 缺少“数据即资产”的意识

很多团队把测试代码纳入版本管理,却把测试数据生成脚本、数据模板、环境初始化逻辑散落在聊天记录、共享文档甚至个人电脑里。最后复用性非常差。


为什么测试数据管理是体系问题,而不是脚本问题

中级测试体系建设里,测试数据不能只看成“某条用例的前置步骤”,它更像一层基础设施,位于:

  • 测试框架
  • 业务接口
  • 环境资源
  • CI 流水线

之间。

一个更稳的理解方式是:

测试用例验证业务逻辑,测试数据系统负责为用例提供可信、可控、可回收的状态输入。

也就是说,测试数据管理要回答 4 个问题:

  1. 数据从哪里来?
  2. 数据如何保持可预测?
  3. 数据如何避免互相污染?
  4. 数据如何被不同用例复用?

核心原理

下面是我在实际项目里比较推荐的一套中级方案:分层数据管理 + 工厂化构造 + 场景化编排 + 生命周期治理

1. 分层设计:把数据分成 3 类

不是所有数据都要动态创建。一个实用的分法是:

A. 基础静态数据

特点:

  • 稳定
  • 变化少
  • 业务上被大量复用

例如:

  • 商品分类
  • 行政区划
  • 配置字典
  • 权限角色模板

这类数据适合:

  • 环境初始化时统一导入
  • 版本化管理
  • 明确变更流程

B. 场景模板数据

特点:

  • 服务于测试场景
  • 可复制
  • 具有预定义状态

例如:

  • 已实名用户模板
  • 有余额用户模板
  • 已下单未支付订单模板

这类数据适合做成数据模板工厂方法

C. 运行时临时数据

特点:

  • 每次执行动态生成
  • 用后可清理
  • 通常带唯一标识

例如:

  • 临时手机号
  • 随机邮箱
  • 本次执行创建的订单号

这类数据用于隔离并发执行冲突。


2. 数据优先级:优先走业务接口,不要默认直写数据库

很多人问:构造数据到底该用 API 还是 SQL?

我的经验是:

  • 优先 API
  • 必要时用内部数据工厂接口
  • 最后才是 SQL 兜底

原因很现实:

用 API 的优势

  • 更贴近真实业务链路
  • 能触发完整校验和事件
  • 降低“造出非法数据”的概率

用 SQL 的风险

  • 绕过业务约束
  • 容易遗漏关联表
  • 版本升级后脚本脆弱
  • 排查问题时与线上真实行为脱节

当然,SQL 不是不能用。比如清理历史测试数据、快速准备底层基础表时,SQL 依然有价值。但要把它放在受控边界内


3. 场景化编排:让用例依赖“状态”,不是依赖“某条固定数据”

这是稳定性的关键。

坏例子:

  • 用例写死使用用户 user_A

好例子:

  • 用例声明需要一个“已实名、有余额、无未支付订单”的用户

也就是说,用例关心的是:

  • 前置状态
  • 业务约束
  • 可观察结果

而不是某个具体 ID。

这时就需要一个数据工厂层

flowchart LR
    A[测试用例] --> B[场景数据工厂]
    B --> C[业务接口/API]
    B --> D[必要的数据库清理]
    C --> E[用户]
    C --> F[订单]
    C --> G[支付单]
    D --> H[脏数据回收]

4. 生命周期治理:创建、使用、清理要形成闭环

测试数据出问题,本质上常常是生命周期没人管。

推荐把数据生命周期显式化:

stateDiagram-v2
    [*] --> Create: 创建数据
    Create --> Bind: 绑定到测试上下文
    Bind --> Use: 执行业务操作
    Use --> Verify: 结果校验
    Verify --> Cleanup: 清理/归档
    Cleanup --> [*]
    Verify --> Retain: 失败保留现场
    Retain --> [*]

这里有两个非常实用的设计点:

失败时保留现场

不是所有数据都应该立即删除。自动化失败后,如果马上清理,排查会很痛苦。更好的方式是:

  • 成功执行后清理
  • 失败执行后打标签保留
  • 定期批量回收

数据与测试运行绑定

建议给每次测试运行生成唯一 run_id,并把它打到所有测试数据上。这样你就知道:

  • 这条数据是谁创建的
  • 哪次流水线创建的
  • 是否可以安全回收

方案对比与取舍分析

在架构层面,常见有三种测试数据管理方式。

方案做法优点缺点适用阶段
固定账号/固定数据池所有用例共用少量预置数据上手快易污染、并发差、维护难早期 PoC
用例内自行造数每条用例自己准备数据灵活重复代码多、标准不统一小规模自动化
独立数据工厂层统一封装数据模板、构造、清理稳定、可复用、适合扩展前期设计成本较高中级及以上体系

如果你的团队已经有几十到上百条自动化用例,我会明确建议走第三种。


一套可落地的中级架构

下面给一个比较实战的分层模型。

classDiagram
    class TestCase {
      +run()
    }

    class ScenarioFactory {
      +create_user(profile)
      +create_product(profile)
      +create_order(user, product, profile)
      +cleanup(context)
    }

    class DataProfile {
      +realname_verified
      +balance
      +coupon_status
      +order_status
    }

    class ApiClient {
      +post(path, payload)
      +get(path, params)
    }

    class CleanupService {
      +mark(run_id)
      +sweep(expire_hours)
    }

    TestCase --> ScenarioFactory
    ScenarioFactory --> DataProfile
    ScenarioFactory --> ApiClient
    ScenarioFactory --> CleanupService

分层职责建议

测试用例层

只负责:

  • 描述业务步骤
  • 调用场景数据工厂
  • 做断言

不要在这里写大量建数细节。

场景数据工厂层

负责:

  • 按状态生成实体
  • 编排依赖关系
  • 返回可复用上下文

这是核心层。

基础客户端层

负责:

  • API 调用
  • SQL 清理
  • 消息队列触发
  • 缓存刷新

回收治理层

负责:

  • 标记测试数据
  • 定时清理
  • 失败保留
  • TTL 过期回收

实战代码(可运行)

下面用 Python 演示一个简化版方案。它不依赖真实业务系统,但完整体现了:

  • 场景数据工厂
  • run_id 绑定
  • 动态造数
  • 成功清理 / 失败保留

你可以直接运行,作为设计参考。

目录结构示意

tdm_demo/
├── app.py
├── test_data_factory.py
├── fake_backend.py
└── test_checkout.py

1. 模拟后端:fake_backend.py

from dataclasses import dataclass, field
from typing import Dict, List
import uuid


@dataclass
class User:
    user_id: str
    name: str
    verified: bool
    balance: int
    run_id: str


@dataclass
class Product:
    product_id: str
    title: str
    stock: int
    price: int
    run_id: str


@dataclass
class Order:
    order_id: str
    user_id: str
    product_id: str
    amount: int
    status: str
    run_id: str


class FakeBackend:
    def __init__(self):
        self.users: Dict[str, User] = {}
        self.products: Dict[str, Product] = {}
        self.orders: Dict[str, Order] = {}

    def create_user(self, name: str, verified: bool, balance: int, run_id: str) -> User:
        user = User(
            user_id=str(uuid.uuid4()),
            name=name,
            verified=verified,
            balance=balance,
            run_id=run_id,
        )
        self.users[user.user_id] = user
        return user

    def create_product(self, title: str, stock: int, price: int, run_id: str) -> Product:
        product = Product(
            product_id=str(uuid.uuid4()),
            title=title,
            stock=stock,
            price=price,
            run_id=run_id,
        )
        self.products[product.product_id] = product
        return product

    def create_order(self, user_id: str, product_id: str, run_id: str) -> Order:
        user = self.users[user_id]
        product = self.products[product_id]

        if not user.verified:
            raise ValueError("user not verified")
        if product.stock <= 0:
            raise ValueError("product out of stock")
        if user.balance < product.price:
            raise ValueError("insufficient balance")

        product.stock -= 1
        user.balance -= product.price

        order = Order(
            order_id=str(uuid.uuid4()),
            user_id=user_id,
            product_id=product_id,
            amount=product.price,
            status="PAID",
            run_id=run_id,
        )
        self.orders[order.order_id] = order
        return order

    def cleanup_by_run_id(self, run_id: str):
        self.orders = {k: v for k, v in self.orders.items() if v.run_id != run_id}
        self.products = {k: v for k, v in self.products.items() if v.run_id != run_id}
        self.users = {k: v for k, v in self.users.items() if v.run_id != run_id}

    def count_by_run_id(self, run_id: str) -> Dict[str, int]:
        return {
            "users": sum(1 for x in self.users.values() if x.run_id == run_id),
            "products": sum(1 for x in self.products.values() if x.run_id == run_id),
            "orders": sum(1 for x in self.orders.values() if x.run_id == run_id),
        }

2. 数据工厂:test_data_factory.py

from dataclasses import dataclass, field
from typing import List
import uuid

from fake_backend import FakeBackend, User, Product, Order


@dataclass
class TestContext:
    run_id: str
    created_user_ids: List[str] = field(default_factory=list)
    created_product_ids: List[str] = field(default_factory=list)
    created_order_ids: List[str] = field(default_factory=list)


class ScenarioFactory:
    def __init__(self, backend: FakeBackend):
        self.backend = backend

    def new_context(self) -> TestContext:
        return TestContext(run_id=str(uuid.uuid4()))

    def create_verified_user_with_balance(self, ctx: TestContext, balance: int = 1000) -> User:
        user = self.backend.create_user(
            name=f"tester_{ctx.run_id[:8]}",
            verified=True,
            balance=balance,
            run_id=ctx.run_id,
        )
        ctx.created_user_ids.append(user.user_id)
        return user

    def create_product_in_stock(self, ctx: TestContext, price: int = 100, stock: int = 10) -> Product:
        product = self.backend.create_product(
            title=f"demo_product_{ctx.run_id[:8]}",
            stock=stock,
            price=price,
            run_id=ctx.run_id,
        )
        ctx.created_product_ids.append(product.product_id)
        return product

    def create_paid_order_scene(self, ctx: TestContext, user_balance: int = 1000, product_price: int = 100):
        user = self.create_verified_user_with_balance(ctx, balance=user_balance)
        product = self.create_product_in_stock(ctx, price=product_price, stock=10)
        order = self.backend.create_order(user.user_id, product.product_id, ctx.run_id)
        ctx.created_order_ids.append(order.order_id)
        return user, product, order

    def cleanup(self, ctx: TestContext):
        self.backend.cleanup_by_run_id(ctx.run_id)

3. 测试用例:test_checkout.py

from fake_backend import FakeBackend
from test_data_factory import ScenarioFactory


def test_paid_order_success():
    backend = FakeBackend()
    factory = ScenarioFactory(backend)
    ctx = factory.new_context()

    try:
        user, product, order = factory.create_paid_order_scene(
            ctx,
            user_balance=500,
            product_price=200,
        )

        assert order.status == "PAID"
        assert order.amount == 200
        assert backend.users[user.user_id].balance == 300
        assert backend.products[product.product_id].stock == 9

        print("test_paid_order_success passed")
    finally:
        factory.cleanup(ctx)
        print("cleanup:", backend.count_by_run_id(ctx.run_id))


def test_paid_order_fail_and_retain():
    backend = FakeBackend()
    factory = ScenarioFactory(backend)
    ctx = factory.new_context()

    try:
        factory.create_paid_order_scene(
            ctx,
            user_balance=100,
            product_price=200,
        )
    except ValueError as e:
        print("expected failure:", e)
        counts = backend.count_by_run_id(ctx.run_id)
        print("retained for debug:", counts)
        assert str(e) == "insufficient balance"
    else:
        raise AssertionError("expected ValueError was not raised")


if __name__ == "__main__":
    test_paid_order_success()
    test_paid_order_fail_and_retain()

4. 运行方式

python test_checkout.py

预期输出类似:

test_paid_order_success passed
cleanup: {'users': 0, 'products': 0, 'orders': 0}
expected failure: insufficient balance
retained for debug: {'users': 1, 'products': 1, 'orders': 0}

这段代码背后的关键设计点

ctx.run_id 做数据隔离

这是最值得落地的一点。无论你最后用的是:

  • 真数据库
  • 微服务接口
  • 测试容器
  • 第三方系统沙箱

都建议有一个统一的测试运行标识。

这样做的好处:

  • 回收时不误删别人数据
  • 并发执行时更可控
  • 排查时可以按 run_id 聚合日志和数据

工厂返回“场景”,而不是原子对象

注意 create_paid_order_scene() 不是单纯创建一个订单,它是在构造一个可直接用于测试的业务场景

这比“创建用户”“创建商品”这种低层方法更适合复用,因为大多数测试其实验证的是场景。

测试用例不关心建数细节

用例只需要说:

  • 我需要一个已支付订单场景
  • 我需要一个余额不足场景

而不是自己拼十几步准备动作。

这能极大降低维护成本。


进一步落地:真实项目中怎么做

上面的示例只是简化版。到了真实系统,建议按以下方式扩展。

1. 为常见业务状态定义 Profile

比如电商系统,可以这样抽象:

from dataclasses import dataclass


@dataclass
class UserProfile:
    verified: bool = True
    balance: int = 1000
    has_default_address: bool = True
    has_unpaid_order: bool = False


@dataclass
class ProductProfile:
    stock: int = 10
    price: int = 100
    enabled: bool = True

好处是:

  • 用例声明更清晰
  • 参数标准化
  • 可跨项目成员复用

2. 引入分层数据源

现实里通常不是一个系统就够了。一个订单测试可能需要:

  • 用户中心
  • 商品中心
  • 订单服务
  • 支付服务
  • 营销服务

这时不要让测试直接散调各个服务,而是由 ScenarioFactory 统一编排。这样你未来重构接口时,改动面最小。


3. 处理异步一致性

很多测试数据不是“创建完立刻可用”的,尤其是有:

  • MQ
  • 定时任务
  • 缓存
  • 搜索索引

时,刚建的数据可能还没传播完。

我踩过一个坑:订单创建成功后,列表查询接口偶发查不到,后来发现是 ES 索引异步刷新。

所以这里建议两件事:

  • 封装 wait_until() 等待机制
  • 把最终可观察状态作为准备完成标志

示例:

import time


def wait_until(condition_func, timeout=5, interval=0.2):
    start = time.time()
    while time.time() - start < timeout:
        if condition_func():
            return True
        time.sleep(interval)
    return False

容量估算:为什么数据工厂要考虑“量”

很多团队设计时只考虑“能不能跑通”,但到了 CI 并发回归就会翻车。

一个简单估算方法:

假设:

  • 每次回归执行 300 条用例
  • 并发 10
  • 每条用例平均创建 1 用户、2 商品、1 订单
  • 每天执行 20 次流水线

那么每天新增数据量约为:

  • 用户:300 × 20 = 6000
  • 商品:600 0
  • 订单:6000

如果不清理,一周就可能把测试环境塞满,甚至拖慢查询和索引。

所以你至少要设计:

  1. 按 run_id 回收
  2. 按 TTL 定时清理
  3. 关键表索引
  4. 测试环境数据配额

一个常见回收策略

  • 成功用例:立即清理
  • 失败用例:保留 24~72 小时
  • 超时任务:定时删除过期 run_id 数据

常见坑与排查

下面这些坑很常见,而且往往不是框架 bug。

坑 1:数据看似隔离,实际上共享了底层资源

例如:

  • 用户是新的,但手机号是固定的
  • 订单是新的,但用了同一个优惠券池
  • 商品是新的,但库存扣的是同一个 SKU

排查方法

  • 检查唯一约束字段是否真的随机化
  • 看关联对象是否也被隔离
  • 对失败数据做完整链路追踪,而不是只看主对象 ID

坑 2:清理逻辑把排查现场一并删掉了

症状:

  • 用例失败
  • 日志提示数据不一致
  • 但数据库里什么都没有

建议

  • 将清理策略做成可配置
  • 在 CI 中支持 KEEP_FAILED_DATA=true
  • 保留 run_id、创建时间、创建来源

坑 3:用 SQL 造出的数据,不满足业务侧隐含规则

比如你手工插入了一条“已支付订单”,但系统真正要求的可能还有:

  • 支付流水表存在记录
  • 优惠券状态已核销
  • 账户流水已扣减
  • 消息补偿表存在状态

排查方法

  • 同步查看真实业务链路
  • 对照生产请求流程补全依赖
  • 优先改为通过内部 API 或领域服务造数

坑 4:并发时偶发冲突,单跑永远复现不了

这类问题最容易误判成“环境不稳定”。

本质通常是:

  • 唯一键冲突
  • 数据回收误删
  • 共享缓存 key
  • 时间窗口竞争

排查建议

  1. 给每次运行打印 run_id
  2. 把所有建数日志串起来
  3. 检查是否存在固定名称、固定手机号、固定订单前缀
  4. 在本地模拟并发执行

坑 5:工厂方法越来越多,最后没人敢改

这是体系成熟后会遇到的问题。

比如后来你会有:

  • create_vip_user
  • create_blacklist_user
  • create_refund_order
  • create_paid_order_with_coupon
  • create_paid_order_with_points_and_coupon_and_invoice

最后组合爆炸。

解法

不要只堆方法名,要逐步过渡到:

  • Profile
  • Builder
  • Preset Template

也就是通过参数描述状态,而不是每种状态写一个新方法。


安全/性能最佳实践

测试数据管理不仅是稳定性问题,也涉及安全和性能。

安全最佳实践

1. 严禁使用真实生产敏感数据

即使脱敏后,也要谨慎。推荐做法:

  • 使用完全虚构数据
  • 脱敏规则版本化
  • 在测试系统禁止导出真实敏感字段

2. 测试账号权限最小化

数据工厂所用账号应做到:

  • 仅允许测试环境使用
  • 权限最小
  • 可审计
  • 可撤销

3. 秘钥统一托管

不要把:

  • 数据库密码
  • 内部造数接口 token
  • 第三方沙箱密钥

直接写进仓库。应放在:

  • CI Secret
  • Vault
  • 环境变量管理平台

示例:

export TDM_API_TOKEN="***"
export TEST_DB_DSN="postgresql://user:pass@host:5432/testdb"

性能最佳实践

1. 避免每条用例重复造重型基础数据

像地区字典、商品类目、角色模板这类数据,适合:

  • 环境启动预置
  • 每日重建一次
  • 作为共享只读基础数据

2. 轻重分离

把数据分成:

  • 重数据:创建成本高、变化少
  • 轻数据:快速生成、按 run_id 隔离

这样可以兼顾速度和稳定性。

3. 批量清理优于逐条清理

如果每个对象删一次都要调接口,成本会很高。推荐按:

  • run_id
  • 创建时间区间
  • 测试套件标识

做批量清理。

示例 SQL:

DELETE FROM orders
WHERE run_id = :run_id;

DELETE FROM products
WHERE run_id = :run_id;

DELETE FROM users
WHERE run_id = :run_id;

4. 为测试数据字段建立必要索引

尤其是这些字段:

  • run_id
  • created_at
  • created_by
  • is_test_data

否则清理任务和排查查询会越来越慢。

示例:

CREATE INDEX idx_orders_run_id ON orders(run_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);

一份可执行的落地建议

如果你准备在现有项目里推进,可以按这个顺序做,不要一口吃太多。

第一步:先统一标识

先做最小闭环:

  • 所有自动化创建的数据都带 run_id
  • 流水线输出 run_id
  • 支持按 run_id 清理

这是性价比最高的一步。

第二步:抽一个场景工厂

先从最常用的 3~5 个场景开始:

  • 可登录用户
  • 可下单用户
  • 已支付订单
  • 可退款订单

把高频重复建数逻辑沉淀进去。

第三步:区分静态数据和临时数据

把基础字典、角色、配置项从用例建数中剥离出来,避免每次重复创建。

第四步:失败保留机制

给 CI 增加配置:

  • 成功自动清理
  • 失败保留现场
  • 定时任务回收过期数据

第五步:建立团队约束

至少约定清楚:

  • 用例里不允许直接写死共享账号
  • 优先走工厂方法
  • SQL 造数必须注明原因和边界
  • 每个新增场景模板都要可复用

总结

自动化测试做到中级阶段,真正拉开稳定性差距的,往往不是断言技巧,而是测试数据管理有没有体系化。

你可以把本文的核心原则记成 5 句话:

  1. 测试用例依赖场景状态,不依赖固定数据。
  2. 优先走业务接口造数,SQL 只做受控兜底。
  3. 所有运行时数据都应可标记、可追踪、可清理。
  4. 把高频前置条件沉淀为场景数据工厂。
  5. 成功清理、失败保留、定时回收,生命周期必须闭环。

如果你的团队现在还处于“脚本能跑,但经常不稳”的阶段,不妨先别急着加更多用例,先把测试数据这一层补齐。
因为一旦数据体系稳定下来,自动化测试的复用性、执行效率和可信度,都会明显提升。

说得更直白一点:
测试数据不是配角,它本身就是自动化测试架构的一部分。


分享到:

上一篇
《Java Web 开发中基于 Spring Boot + MyBatis 的高并发订单系统接口幂等与防重复提交实战》
下一篇
《Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离登录鉴权实战》