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 万行,让优化器有真实选择;性能/容量测试百万行起,放独立环境和流水线。把大数据量塞进单元测试,会让反馈从毫秒变分钟,最终被跳过。

关联