53.7 测试数据管理
一句话
测试数据的目标不是“库里有数据”,而是每个测试都能独立、确定、可重复地造出它需要的那一份;共享库、硬编码 ID、生产数据是三大污染源。
先破除五个误区
| 误区 | 现实 |
|---|---|
| “用生产数据最真实” | 生产数据含 PII、会变化、不可复现,还违反合规。真实的是结构,不是数据本身 |
| “固定 ID=1 最方便” | 并行、重复运行、多次执行都会撞;测试顺序一变就挂 |
| “一个测试库大家共用” | A 改的数据影响 B,清理互相踩;最后没人敢并行跑 |
| “fixture 越大越省事” | 大 fixture 把无关依赖带进来,改一个字段全挂,还看不出测试到底需要什么 |
“时间就用 time.Now()” | 跨天、跨时区、月末边界必出 flake;断言“创建于今天”的测试在 23:59 最脆弱 |
四条原则
| 原则 | 含义 | 违反的后果 |
|---|---|---|
| 独立 | 测试不依赖其他测试留下的数据 | 顺序依赖,单跑绿、全跑红 |
| 确定 | 同一输入永远同一结果(时钟、随机、ID 可控) | flaky,无法复现 |
| 最小 | 只造这个测试真正需要的字段 | 改 schema 时全库测试一起挂 |
| 可清理 | 测试结束能删掉自己造的数据 | 数据库越跑越脏,最终只能重建 |
三类数据,三种用法
| 类型 | 是什么 | 适合 | 不适合 |
|---|---|---|---|
| Fixture | 静态文件/固定记录(testdata/*.json) | 黄金文件、解析器输入、稳定样例 | 需要变化的状态、并发写 |
| Factory | 代码按需构造,可局部覆盖字段 | 绝大多数业务测试 | 需要复杂关联图的场景(要配合 builder) |
| Seed | 环境级初始化数据(迁移后灌入) | 本地开发、E2E 基础数据 | 单元/集成测试的独立数据 |
默认用 factory:它把“这个测试依赖哪些字段”写在测试里,比一份巨大的 fixture 清楚得多。
隔离方案:按成本从低到高
| 方案 | 做法 | 速度 | 局限 |
|---|---|---|---|
| 唯一前缀 + 清理 | 每个测试用自己的 ID 前缀,t.Cleanup 删除 | 最快 | 依赖清理纪律;中途崩溃会残留 |
| 事务回滚 | 整个测试包在事务里,结束 rollback | 快 | 代码里有 COMMIT/DDL 就不适用 |
| 独立 schema | 每个测试建 schema,结束 drop | 中 | 迁移/权限要适配 |
| 每测试一个容器 | testcontainers 起新库 | 慢(秒级) | 资源消耗大,适合关键集成测试 |
| 快照恢复 | 恢复到基线快照 | 中 | 快照维护成本高 |
大多数测试用“唯一前缀 + cleanup”即可;数据访问层的集成测试用事务回滚或容器(见 53.3 集成测试)。
可运行的例子:factory + 固定时钟 + 并行清理
package testdata
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type User struct {
ID string
Email string
Plan string
CreatedAt time.Time
}
type MemRepo struct {
mu sync.Mutex
users map[string]User
}
func NewMemRepo() *MemRepo { return &MemRepo{users: map[string]User{}} }
func (r *MemRepo) Save(u User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, dup := r.users[u.ID]; dup {
return fmt.Errorf("duplicate id %q", u.ID)
}
r.users[u.ID] = u
return nil
}
func (r *MemRepo) Delete(id string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.users, id)
}
func (r *MemRepo) Count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.users)
}
// Factory:按需造数据,ID 唯一、时间可控、可局部覆盖字段。
type Factory struct {
prefix string
seq atomic.Int64
now func() time.Time
}
func NewFactory(prefix string, now func() time.Time) *Factory {
return &Factory{prefix: prefix, now: now}
}
func (f *Factory) User(mut ...func(*User)) User {
n := f.seq.Add(1)
u := User{
ID: fmt.Sprintf("%s-%04d", f.prefix, n),
Email: fmt.Sprintf("%s+%04d@example.test", f.prefix, n),
Plan: "free",
CreatedAt: f.now(),
}
for _, m := range mut {
m(&u)
}
return u
}
package testdata
import (
"fmt"
"testing"
"time"
)
func TestParallelIsolation(t *testing.T) {
// 固定时钟:时间相关断言不用 sleep,也不会跨时区漂移。
fixed := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
f := NewFactory("t", func() time.Time { return fixed })
repo := NewMemRepo()
// 父测试最后检查:每个子测试都清理了自己的数据。
t.Cleanup(func() {
if n := repo.Count(); n != 0 {
t.Errorf("残留 %d 条测试数据", n)
}
})
for i := 0; i < 50; i++ {
i := i
t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
t.Parallel()
u := f.User(func(u *User) { u.Plan = "pro" })
if err := repo.Save(u); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { repo.Delete(u.ID) })
if u.CreatedAt != fixed {
t.Fatalf("CreatedAt=%v, want %v", u.CreatedAt, fixed)
}
if u.Plan != "pro" {
t.Fatalf("Plan=%q, want pro", u.Plan)
}
})
}
}
$ go test -race -count=20 ./...
ok example.com/testdata 1.342s
-count=20 重复 20 轮、-race 开检测,仍然全绿,说明数据与并发隔离成立。若把 seq 换成全局固定 ID,第二轮就会报 duplicate id——这就是“独立”被破坏的样子。
时间、随机与 ID:三个不确定源
| 来源 | 做法 | 反面 |
|---|---|---|
| 时间 | 注入 now func() time.Time,测试固定到某一刻(假时钟替身见 53.6 测试替身) | 直接 time.Now(),断言“今天/一小时内” |
| 随机 | 注入 rand.Source,固定 seed;或只断言不变量 | 每次随机、断言具体值 |
| ID | factory 生成唯一 ID(前缀 + 原子递增/UUID) | 硬编码 id=1、依赖数据库自增顺序 |
时区也要显式:用 time.UTC 造数据,展示层再按用户时区转换;否则 CI(UTC)和本机(Asia/Shanghai)结果不同。
生产数据:不能直接用,怎么“像真的”
| 需求 | 做法 |
|---|---|
| 字段长度/格式真实 | 用生产 schema 生成合成数据(faker),不复制值 |
| 统计分布真实 | 从生产采样分布参数,再按分布生成 |
| 复现线上 bug | 脱敏后最小化:只保留触发问题的字段组合 |
| 关联关系 | 用 factory 按外键顺序造,或用匿名化后的图结构 |
合规红线:生产数据出库前必须脱敏/匿名化,且要符合数据分级要求(见 38.5 数据分级与脱敏)。测试库里出现真实手机号/身份证,是事故,不是便利。
数据量:逻辑测试和性能测试分开
| 目的 | 数据量 | 说明 |
|---|---|---|
| 业务逻辑/边界 | 1–100 行 | 越小越好定位 |
| SQL/索引/查询计划 | 1 万–10 万行 | 让优化器有真实选择 |
| 性能/容量 | 100 万行+ | 单独环境、单独流水线(见 34.5 性能测试与负载模型) |
不要把“大数据量”塞进单元测试:一条测试跑 30 秒,开发者就会跳过整个包。
生产落地:指名 → 可抄 → 可验 → 可退
指名:集成测试用 testcontainers-go 起真实库;事务回滚可用 go-txdb;factory 手写优先(本章示例),复杂图用 builder。替代:本地 docker-compose + 迁移脚本,适合 E2E 环境。
可抄(清理模板):
u := factory.User() // 唯一 ID + 固定时间
t.Cleanup(func() { repo.Delete(u.ID) }) // LIFO:后注册先执行
可验:go test -race -count=20 ./... 全绿;故意把 factory 的 seq 改成常量,第二轮应报 duplicate id,证明隔离测试真的在起作用。
可退:
- 清理失败导致脏数据:给测试数据打 run-id 前缀,提供按前缀批量删除的脚本;不要手工
DELETE FROM users。 - 事务回滚不适用(代码有 DDL/COMMIT):切到唯一前缀 + cleanup,或独立 schema。
- 生产数据脱敏出问题:立即停止使用该数据集,按安全事件流程处理,并改用合成数据。
反模式
| 反模式 | 后果 |
|---|---|
| 测试共享一个数据库、互相依赖顺序 | 单跑绿、全跑红,无法并行 |
| 硬编码 ID/时间/随机值 | 重复运行冲突、跨天 flaky |
| 复制生产数据到测试库 | 合规事故 + 不可复现 |
| 巨型 fixture | 改 schema 全挂,测试意图被淹没 |
| 不清理或只在成功时清理 | 失败一次脏一片 |
用 time.Sleep 等数据就绪 | 慢且不稳,见 53.9 竞态检测与并发测试 |
| 逻辑测试塞百万行数据 | 测试变慢,最后没人跑 |
| 依赖自增 ID 顺序 | 换个库/并发插入就错位 |
自检
Q1 测试在本地全绿,CI 上偶发 duplicate key。最可能的原因是什么?
▸ 答案:数据不独立。常见三种:硬编码 ID/唯一键、多个测试共用同一份种子数据、并行测试共享自增序列。CI 上并行度和执行顺序与本地不同,冲突才暴露。修法是 factory 生成带 run-id 前缀的唯一键,每个测试 t.Cleanup 删自己的数据,并用 -count=20 -race 复现验证。
Q2 为什么推荐 factory 而不是一份大 fixture?
▸ 答案:factory 把“这个测试需要哪些字段”写在测试代码里,读测试就知道依赖;大 fixture 把所有字段塞进来,改 schema 时全部测试一起挂,还掩盖了测试真正关心的数据。fixture 适合稳定的黄金文件/解析输入,不适合会变化的业务状态。
Q3 生产数据脱敏后能直接用吗?
▸ 答案:要非常谨慎。脱敏必须不可逆、覆盖所有直接/间接标识符(含组合可识别),并符合数据分级与合规要求;即便如此,生产数据仍会变化、不可复现,不适合做测试基线。更好的做法是用生产 schema + 合成数据(分布参数可参考生产),只把脱敏后的最小案例用于复现线上 bug。
Q4 事务回滚隔离看起来很完美,什么时候不能用?
▸ 答案:被测代码自己 COMMIT、执行 DDL、或用独立连接池时,外层事务包不住它。另外有些数据库的 DDL 会隐式提交。此时改用唯一前缀 + cleanup,或每个测试独立 schema/容器。判断标准:测试结束后数据库状态是否真的回到基线。
Q5 测试数据量和测试层级怎么匹配?
▸ 答案:逻辑/边界测试 1–100 行,追求快和可定位;SQL/索引测试 1 万–10 万行,让优化器有真实选择;性能/容量测试百万行起,放独立环境和流水线。把大数据量塞进单元测试,会让反馈从毫秒变分钟,最终被跳过。
关联
- 前置:53.3 集成测试、53.6 测试替身
- 并列:53.9 竞态检测与并发测试
- 下游:34.5 性能测试与负载模型