在 Go 语言中编写单元测试时,处理诸如文件系统、系统时钟或外部命令之类的外部依赖往往颇为棘手。本文将探讨解决这一问题的三种方法,并最终展示为什么**命令模式(Command Pattern)**能够替代复杂的模拟对象(Mocks),提供一种更加简洁、易于测试的方案。
我们先来看一个例子:一个用于对增量构建(incremental builds)进行基准测试的函数。
func Benchmark(binary string, sourcePath string, resultPath string, numBuilds int) error {
if err := build(binary); err != nil {
return err
}
source, err := read(sourcePath)
if err != nil {
return err
}
deltas := []time.Duration{}
for i := 0; i < numBuilds; i++ {
if err := write(sourcePath, edit(source, i)); err != nil {
return err
}
start := now()
if err := build(binary); err != nil {
return err
}
delta := now().Sub(start)
deltas = append(deltas, delta)
if err := revert(sourcePath); err != nil {
return err
}
}
return write(resultPath, report(deltas))
}
这个 Benchmark 函数做了好几件事:
直接对这个函数进行测试非常困难,因为它依赖四个外部组件:
build()read() 和 write()now()revert()许多开发者可能只会去测试自包含的纯函数 edit 和 report,而让核心的编排逻辑处于未测试状态。下面我们来看看如何改进设计,使整个流程都具备可测试性。
Main
🔗有时候,你可以把外部依赖向上推给调用方(通常是 main 函数)。
func Benchmark(binary string, sourcePath string, source string, numBuilds int) (string, error) {
// ... core loop logic remains ...
// returning the report string instead of writing it
return report(deltas), nil
}
func Main() error {
// setup and initial build moved here
source, _ := read(sourcePath)
result, _ := Benchmark(binary, sourcePath, source, 3)
return write(resultPath, result)
}
评价: 虽然这有一定帮助,但在这里还远远不够。Benchmark 的主循环在每次迭代中,依然从根本上需要与构建系统、时钟、文件系统和版本控制进行交互。
传统的面向对象做法是依赖注入(Dependency Injection)。你可以定义一个代表所有外部操作的接口。
type Delegate interface {
Build(binary string) error
Read(path string) (string, error)
Write(path string, content string) error
Now() time.Time
Revert(path string) error
}
type BenchmarkRunner struct {
Delegate Delegate
}
func (b *BenchmarkRunner) Run(binary string, sourcePath string, resultPath string, numBuilds int) error {
// ... implementation using b.Delegate.Build(), b.Delegate.Now(), etc ...
}
评价: 这种方法行之有效,允许你在测试中换入一个伪造的(fake)委托实现。然而,它要求你为每一种副作用都定义接口方法和 Mock 实现,这很容易导致接口膨胀(interface bloat)。
与其为每一个操作定义专门的接口方法,不如把每一种副作用都视为一个通用的“命令(Command)”,交由一个核心的“执行器(Runner)”去运行。
首先,定义运行一个命令的接口:
type Runner interface {
Run(cmd Command) (Result, error)
}
接着,将具体的命令定义为简单的结构体(或常量):
type BuildCmd struct { Binary string }
type ReadCmd struct { Path string }
type WriteCmd struct { Path string; Content string }
type NowCmd struct {}
type RevertCmd struct { Path string }
现在,重构基准测试逻辑,让它返回自己想要运行的命令,或者将控制权交给执行器(Runner):
type BenchmarkProcess struct {
runner Runner
}
func (p *BenchmarkProcess) Execute(binary, sourcePath, resultPath string, numBuilds int) error {
_, err := p.runner.Run(BuildCmd{Binary: binary})
if err != nil { return err }
readRes, err := p.runner.Run(ReadCmd{Path: sourcePath})
if err != nil { return err }
source := readRes.(string) // Type assertion needed here
// ... rest of the loop using p.runner.Run() ...
}
这种模式的真正威力在单元测试中体现得淋漓尽致。你只需要编写一个 FakeRunner,而不必为每个依赖项实现复杂的 Mock 接口。
测试代码由此变成了一份由“预期命令”与“模拟响应”组成的简单列表:
func TestBenchmark(t *testing.T) {
// 1. Define the script: "When you see this command, return this result"
fakeRunner := NewFakeRunner([]Step{
{Cmd: BuildCmd{"binary"}, Err: nil},
{Cmd: ReadCmd{"source_path"}, Res: "source code", Err: nil},
{Cmd: WriteCmd{"source_path", "edited code"}, Err: nil},
{Cmd: NowCmd{}, Res: time.Unix(0, 0), Err: nil},
// ... and so on ...
})
p := BenchmarkProcess{runner: fakeRunner}
err := p.Execute("binary", "source_path", "result_path", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 2. Verify the runner consumed all expected steps
if !fakeRunner.IsDone() {
t.Fatalf("did not execute all expected commands")
}
}
通过将函数调用转化为数据结构(Commands),你可以将业务逻辑与其副作用彻底解耦。
获取《卡乌埃的生活方式》精选离线版——一份全面优化 🍏 健康、💰 财富、❤️ 爱 与 🧘 意义 的实践指南。
当我发布关于美好生活的新随笔、读书笔记和实践指南时接收通知。绝无垃圾邮件——可随时退订。
有任何问题、见解或建议吗?欢迎在下方分享您的想法。评论功能由 GitHub Discussions 驱动(无广告、无垃圾信息)——点击下方的使用 GitHub 登录即可发表评论或互动(注册免费 GitHub 账号不到一分钟即可完成)。
最后更新于 2026年10月11日.
免责声明:本站所有内容仅供信息参考与教育用途,不构成任何医疗、心理、财务、投资、税务或法律建议。此处呈现的观点、整理与见解仅代表我个人,不代表我雇主的立场。书籍、演讲及文章摘要均为独立的教育性评论,其知识产权归原作者所有。本站可能使用 AI 工具辅助编辑、排版与归纳;尽管我们竭力确保准确无误,但仍可能存在疏漏。隐私政策:本站使用第三方服务提供可选的邮件通讯订阅(follow.it)、评论(giscus / GitHub Discussions)以及嵌入媒体(YouTube),这些服务可能会依据各自的隐私政策处理访客的 IP 地址和 Cookie。