测试库里的数据是人手工插的,没人知道哪条能删。这句话描述的不是脏数据,而是所有权缺失:数据没有主人,就没人能回答"这条为什么存在"、"删了会影响谁"、"重建要多久"。治理要解决的不是"怎么造数据",而是让数据可重建、可解释、可丢弃。本文只给判据与代价,不写测试框架操作手册。
1. 三个来源,三种维护成本曲线
| 来源 | 初始成本 | 维护成本 | 可重建性 | 适用 |
|---|---|---|---|---|
| 手工插入 | 最低 | 随时间线性上升且不可逆 | 无 | 一次性演示 |
| 生产脱敏 | 高(管道 + 合规) | 平稳,结构随生产演进 | 有 | 要真实分布与规模 |
| 夹具文件 | 中(需设计格式) | 平稳,改动集中 | 强(随代码版本化) | 绝大多数功能测试 |
判断口径是**"三个月后谁还能改它"**。手工数据三个月后没人敢改,因为删了不知道谁会红;夹具文件新人也能改,因为有 diff、有 review。测试数据的第一属性不是真实,是可维护。
2. 造数的最小结构
def make_order(*, order_id="ORD-FIX-001", user_id="USR-FIX-001",
amount_cents=1999, status="PAID"):
return repo.insert_order(order_id=order_id, user_id=user_id,
amount_cents=amount_cents, status=status)
三条硬约束:主键可预测(随机主键让断言只能靠返回值,跨进程即失效;固定 id 让清理能按前缀定位)、默认值合法(默认参数要能直接构造出业务成立的对象,否则每个调用点都在补参数)、不依赖顺序(跨实体依赖用显式参数传 id,不靠自增)。
| 纪律 | 反例 | 代价 |
|---|---|---|
| 进仓库、走 review | SQL 存在某人笔记里 | 没人知道数据从哪来 |
| 幂等可重跑 | 每次执行都 INSERT 新行 |
重复执行即脏数据 |
| 有清理入口 | 只能手工 DELETE |
库越用越乱 |
| 有版本记录 | 直接改生产导出文件 | 无法复现历史失败 |
造数脚本的读者是未来的同事,不是数据库。写在仓库里的能被 review 拦住,写在聊天记录里的只能靠记忆。
3. 生产脱敏:四类字段
| 字段类 | 例子 | 处理 | 可逆性 |
|---|---|---|---|
| 标识类 | 手机号、身份证、邮箱、openid | 保留格式替换(位数、前缀、校验位) | 不可逆 |
| 联系类 | 姓名、地址、公司 | 假名化(字典或生成) | 不可逆,需保持唯一 |
| 金额类 | 订单额、余额、费率 | 保序变换或加噪 | 可逆(需真实统计时) |
| 自由文本 | 备注、工单、聊天记录 | 截断或模板化 | 建议不保留 |
最容易漏的是第四类:"备注"里出现手机号、地址的概率远高于预期,而它通常不在规则表里。判据:凡不受结构约束的文本字段,默认按含 PII 处理。脱敏管道的成本不在技术而在规则维护——新增字段不同步加规则,下次导出就漏。兜底方案是导出后跑一次 PII 扫描(正则 + 校验位),把漏网字段变成可发现问题。
4. 脱敏后仍可用的两个条件
-- 反例:分别脱敏,关联关系断掉
UPDATE orders SET user_id = 'U-' || md5(random()::text);
-- 正例:同一映射表驱动,外键关系不变
UPDATE users u SET phone = m.fake_phone FROM pii_map m WHERE m.real = u.phone;
UPDATE orders o JOIN users u ON o.user_id = u.user_id
SET o.user_id = u.user_id;
| 条件 | 要求 | 不满足的后果 |
|---|---|---|
| 关联一致性 | 同一实体在所有表映射到同一假值 | 联表查不到,测试大面积假失败 |
| 分布形态 | 保留量级、稀疏度、长尾 | 查询计划失真,性能结论无效 |
| 约束合法 | 长度、格式、校验位、唯一 | 插入被约束拒绝 |
只有关联一致性是硬要求,其余越接近越好。为了分布真实而牺牲一致性,是最常见的错误取舍。
5. 脱敏失败的两个方向
| 方向 | 表现 | 影响 |
|---|---|---|
| 脱得不够 | 真实 PII 进了测试环境 | 泄露,往往很久后才发现 |
| 脱得过头 | 关联断裂、格式非法、全表同值 | 测试不可用,团队转回手工造数 |
后者更常见也更容易被忽视,因为它的表现只是"测试老是红"。可操作判据:脱敏后跑核心用例集,通过率应接近未脱敏时水平;显著下降说明规则破坏了可用性。
6. 隔离的三种方式与并发问题
| 方式 | 隔离强度 | 并行友好 | 主要问题 |
|---|---|---|---|
| 独立数据库 | 最高 | 好(按 worker 分库) | 建库成本、连接数、迁移同步 |
| 独立 schema | 高 | 好 | 权限与迁移脚本要支持多 schema |
| 事务回滚 | 低 | 差(共享序列与锁) | 不覆盖非事务副作用,自增不回收 |
| 前缀分区 | 中 | 好(数据带运行 id) | 所有表都要有分区键 |
事务回滚被高估:它只回滚事务内可见变更,缓存、消息、外部调用、文件都不回滚。"单跑能过、并行就红"多半是把回滚当成了完全隔离。判据:产生事务外副作用的测试不能依赖回滚。
7. 并行冲突来自共享命名空间
NS = os.environ.get("TEST_NS", f"w{os.getpid()}")
def uid(base): return f"{NS}-{base}" # 断言与清理都按 NS 定位
def cleanup(): repo.delete_where("id LIKE :p", p=f"{NS}-%")
| 冲突 | 成因 | 解法 |
|---|---|---|
| 主键冲突 | 各 worker 用同一固定 id | id 带 ns 前缀 |
| 唯一约束 | 手机号/邮箱写死同值 | 按 ns 派生 |
| 清理互杀 | 某 worker TRUNCATE 全表 |
只按前缀删 |
| 序列竞争 | 断言依赖自增值 | 断言改用业务键 |
随机化只应出现在"值"上,不应出现在"结构"上:随机 id 让断言无法引用,随机组合让失败无法复现。可复现随机 = 种子固定、序列确定。
8. 版本化与清理
./tools/testdb rebuild --schema head --seed fixtures/base.sql --ns "$TEST_NS"
./tools/testdb clean --ns "$TEST_NS" # 兜底,必须限定命名空间
| 策略 | 成本 | 适用 |
|---|---|---|
| 每次运行前重建 | 高(建库耗时) | 集成测试、CI |
| 增量迁移 + 按需重建 | 中 | 本地开发 |
| 只清理不重建 | 低 | 短期可行,长期失控 |
| 快照恢复 | 中 | 有稳定基线的项目 |
终局判断很简单:测试库坏了,能不能三分钟重建? 能,清理策略就不重要;不能,清理脚本写得再细也只是延缓腐烂。快照最易误用——与 schema 版本脱节后恢复出"看似正常但缺列"的库。
9. 种子数据、夹具与业务初始化
| 类型 | 内容 | 归属 | 变更频率 |
|---|---|---|---|
| 种子数据 | 字典、枚举、权限点、地区表 | 随代码版本 | 低 |
| 测试夹具 | 用例所需最小实体图 | 随测试代码 | 高 |
| 业务初始化 | 真实运营配置(费率、开关) | 随运营决策 | 不定 |
混在一个脚本里的结果是:改测试要动生产初始化脚本,或运营改配置把测试跑红。三者 review 路径不同,混在一起就都没有 review。
10. 分布偏差导致的假通过
| 偏差 | 典型缺失 | 后果 |
|---|---|---|
| 数量分布 | 永远只有 1–2 条 | 分页、批量、N+1 测不到 |
| 值域边界 | 缺 0、负数、最大值、NULL | 校验与溢出漏测 |
| 状态组合 | 只造终态 | 状态机迁移错误漏测 |
| 时间跨度 | 全是当前时间 | 过期、跨期、时区漏测 |
对策不是"造更多数据",而是在夹具里显式声明边界样本:涉及数量或状态的功能,至少一组"0 条 / 1 条 / 多条 / 边界值",由测试代码生成,不靠生产数据碰巧覆盖。
11. 外部依赖固定样本与联邦数据
fixtures/thirdparty/
payment_success.json # 录制的真实响应,去掉敏感字段
payment_timeout.json # 构造的超时样本
payment_dup_notify.json # 重复通知(验证幂等)
原则是录制 + 版本化 + 显式失败样本。只录成功响应等于只测顺利路径,必须构造超时、重复、乱序、部分成功,否则重试与幂等永远"看起来对"。
联邦数据(跨服务共享 id)更隐蔽:服务 A 依赖 B 的固定用户 id,B 改种子后 A 静默失败。解法两条:共享版本化 id 契约(fixtures/ids.json 进同一仓库),或各服务自建数据、通过 API 建关联。前者简单但耦合,后者干净但慢;服务数超过五个必须转后者。
12. 排错:测试数据相关的典型故障
| 现象 | 根因 | 处理 |
|---|---|---|
| 单跑通过、并行全红 | 共享命名空间或全表清理互杀 | id 加 ns 前缀,清理按前缀 |
| 偶发唯一约束报错 | 手机号/邮箱写死同值 | 按 ns 派生唯一值 |
| 本地通过、CI 失败 | 本地残留数据掩盖缺失依赖 | CI 每次重建 |
| 脱敏后大量用例失败 | 关联断裂或格式非法 | 映射表驱动,保外键一致 |
| 清理后仍有脏数据 | 事务外副作用未清 | 显式清缓存/消息/文件 |
| 断言依赖自增 id | 造数未固定主键 | 固定 id 或改业务键断言 |
13. 边界:什么时候不必建治理流程
| 场景 | 合理做法 |
|---|---|
| 纯逻辑测试(无 IO) | 内存对象即可 |
| 无数据库依赖 | 只治理外部依赖样本 |
| 团队一两人 | 一个造数模块 + 重建脚本 |
| 一次性原型 | 不治理,随项目丢弃 |
| 强合规环境 | 禁止生产数据入测试,只用合成数据 |
| 已有成熟平台 | 用平台的隔离与快照能力 |
治理成本是持续的(规则维护、review、重建耗时)。出现"没人敢删的数据"或"失败无法复现"才该开始治理;两者都没出现,先写一条重建脚本。
14. 自测
- 手工插入的测试数据,三个月后的主要问题是什么?
- 为什么随机主键会让断言变脆?
- 脱敏必须保住的两个条件里,哪一个是硬要求?
- 事务回滚做隔离,什么情况下会失效?
- 并行测试的主键冲突,最简解法是什么?
- 测试全绿但上线出事,数据可能缺了什么?