ARCHIVE / INITIALIZING000%
正在载入档案界面SYS.07
Interview Prep

单元测试

测试边界、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,不把单次输出当真理。

参考