为什么需要 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 2025-06-24 15:36  lvan100  阅读(38)  评论(0)    收藏  举报