为什么需要 mock?
在单元测试中,我们通常希望测试的目标是一个最小可控的单位。然而现实中的业务逻辑往往依赖多个外部模块,比如数据库、HTTP 服务、Redis 缓存、第三方 API 等。这些外部依赖不可控、执行缓慢,甚至可能有副作用,比如写入数据或发送消息。
为了让单测更快、更稳定、更可重复,我们需要将这些不可控的依赖 “替身”出来,也就是 mock 掉,以便只聚焦在我们真正想测试的那部分逻辑。
常见的 Mock 问题
虽然 mock 是测试利器,但很多人在使用 mock 时陷入了一个误区:mock 写得太满、太细、太具体。比如:
-
对每一个 mock 的调用次数进行断言 -
精确检查每个入参是否一致 -
所有依赖都 mock,哪怕是内部函数 -
所有可能的场景都覆盖测试
这样做的直接后果是,测试代码极其脆弱:一旦实现代码有微小改动,比如多调用了一次日志函数,测试就挂了。这种过度测试会反过来让我们害怕去重构、优化业务逻辑,因为改动就意味着维护大量的测试。
黑盒测试 vs 白盒测试:哪种更适合 Mock?
在讨论如何使用 mock 之前,有必要先厘清一个概念:我们在测试时,到底是以“黑盒”视角来看待被测单元,还是以“白盒”视角?
黑盒单测:只关心输入和输出
所谓黑盒测试,就是把函数当作一个输入输出的函数。你只关心:
-
给定输入 -
得到什么输出(或副作用)
黑盒单测的核心理念是:只要输入不变,输出符合预期,代码怎么实现我不关心。这种方式更能容忍内部结构的调整,因此更具弹性和稳定性。
例如:
func GetUserAge(api ApiClient, userID string) int {
data := api.FetchUser(userID)
return data.Age
}
黑盒测试写法:
mockApi := new(MockApiClient)
mockApi.On("FetchUser", "u123").Return(User{Age: 30})
age := GetUserAge(mockApi, "u123")
assert.Equal(t, 30, age)
这里我们只关心最终返回的 age 是否正确,至于 FetchUser 调用了几次、用的参数是否有 log,都不关心。
白盒单测:验证内部行为
白盒测试则关注代码的执行路径,比如函数是否调用了某个子函数、是否执行了某个分支。它常常会配合 mock 来验证内部行为是否符合预期。
例如:
mockApi.AssertCalled(t, "FetchUser", "u123")
mockApi.AssertNumberOfCalls(t, "FetchUser", 1)
这种测试虽然精确,但问题在于这种测试紧紧依赖调用细节,即使返回值没变,只要 FetchUser 多调用一次,测试就挂了。
过度测试的危害
我们不能否认白盒测试在某些复杂场景下(比如安全敏感路径、边界条件处理)是有价值的,但在常规业务开发中,如果所有测试都采用白盒思路,就会导致:
-
测试代码与实现代码高度耦合 -
频繁修改测试代码 -
重构成本增加 -
测试代码膨胀,阅读困难
因此,一个更务实的选择是:尽可能采用黑盒思维,只在必要时才落入白盒逻辑。
Mock 的两条核心准则
为了让 mock 写得更有“弹性”,我在自己的工作经验中提取出了下面两个最基本、也最关键的准则。
准则一:输入型 mock -- 只需设定返回值,不用断言其行为
有些 mock 是为了提供某种输入数据,例如读取配置、获取用户信息、调用远程服务等。这类 mock 的核心作用是提供依赖数据,只要它能正常返回需要的内容即可。
你不需要关心:
-
它有没有被调用 -
被调用了几次 -
入参是不是某个具体值(除非该值很关键)
示例:
mockApi := new(MockApiClient)
mockApi.On("FetchUser", mock.Anything).Return(User{Age: 25})
// 不需要断言 mockApi 被调用几次,甚至可以不被调用
result := ServiceLogic(mockApi)
assert.Equal(t, expectedValue, result)
这种做法的好处是:即使后续业务代码重构,只要返回的 mock 数据没有变,测试就仍然有效。
准则二:输出型 mock -- 只在关心时才断言调用或顺序
另一类 mock 是用于观察代码的输出行为,比如:
-
是否写入数据库 -
是否发送了消息 -
是否调用了第三方接口
对于这种 mock,我们可以在特别重要的地方加上断言,比如:
-
是否真的调用了发送函数 -
调用顺序是否正确(比如先存库再发消息)
但也不需要每一个输出都去断言。日志输出、调试信息这类副作用不太重要的内容,可以忽略不测。
示例:
mockNotifier := new(MockNotifier)
mockNotifier.On("Send", "order-created").Return(nil)
CreateOrder(mockNotifier)
mockNotifier.AssertCalled(t, "Send", "order-created")
如果你不关心顺序,就不需要 AssertCalledInOrder 这种高度耦合的断言。
Mock 的两条辅助准则
前面我们讲了两条最基础的 mock 准则:输入型 mock 不断言行为,输出型 mock 只在关心时才断言顺序。它们已经可以帮助我们写出稳定、可维护的测试了。
但是,还有两条 mock 的辅助准则,也非常值得遵守,特别是在大型项目中,它们可以极大降低测试的维护成本。
准则三:只 mock 协作对象(不要 mock 自己的代码)
协作对象是指你要测试的函数依赖的外部依赖,这些依赖通常不归你控制,比如:
-
调用第三方接口的客户端 -
发通知的消息模块 -
读写数据库的 DAO 层 -
标准库里的网络、时间等操作
你应该 mock 它们,因为你没有办法也不可能在测试中直接使用它们。
但是也有一种情况经常被误用:mock 自己模块中的内部函数,比如测试 A 函数时 mock 了它调用的 B 函数,而 B 函数其实就在你控制的代码库里。
这通常是不必要的,甚至是反模式的。
为什么?
因为你本来就可以在测试中直接走通 B 函数,mock 掉反而导致测试偏离真实路径,覆盖率也不准确。
不推荐的例子(mock 自己的函数):
// 实际函数
func Process(id string) {
data := getData(id)
save(data)
}
// 测试时 mock 了 getData,自家函数!
var getData = func(id string) string {
return "mocked"
}
这样写虽然测试通过了,但你完全失去了对 getData 实现的测试。
准则四:优先使用接口进行 mock,而不是结构体方法
Go 的一大特色是接口设计。你在进行 mock 时,如果直接 mock 结构体的某个方法,可能会碰到很多问题,比如:
-
不能方便地替换具体实现 -
使用 mock 库很麻烦,api 设计的也不好 -
结构体过于具体,耦合太强,导致测试无法复用
最好的做法是:把你的依赖抽象成接口,在业务代码中依赖接口而非具体结构体。
推荐做法(依赖接口,容易 mock):
type UserFetcher interface {
Fetch(id string) User
}
func GetUserName(fetcher UserFetcher, id string) string {
return fetcher.Fetch(id).Name
}
type MockFetcher struct{}
func (m *MockFetcher) Fetch(id string) User {
return User{Name: "Alice"}
}
这样,mock 实现只需要自己写个 struct 或用 gsmock、mockgen 等生成即可。简单清晰,解耦合理。
结语:测试应该验证“行为边界”,而不是实现细节
其实,测试的本质,不是验证“这段代码是不是这样写的”,而是验证“这段代码在特定条件下,是不是做了该做的事”。也就是说,
测试不该验证实现细节,而应验证可观察的行为边界。
只有当你关注输入和输出、只 mock 外部依赖、不关心不必要的细节时,测试才能:
-
成为重构的安全网 -
不因实现微调而频繁破碎 -
更接近真实使用场景
----------------------------------------------------
欢迎关注 Go-Spring 项目!
GitHub 地址:https://github.com/go-spring/spring-core
欢迎扫码关注微信公众号 GoSpring实战,获取更多实战分享。

欢迎扫码加入 Go-Spring 讨论群,与开发者们共同探讨技术,交流经验。

您的支持对 Go-Spring 非常重要!欢迎点赞、在看、分享,助力 Go-Spring 的成长与发展!
posted on
浙公网安备 33010602011771号