测试数据与假依赖:一个种子、四个替身与三列脱敏

他用生产导出复现筛选缺陷,差点把客户真邮箱写进测试邮件。之后他做了 18 KB 的种子:1 租户、3 用户、4 项目、12 任务,重置 2.4 秒;四个外部依赖换替身,两个只读依赖用真库;邮箱、姓名、外部账号 ID 三列脱敏,一年省 2.72 小时。

测试数据的第一个要求不是像真的,而是不会变成真的。

一、一份生产导出差点发出真邮件

2026 年 9 月的一个周三下午,他为了复现「按负责人筛选任务时列表少一条」,从生产库导出了 344 个账号、1,180 条任务。文件落在本地临时目录,他改了三行代码,用这份数据起了服务,点了一遍筛选,问题复现了。接着他顺手点了一次「发送周报摘要」——邮件服务的地址来自本地配置,那一行还指着真实的发信服务商。

以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。

那份导出里有 344 个账号,其中 120 个是付费客户,还包括当周正在跟他来回发支持邮件的三个。也就是说,只要那封摘要发出去,收件人里就有刚投诉过他的人。

真正拦住他的不是代码,是控制台里跳出的第一个收件人:一个真实客户的域名。他关掉页面,删了文件,翻了发件日志确认没有投递成功。这之后再看他自己的测试环境,才发现它从来没有过「自己的数据」:跑测试用生产导出,造数据靠手点界面,改一次表结构就得重新导一遍。

轻的代价是 40 分钟清理本地文件与缓存;重的代价是他意识到测试环境的每一封邮件、每一次对象存储写入、每一个支付回调,都可能落到真实世界。测试数据的第一属性不是「像真的」,而是「不会变成真的」。

第二层问题更慢:生产快照会漂移。今天导出时有 1,180 条任务,下周客户删了 90 条,断言里写死的数字就红了;他改断言,再下周又红。一个月里改了三回「应该是几条」,最后把断言放宽成「大于 0」——那等于没有断言。

他试过折中:导出时只取 30 条。结果筛选分页从 12 页变成 1 页,缺陷复现不出来;他又把 30 条改成 300 条,文件从 18 KB 变成 2.1 MB,进入仓库后每次提交都在 diff 里晃。

三种做法都不成立,剩下的路只有一条:自己造一份小到能背下来、每次从零开始的数据,把外部世界换成可以随时重启的替身。

二、一个种子:一租户、三用户、四项目、十二任务

他把测试数据收成一份种子文件,18 KB,随仓库走。范围小到能背下来:1 个租户、3 个用户、4 个项目、12 条任务。

范围按「每条关键路径至少有一条可指向的记录」定,不按真实比例缩放。12 条任务对应 12 条关键路径,每条至少覆盖一个状态;4 个项目里有一个已归档、一个为空,用来测空列表与只读态;3 个用户分别是管理员、成员、只读,其中一个邮箱未验证。

种子刻意缺三样东西:真实姓名、真实时间戳、真实数量。所有时间固定在同一个基准日,逾期与否由「基准日减截止日」算出来,不依赖今天几号;否则每到月末就有几条任务从「未逾期」翻成「已逾期」,测试跟着日历变红。

对象数量关键取值覆盖的边界
租户1状态 trialing,时区 Asia/Shanghai试用期横幅、跨时区日期
用户3管理员 / 成员 / 只读,1 个未验证权限拒绝、未验证拦截
项目41 个归档、1 个空项目归档只读、空列表
任务124 种状态、2 条逾期、1 条无负责人筛选、排序、逾期计算
附件21 个 0 字节、1 个超长文件名上传失败、名称截断
审计日志5含 1 次权限变更变更记录与回显

重置是 2.4 秒:清空 0.3 秒、灌入 1.6 秒、跑到最新迁移 0.5 秒。这个数字决定它能不能被频繁使用——如果重置要 40 秒,人就会开始「这次先不重置」,测试之间互相污染。他的习惯是每个用例开头重置,一天大约 8 次。

清空用的是按表名倒序 truncate,不是 drop 整个 schema。drop 之后再重建要把 12 张表、8 个索引、3 个触发器全部跑一遍,实测 6.8 秒;truncate 保留结构,只清数据,快出来的 4 秒决定了「每个用例都重置」这件事能不能坚持。

种子必须幂等:重复灌入不产生第二份数据,靠固定主键而不是自增。固定主键还有个好处,断言里可以写 task_07,而不是「查出来的第一条」。自增主键在重置之后会从 1 重新开始,两次运行之间看起来一样,实际是两个人。

种子不进生产,也不从生产来。它由一份 seed.yaml 加一个加载脚本生成,脚本只做三件事:校验数量、灌入、打印校验和。校验和写进测试输出的第一行,失败时能立刻判断「是数据变了还是代码变了」。

18 KB 够用的原因是它只包含结构与边界值,不包含批量。要测分页,不必真有 300 条,把页大小临时设成 3 就有 4 页;要测超时,不必真等 30 秒,把超时设成 50 毫秒。种子里放的是「形状」,规模参数从配置来。

种子也要维护。每加一个状态、每加一种角色,都要回来补一条记录,否则新分支永远测不到。他的做法是把种子当成迁移的一部分:表结构加列的同一个提交里,种子必须同步加字段,CI 里跑一次「种子能否灌入最新结构」的检查。

格式选 YAML 而不是 SQL dump,是因为 diff 可读。加一条任务在 diff 里是七行结构化字段,review 时能看出改的是状态还是负责人;SQL dump 里同样的改动只会显示成一长串 INSERT,谁也看不出变了什么。

三、四个替身与两个真实只读依赖

外部依赖有六个,他的分法是四假两真。判据只有一句:这个依赖会不会产生不可撤销的外部副作用。会,就用替身;不会且输出是纯数据,就用真实只读版本,并在锁文件里钉死版本号。

依赖替身还是真实理由残留风险
支付替身回调会重放,测试卡也会留痕事件字段与真实响应漂移
邮件替身(内存收件箱)一次误发就是一次真实投递模板渲染错误只能靠快照发现
对象存储替身(本地目录)签名链接有时效,测试会因时钟漂移变红分片上传与限流测不到
OAuth替身(固定令牌端点)第三方登录无法在 CI 里自动完成令牌过期与授权范围变更测不到
时区库真实只读夏令时边界必须靠真库升级会改历史时区数据
邮编库真实只读格式规则数据量大,自造必错数据更新后断言过期

邮件替身除了「不发出去」,还要能被断言。内存收件箱提供两个查询:按收件人取最新一封、按模板名取全部;测试里写「给只读用户发邀请应失败」,断言的是收件箱为空,而不是日志里有没有一行字符串。

替身最容易犯的错是「比真的更好」。他的支付替身第一版永远返回成功,所有失败分支都没被测过;后来改成按请求参数返回四种结果:成功、卡被拒、需要二次验证、网络超时。四种结果来自一份录制的真实响应,字段名一个不改,只把金额与客户号换掉。

替身也要有契约测试。每个月拿一次沙箱环境的真实响应与替身响应做字段对比,少的字段报警。没有这一步,替身会随着上游升级慢慢变成一个「测试永远通过」的假世界。

两个真实只读依赖要写清「只读」的边界:时区库只做换算,不写任何状态;邮编库只查表,不发请求。它们不联网、不写盘,所以可以在单元测试里直接用。版本钉在锁文件里,升级是一次显式提交,而不是某天安装依赖之后的意外。

替身还有一个真实依赖给不了的能力:注入失败。支付替身可以被要求「第三个请求超时」,对象存储替身可以被要求「上传到 90% 时断开」。这些分支在沙箱环境里很难凑出来,却正是恢复逻辑最需要被验证的部分;每季度挑两个分支写成固定用例,比临场手点可靠。

替身放哪里也要定死。地址一律来自环境变量,测试配置里指向本地替身,生产配置里指向真实服务;代码里不出现 if (env === 'test') 这种分支,否则替身的行为会漏进生产。启动时打印一行「当前使用替身:支付、邮件、对象存储、OAuth」,让误配无处可藏。

四、三列脱敏与二点七二小时

生产数据不是不能用,是不能原样用。他的规则是三列必须换:邮箱、姓名、外部账号 ID。

邮箱换成保留域名下的固定值,user1@example.test 到 user3@example.test。不要保留真实域名,也不要只把 @ 前面打码——域名本身就能定位到客户。姓名换成「用户一」「用户二」,不留姓氏首字,三个人的租户里首字加部门就足以反推。

外部账号 ID 包括支付客户号与 OAuth 主体标识,一律重新生成,映射表只存在于导出脚本的内存里,不落盘、不进仓库。这样即使测试库被人拿到,也拼不回生产里的任何一个真人。

三列之外还有一处他踩过:任务描述是自由文本,客户在里面写过手机号与快递单号。三列脱敏管不到自由文本,所以快照进测试前还要跑一次正则扫描,命中就整段替换,而不是只替换匹配到的片段。

附件是第二处。对象存储里存的是客户上传的合同与截图,文件名本身就可能带公司名。快照进测试时,附件一律替换成 1 KB 的占位文件,文件名改成 file_1.pdf 这类值,原文件不下载、不落地,只保留大小与类型两个字段。

复算:
种子 18 KB,重置 2.4 秒 = 清空 0.3 + 灌入 1.6 + 迁移 0.5
2.4 秒 × 8 次/天 × 240 天 = 4,608 秒 = 76.8 分钟 = 1.28 h
手工造一次 20 分钟 × 12 次 = 240 分钟 = 4 h
净省 4 − 1.28 = 2.72 h

2.72 小时不算多,但它买到的不是时间,是「可以随时重置」这个前提。没有这个前提,受影响集、回归清单、门禁都建在流沙上:数据不确定,测试结果就不能用来做发布决定。

脱敏还有一条常被忽略的收尾:导出脚本要能重复运行,且每次从生产读、往测试写,中间不落地明文。他的脚本是流式的,读一行、换三列、写一行,临时文件在结束时删除;中途失败也要在 finally 里删掉。

处理完的数据也不该长期留着。本地测试库每次启动重建,CI 里跑在临时容器里、任务结束即销毁;需要复现线上问题时,只保留那一份经过 scan 的最小快照,用完手动删除并记录删除时间。

脱敏做完要有断言,不能靠人眼。测试启动时跑三条检查:邮箱域名必须在保留域名单里、姓名必须匹配「用户N」、外部 ID 必须匹配新生成的前缀;任何一条不满足就拒绝启动。这条检查拦住过一次把生产快照直接拷进测试库的操作。

五、行动清单与复算验收

把种子、替身与脱敏收进同一条启动流程,再用八条检查确认它不会把测试变成事故。

  • 写一份 18 KB 的 seed.yaml:1 租户、3 用户、4 项目、12 任务
  • 用固定主键灌入,重复执行不产生第二份数据
  • 把重置压到 3 秒以内,并在测试输出第一行打印校验和
  • 支付、邮件、对象存储、OAuth 四个依赖全部换成本地替身
  • 时区库与邮编库用真实只读版本,版本号钉进锁文件
  • 替身响应字段与录制的真实响应逐字段对齐,每月比对一次
  • 邮箱、姓名、外部账号 ID 三列按规则替换,映射表不落盘
  • 测试启动时断言三列已脱敏,不满足就拒绝运行

验收标准:从零重置一次不超过 3 秒,同一份种子连跑两遍结果一致,且测试进程的收件箱与支付记录里没有任何一个真实客户标识。

使用边界与实操验收

边界一:种子不覆盖性能。 12 条任务能验证逻辑,验证不了 1,180 条时的列表查询与导出耗时;容量类问题要用单独的生成器造量,且生成器产出的数据不含任何真实字段。

边界二:替身不能证明第三方行为。 替身证明的是「我方代码在给定响应下的行为」,证明不了上游真的会那样响应;上线前的沙箱联调不可省略,具体字段与限制以官方文档为准。

边界三:脱敏不等于匿名。 三列替换之后,任务标题、项目名、备注仍可能指向具体客户;对外分享测试数据前要先看自由文本,必要时整段清空而不是局部替换。

边界四:本文的种子规模、重置耗时与替身划分均为教学假设。 实际项目应按自己的关键路径数量调整,并以真实依赖的官方说明为准;本文不构成对任何数据处理方式合规性的承诺。

关键参考与核验范围

  • Stripe 测试文档 :用于核对测试卡号、测试时钟与测试事件的行为边界,替身的事件形状以此为参照。
  • PostgreSQL 文档 :用于核对每个用例在事务里回滚、以及用临时 schema 隔离测试数据的做法。
  • Vitest 指南 :用于核对测试夹具、setup 与 teardown 的组织方式。
  • The Twelve-Factor App :用于核对测试与生产配置分离、依赖地址来自环境变量的原则。

以上链接于 2026-10-26 实测可公开访问;未核验版本、价格与配额。核验日期:2026-10-26。

范围声明:本文只讨论测试数据的构造、外部依赖替换与三列脱敏,不讨论生产数据备份、跨境传输与合规审计;这些属于隐私组与运维组的范围。

系列导航

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Indie」更多文章

  1. 开源项目验收:十项能力、四维评分与三种不该开源的项目
  2. 治理、fork 与退出:17 位贡献者、82% 的提交与一次改协议的代价
  3. 双许可与企业版:九家询价、两家成交与 4,800 美元