单元测试
测试边界、AAA、Table-driven Test、Test Double、属性测试、Fuzz、并发与时间,以及 Agent 确定性组件的测试方法。
单元测试
单元测试中的“单元”并不固定等于单个函数,也不要求Mock所有依赖。它是能够快速、隔离且确定性验证的行为边界。高质量单元测试保护公开行为并允许内部重构;与实现细节过度耦合的测试则会显著增加重构成本。
1. 高质量单元测试的性质
- Fast:开发者愿意频繁运行;
- Isolated:不依赖测试顺序、真实网络和共享环境;
- Repeatable:同样输入稳定得到同样结果;
- Self-validating:自动断言,不靠人看日志;
- Focused:失败时能快速指向一段行为。
FIRST并非强制规范,但可用于检查测试可维护性下降的原因。
2. AAA与行为命名
Arrange:准备输入、依赖与前置状态
Act:只执行一次核心行为
Assert:验证输出、状态和必要交互
测试名写行为而不是函数编号:
TestCancelIncident_RunningTask_TransitionsToCancelling
TestCancelIncident_CompletedTask_ReturnsConflict
失败时报告应包含input / got / want,而不是一句深沉的assert failed。
3. Go Table-driven Test
func TestCanTransition(t *testing.T) {
tests := []struct {
name string
from Status
to Status
want bool
}{
{"pending starts", Pending, Running, true},
{"running succeeds", Running, Succeeded, true},
{"terminal cannot restart", Succeeded, Running, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CanTransition(tt.from, tt.to)
if got != tt.want {
t.Fatalf("CanTransition(%s, %s)=%v, want %v",
tt.from, tt.to, got, tt.want)
}
})
}
}
Table-driven Test适合一组共享结构的等价类与边界值。若每个Case需要大量布尔开关和较长Setup,应拆分场景或重新设计API,避免测试表包含过多隐含分支。
4. 区分Test Double类型
| 类型 | 行为 | 用途 |
|---|---|---|
| Dummy | 只占参数位 | 当前Case不使用的依赖 |
| Stub | 返回预设结果 | 驱动成功、超时、错误分支 |
| Fake | 有简化可工作实现 | In-memory Store、Fake Clock |
| Spy | 记录调用 | 验证参数或调用次数 |
| Mock | 带预期交互并自校验 | 严格协议交互 |
状态验证通常比脆弱的交互验证更稳。与其断言Repository某三个私有方法按精确顺序调用,不如断言事务后的业务状态和Outbox记录正确。只有调用本身就是契约时才验证交互,例如“危险Tool必须先Approval再Execute”。
接口应该由消费者定义
type IncidentStore interface {
Get(ctx context.Context, id string) (Incident, error)
CompareAndSwapStatus(ctx context.Context, id string, from, to Status) error
}
接口越窄,Fake越容易保持语义一致,实现耦合也越少。仅为便于Mock而将数据库对象抽象为包含大量方法的接口,只会把实现耦合转移到接口定义。
5. 输出与不变量验证
状态机Case应检查:
- 返回值或错误类型;
- 持久状态是否改变;
- 不该发生的副作用是否没发生;
- 事件是否只发一次;
- 重复请求是否幂等;
- Context取消后是否停止。
例如拒绝越权调用时,不只断言ErrForbidden,还要断言Tool没有被调用、审计记录存在、敏感参数没有进入日志。
6. 时间、随机数与ID的依赖注入
type Clock interface { Now() time.Time }
type IDGenerator interface { New() string }
type Random interface { Intn(n int) int }
测试用Frozen Clock和固定ID,生产用真实实现。直接在业务深处调用time.Now()、uuid.New()和全局随机数,会让边界时间、重放和Snapshot测试一起变得很有个性。
7. 属性测试与Metamorphic Test
Example-based Test验证几个已知输入;Property-based Test验证大量输入都应满足的性质:
- Encode后Decode得到原对象;
- 去重执行两次等于执行一次;
- 排序结果单调;
- 权限集合缩小时,可执行动作不会增加;
- Context预算减少时,选出的Token总数不能上升。
当正确输出难以精确写出时,Metamorphic Relation尤其好用。例如检索结果加入一份完全无关文档,不应让原Gold全部消失;Query只做大小写变化时,错误码检索结果应保持稳定。
8. Fuzz Testing
Go原生Fuzz使用Coverage Guidance变异输入并保留能扩展路径的Corpus:
func FuzzParseToolArguments(f *testing.F) {
f.Add([]byte(`{"service":"payment"}`))
f.Add([]byte(`{}`))
f.Fuzz(func(t *testing.T, data []byte) {
args, err := ParseToolArguments(data)
if err == nil {
if err := args.Validate(); err != nil {
t.Fatalf("parser accepted invalid args: %v", err)
}
}
})
}
适合Fuzz的边界:JSON/URL/协议Parser、模板、压缩、文件格式、Tool参数和安全过滤器。Fuzz Target必须快速、确定、无跨Case共享状态。找到的最小失败输入要进入Seed Corpus,后续普通go test也能回归。
9. 并发单测
并发缺陷不能仅通过重复运行测试发现,应组合使用以下方法:
go test -race发现Data Race;- Barrier控制Goroutine交错;
- Fake Clock测试Timeout和Lease;
- 重复运行放大低概率窗口;
- Invariant验证最终状态;
- 真数据库集成测试验证锁与隔离级别。
Race Detector没报错不等于没有逻辑竞态。两个请求都合法读到库存1,再各自扣减,就是业务Race,不一定有未同步内存访问。
10. Snapshot与Golden Test
Snapshot适合大而稳定的结构化输出,例如生成的OpenAPI、SQL Plan、Prompt模板。规则:
- Snapshot可读、可Review;
- 更新必须显式,不能在CI失败时自动执行
-update; - 屏蔽时间戳、随机ID等噪声;
- 核心Invariant仍用独立断言;
- 自然语言Snapshot只保护结构,不苛求每个标点。
11. Agent系统中的确定性单元测试
Prompt Builder:角色、变量、Evidence边界、版本
Output Parser:合法/非法JSON、缺字段、未知字段
Tool Router:Intent到允许Tool集合
Policy:租户、Scope、审批、预算、不变量
State Machine:阶段转换、取消、重入、恢复
Context Builder:Token预算、去重、Citation映射
Trace Recorder:Parent Span、脱敏、Hash、错误分类
这些都不需要真实模型。给Planner注入Fake Model:
{"action":"search_logs","arguments":{"service":"payment"}}
然后精确断言Executor调用了哪个Fake Tool、参数是否规范化、状态怎样变化。这样在模型升级时,可以区分Orchestrator缺陷与模型工具选择错误。
12. 明确Fake Model的验证边界
Fake Model适合验证编排,不证明真实模型能按预期输出;Fake Tool适合模拟错误,不证明真实MCP Server契约没变。因此组合:
大量确定性单元测试
+ 少量真实模型离线评测
+ 边界集成与Contract Test
+ 极少数端到端关键旅程
Fake输出需要覆盖成功、Malformed、Refusal、Tool Error、Timeout、重复调用和超预算,而不只是永远返回一条完美Action。
13. 常见反模式
- 只测Happy Path;
- 一个测试验证十种行为;
- 依赖执行顺序或共享全局状态;
- Mock内部实现而不是边界;
- 用
sleep等待并发结果; - 断言日志文本代替业务状态;
- 对自然语言做脆弱的全文字符串相等;
- 为覆盖率写没有断言的Case。
14. 面试速答
单元测试要不要访问数据库
通常不访问外部数据库,但“Unit”的边界由团队定义。真实数据库语义应由集成测试验证,不能使用In-memory Fake证明PostgreSQL行锁行为正确。
Mock越多越隔离吗
也越可能复制实现和制造虚假信心。只Mock真正的外部边界,优先验证可观察状态。
如何测试随机Agent
确定性组件用Fake Model精确断言;真实模型用固定数据集、多次采样、统计指标和可回放Trace,不把单次输出当真理。