🧪 单元测试:Go 语言中的命令模式 vs. 模拟对象(Mocks) 🔗

在 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 函数做了好几件事:

  1. 构建二进制文件。
  2. 读取源文件。
  3. 循环修改源码、测量构建耗时,并将文件还原。
  4. 写入最终报告。

直接对这个函数进行测试非常困难,因为它依赖四个外部组件:

许多开发者可能只会去测试自包含的纯函数 edit 和 report,而让核心的编排逻辑处于未测试状态。下面我们来看看如何改进设计,使整个流程都具备可测试性。


1️⃣ 方法 1:将外部调用上移到 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 的主循环在每次迭代中,依然从根本上需要与构建系统、时钟、文件系统和版本控制进行交互。


2️⃣ 方法 2:依赖注入(Mocks / Fakes) 🔗

传统的面向对象做法是依赖注入(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)。


3️⃣ 方法 3:命令模式(The Command Pattern) 🔗

与其为每一个操作定义专门的接口方法,不如把每一种副作用都视为一个通用的“命令(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。