Go Mock 测试实战——gomock、mockery、interface 设计与可测试性
Go Mock 测试实战——gomock、mockery、interface 设计与可测试性
适读人群:Go 开发工程师、后端工程师 | 阅读时长:约 15 分钟 | 核心价值:掌握 Go Mock 生成工具链,通过良好的 interface 设计实现真正的单元隔离
上周刚给小陈讲完 testing 包和 testify,他周末就把组里所有新代码都加上了测试。周一他来找我,满脸困惑:"老张,我碰到问题了。我有个 OrderService,里面要查数据库、发短信、调三方支付接口——我单测根本没法写,一跑就要连数据库,速度巨慢,还不稳定。"
我问他:"你的 OrderService 是直接 new 了 MysqlOrderRepo 对象,还是通过 interface 注入的?"
他沉默了几秒,说:"直接 new 的……"
我说:"这就是根本问题所在。不是 Mock 工具的问题,是代码结构的问题。Mock 的前提是 interface,interface 的前提是依赖注入。我们今天把这整条链路理清楚。"
1. 为什么需要 Mock?
单元测试的核心原则是隔离——被测单元不应该依赖外部不可控的系统。数据库、Redis、HTTP 接口、消息队列……这些都是"不可控"的:
- 速度慢(网络 IO、磁盘 IO)
- 状态不可预测(数据库里有什么数据?)
- 可能不可用(CI 环境没有 Redis)
- 副作用难清理(发了真实短信怎么办?)
Mock 的作用就是用"假对象"替换这些外部依赖,让你的单元测试只关注被测逻辑本身。
2. interface 设计:可测试代码的基础
// 不可测试的写法:直接依赖具体实现
type OrderService struct {
repo *MySQLOrderRepository // 具体类型,无法替换
sms *AliyunSMSClient // 具体类型,无法替换
}
// 可测试的写法:依赖 interface
type OrderRepository interface {
FindByID(id string) (*Order, error)
Save(order *Order) error
List(userID string) ([]*Order, error)
}
type SMSNotifier interface {
Send(phone, message string) error
}
type OrderService struct {
repo OrderRepository // interface,可以注入任何实现
notifier SMSNotifier // interface,可以注入 mock
}
// 构造函数接受 interface
func NewOrderService(repo OrderRepository, notifier SMSNotifier) *OrderService {
return &OrderService{repo: repo, notifier: notifier}
}Interface 设计原则——小而专注:
// 反例:大而全的 interface,难以 Mock
type UserRepository interface {
FindByID(id string) (*User, error)
FindByEmail(email string) (*User, error)
Save(user *User) error
Delete(id string) error
List(page, size int) ([]*User, error)
Count() (int64, error)
Transaction(fn func(tx UserRepository) error) error
}
// 正例:按使用场景拆分
type UserReader interface {
FindByID(id string) (*User, error)
FindByEmail(email string) (*User, error)
}
type UserWriter interface {
Save(user *User) error
Delete(id string) error
}
type UserLister interface {
List(page, size int) ([]*User, error)
Count() (int64, error)
}3. gomock:官方推荐的 Mock 框架
gomock 是 Google 官方维护的 Mock 框架,通过代码生成来创建 Mock 对象。
3.1 安装
go install go.uber.org/mock/mockgen@latest注意:原
github.com/golang/mock已停止维护,推荐使用go.uber.org/mock。
3.2 生成 Mock
// file: repository/order.go
//go:generate mockgen -destination=../mocks/mock_order_repository.go -package=mocks . OrderRepository
package repository
type OrderRepository interface {
FindByID(id string) (*model.Order, error)
Save(order *model.Order) error
}执行生成:
go generate ./...或者直接运行:
mockgen -source=repository/order.go -destination=mocks/mock_order_repository.go -package=mocks3.3 使用生成的 Mock
package service_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"example.com/app/mocks"
"example.com/app/model"
"example.com/app/service"
)
func TestOrderService_CreateOrder(t *testing.T) {
ctrl := gomock.NewController(t)
// ctrl.Finish() 在 Go 1.14+ 可以省略,gomock 自动调用
mockRepo := mocks.NewMockOrderRepository(ctrl)
mockNotifier := mocks.NewMockSMSNotifier(ctrl)
svc := service.NewOrderService(mockRepo, mockNotifier)
t.Run("创建订单成功", func(t *testing.T) {
// 设置期望:Save 方法被调用一次,传入任意 Order,返回 nil
mockRepo.EXPECT().
Save(gomock.Any()).
Return(nil).
Times(1)
// 设置期望:短信通知被调用一次
mockNotifier.EXPECT().
Send(gomock.Eq("13800138000"), gomock.Any()).
Return(nil).
Times(1)
order, err := svc.CreateOrder("user-001", "13800138000", 99.9)
require.NoError(t, err)
assert.NotEmpty(t, order.ID)
assert.Equal(t, 99.9, order.Amount)
})
t.Run("数据库保存失败", func(t *testing.T) {
dbErr := errors.New("connection refused")
mockRepo.EXPECT().
Save(gomock.Any()).
Return(dbErr).
Times(1)
// 保存失败时不应该发短信
mockNotifier.EXPECT().Send(gomock.Any(), gomock.Any()).Times(0)
_, err := svc.CreateOrder("user-001", "13800138000", 99.9)
assert.Error(t, err)
assert.ErrorIs(t, err, dbErr)
})
}3.4 gomock Matcher 详解
// 精确匹配
gomock.Eq("exact-value")
// 任意值
gomock.Any()
// 自定义匹配
gomock.AssignableToTypeOf(&model.Order{}) // 类型匹配
// 条件匹配
gomock.Cond(func(x interface{}) bool {
order, ok := x.(*model.Order)
return ok && order.Amount > 0
})
// 组合匹配
gomock.All(gomock.Any(), gomock.Not(gomock.Nil()))踩坑记录 1:Times(0) 和不设置 EXPECT 的区别
如果你想断言某个方法不被调用,必须显式设置 Times(0)。如果什么都不设置,gomock 默认不验证调用次数——这意味着即使方法被意外调用,测试也不会失败。很多人以为"没设置 EXPECT 就是断言不调用",其实不是。
4. mockery:更现代的 Mock 生成工具
mockery 是另一个流行的 Mock 生成工具,配置更灵活,支持批量生成。
4.1 安装
go install github.com/vektra/mockery/v2@latest4.2 配置文件(推荐)
在项目根目录创建 .mockery.yaml:
with-expecter: true # 生成类型安全的 EXPECT() 方法
keeptree: false # mock 文件放在统一的 mocks 目录
outpkg: mocks
output: mocks
packages:
example.com/app/repository:
interfaces:
OrderRepository:
UserRepository:
example.com/app/notifier:
interfaces:
SMSNotifier:
EmailNotifier:运行:
mockery4.3 使用 mockery 生成的 Mock(with-expecter 模式)
func TestOrderService_GetOrder(t *testing.T) {
mockRepo := mocks.NewMockOrderRepository(t) // 自动注册 t.Cleanup
expectedOrder := &model.Order{
ID: "order-001",
Amount: 199.9,
Status: model.OrderStatusPaid,
}
// 类型安全的 EXPECT,IDE 有代码提示
mockRepo.EXPECT().
FindByID("order-001").
Return(expectedOrder, nil).
Once()
svc := service.NewOrderService(mockRepo, nil)
order, err := svc.GetOrder("order-001")
require.NoError(t, err)
assert.Equal(t, expectedOrder, order)
}with-expecter: true 模式下,EXPECT().FindByID(...) 是类型安全的——传错参数类型,编译期就报错,而不是运行时。这比 gomock 的 Any() 要更安全。
5. 手写 Mock:何时不需要生成工具
对于简单场景,手写 Mock 往往更直观:
// 手写 mock,适合接口方法少、逻辑简单的场景
type mockOrderRepo struct {
orders map[string]*model.Order
saveErr error
findErr error
saveCalls int
}
func (m *mockOrderRepo) FindByID(id string) (*model.Order, error) {
if m.findErr != nil {
return nil, m.findErr
}
if o, ok := m.orders[id]; ok {
return o, nil
}
return nil, repository.ErrNotFound
}
func (m *mockOrderRepo) Save(order *model.Order) error {
m.saveCalls++
if m.saveErr != nil {
return m.saveErr
}
m.orders[order.ID] = order
return nil
}手写 Mock 的优点:无工具依赖,可以自由添加调用计数、参数记录等辅助能力。缺点:interface 变更时需要手动同步。
6. 完整可测试代码示例
下面是一个完整的订单服务,展示 interface 设计 + gomock 的最佳实践:
// === 生产代码:service/order_service.go ===
package service
import (
"fmt"
"time"
"github.com/google/uuid"
"example.com/app/model"
"example.com/app/repository"
)
var (
ErrInvalidAmount = fmt.Errorf("invalid amount: must be positive")
ErrOrderNotFound = fmt.Errorf("order not found")
)
//go:generate mockgen -destination=../mocks/mock_order_repo.go -package=mocks . OrderRepository
type OrderRepository interface {
FindByID(id string) (*model.Order, error)
Save(order *model.Order) error
}
//go:generate mockgen -destination=../mocks/mock_notifier.go -package=mocks . PaymentNotifier
type PaymentNotifier interface {
NotifySuccess(userID string, amount float64) error
}
type OrderService struct {
repo OrderRepository
notifier PaymentNotifier
}
func NewOrderService(repo OrderRepository, notifier PaymentNotifier) *OrderService {
return &OrderService{repo: repo, notifier: notifier}
}
func (s *OrderService) PlaceOrder(userID string, amount float64) (*model.Order, error) {
if amount <= 0 {
return nil, ErrInvalidAmount
}
order := &model.Order{
ID: uuid.New().String(),
UserID: userID,
Amount: amount,
Status: model.OrderStatusPending,
CreatedAt: time.Now(),
}
if err := s.repo.Save(order); err != nil {
return nil, fmt.Errorf("save order: %w", err)
}
// 非关键路径,通知失败不影响下单
if err := s.notifier.NotifySuccess(userID, amount); err != nil {
// 记录日志但不返回错误
fmt.Printf("notify failed for user %s: %v\n", userID, err)
}
return order, nil
}
// === 测试代码:service/order_service_test.go ===
package service_test
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"example.com/app/mocks"
"example.com/app/service"
)
func TestOrderService_PlaceOrder(t *testing.T) {
tests := []struct {
name string
userID string
amount float64
repoErr error
notifyErr error
wantErr error
wantSaveCalls int
}{
{
name: "下单成功",
userID: "u-001",
amount: 99.9,
wantSaveCalls: 1,
},
{
name: "金额非法",
userID: "u-001",
amount: -10,
wantErr: service.ErrInvalidAmount,
},
{
name: "数据库失败",
userID: "u-001",
amount: 99.9,
repoErr: errors.New("db error"),
wantErr: errors.New("db error"),
wantSaveCalls: 1,
},
{
name: "通知失败不影响下单",
userID: "u-001",
amount: 99.9,
notifyErr: errors.New("sms timeout"),
wantSaveCalls: 1,
// wantErr 为 nil,说明下单仍成功
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockOrderRepository(ctrl)
mockNotifier := mocks.NewMockPaymentNotifier(ctrl)
if tt.wantSaveCalls > 0 {
mockRepo.EXPECT().
Save(gomock.Any()).
Return(tt.repoErr).
Times(tt.wantSaveCalls)
}
if tt.repoErr == nil && tt.amount > 0 {
mockNotifier.EXPECT().
NotifySuccess(tt.userID, tt.amount).
Return(tt.notifyErr).
Times(1)
}
svc := service.NewOrderService(mockRepo, mockNotifier)
order, err := svc.PlaceOrder(tt.userID, tt.amount)
if tt.wantErr != nil {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr.Error())
assert.Nil(t, order)
} else {
require.NoError(t, err)
require.NotNil(t, order)
assert.Equal(t, tt.userID, order.UserID)
}
})
}
}7. 踩坑汇总
踩坑记录 2:Mock 没有重置,跨测试污染
使用 gomock.NewController(t) 时,controller 是和 t 绑定的,每个测试都会自动 Finish。但如果你在 TestMain 或 Suite 里共用一个 controller,不同测试的 EXPECT 设置会互相干扰。解决方案:每个测试方法都创建新的 controller。
踩坑记录 3:EXPECT 顺序断言
gomock 默认不关心方法调用顺序。如果你需要验证顺序(比如先查询后更新),要用 gomock.InOrder:
gomock.InOrder(
mockRepo.EXPECT().FindByID("123").Return(order, nil),
mockRepo.EXPECT().Save(gomock.Any()).Return(nil),
)8. 依赖注入的最佳实践
Mock 测试能不能用得好,很大程度上取决于你的依赖注入设计是否合理。这里有几个实践原则值得记住。
8.1 构造函数注入而非属性注入
// 不推荐:属性注入,测试时需要直接操作结构体字段
type OrderService struct {
Repo OrderRepository // 公开字段,外部可任意修改
Notifier SMSNotifier
}
// 推荐:构造函数注入,接口类型,测试替换方便
type OrderService struct {
repo OrderRepository // 私有字段
notifier SMSNotifier
}
func NewOrderService(repo OrderRepository, notifier SMSNotifier) *OrderService {
return &OrderService{repo: repo, notifier: notifier}
}构造函数注入的好处:依赖关系明确(一眼能看出这个服务依赖什么)、强制在创建时注入(不会有"忘记初始化"的问题)、接口类型允许测试替换。
8.2 控制反转(IoC)容器在 Go 里的实践
大型项目里,依赖关系复杂,手动组装对象很繁琐。可以用 Google 的 wire 或者 Uber 的 fx 做自动依赖注入:
// 使用 wire(代码生成方式)
// +build wireinject
func InitializeOrderService(db *sql.DB, smsKey string) (*service.OrderService, error) {
wire.Build(
repository.NewOrderRepository,
sms.NewAliyunClient,
service.NewOrderService,
)
return nil, nil
}wire 会在编译时生成 wire_gen.go,包含所有依赖的实例化代码。测试时你不用 wire,直接手动注入 mock 即可。
8.3 接口定义的位置:消费者还是提供者?
Go 社区有一个习惯:接口定义在使用方(消费者)一侧,而不是实现方一侧。
// 反模式:在 repository 包里定义接口
// repository/order.go
package repository
type OrderRepository interface { // 实现方定义
Save(order *model.Order) error
}
type MySQLOrderRepository struct{}
func (r *MySQLOrderRepository) Save(order *model.Order) error { ... }
// 推荐:在 service 包(消费者)里定义接口
// service/order_service.go
package service
// 这个接口由 service 包定义,描述"我需要什么能力"
type OrderRepository interface {
Save(order *model.Order) error
FindByID(id string) (*model.Order, error)
}
type OrderService struct {
repo OrderRepository
}消费者定义接口的好处:接口是针对消费方需求裁剪的(最小接口原则),不会把实现方所有方法都暴露出来;避免了循环依赖问题;更符合 Go 的隐式接口哲学。
9. Mock 测试的边界:什么时候不应该 Mock
Mock 虽然强大,但并非所有依赖都应该 Mock。
应该 Mock 的依赖:
- 数据库(速度慢,状态不可控)
- 外部 HTTP API(不稳定,有副作用)
- 消息队列(异步,难以控制)
- 文件系统(环境差异)
- 时间(
time.Now()需要确定性)
不应该 Mock 的依赖:
- 同包内的辅助函数(直接调用)
- 标准库(已经过充分测试)
- 纯计算逻辑(没有副作用)
- 简单的 DTO/VO 对象
有一个判断标准:如果 Mock 掉某个依赖,你的测试还能发现业务逻辑里的 Bug 吗?如果能,Mock 是合理的;如果 Mock 让你的测试变成了"验证我调用了 Mock 方法"而不是"验证业务逻辑正确",那你可能 Mock 过度了。
10. Mock 测试的工程实践:从技巧到文化
掌握了 gomock 和 mockery 的用法,不等于掌握了 Mock 测试的精髓。真正的挑战不是"怎么生成 Mock",而是"什么时候该 Mock,什么时候不该 Mock,Mock 的粒度应该多细"。
过度 Mock 的危害
过度 Mock 是 Mock 测试最常见的问题。表现是:代码里到处是 Mock,每个测试都要设置一大堆期望,测试本身比被测代码还复杂。结果是:测试通过了,但没有真正测试任何东西——只是验证了"代码以特定方式调用了特定接口",而不是"代码的业务逻辑是否正确"。
衡量 Mock 是否合适,有一个简单的问题:如果我把被测函数的实现换一种方式,但业务行为完全相同,测试是否还能通过?如果不能通过(因为 Mock 的调用顺序或调用方式变了),说明测试耦合了实现细节,而不是行为。
Mock 的正确使用场景
Mock 应该用在真正有外部依赖的地方:数据库、HTTP 接口、消息队列、文件系统、时钟。这些外部系统的特点是:慢(网络延迟)、有状态(全局共享)、不确定(网络抖动)、有副作用(发邮件、扣费)。Mock 这些依赖,让单元测试能快速、可靠地运行。
纯内存计算不需要 Mock,简单的业务逻辑验证不需要 Mock,同一包内的辅助函数不需要 Mock。滥用 Mock 会让测试变成"验证实现细节的代码",失去作为质量保障的价值。
interface 设计决定 Mock 成本
一个好的经验法则:让 interface 由消费者定义,而不是由实现者定义。UserService 的数据库层,不应该因为"我用了数据库"就定义一个包含几十个方法的 UserRepository interface,而应该根据 UserService 实际需要的操作来定义——可能只需要 FindByID、Save、Delete 三个方法。小而精确的 interface 更容易 Mock,也更容易被不同实现替换。
在 Go 里,"隐式 interface 实现"是一个强大的设计工具。你不需要事先声明"这个结构体实现了这个 interface",只要方法签名匹配就行。这意味着你可以在任何时候为现有代码添加 interface 层,而不需要修改现有代码——这是 Go 相比 Java、Python 在可测试性设计上的重要优势。
Mock 的维护成本与工具选择
gomock 和 mockery 各有优劣。gomock 是官方支持的工具,生成的代码类型安全、表达力强,但语法相对繁琐,每次接口变更后需要重新生成 Mock。mockery 生成的代码更简洁,和 testify/mock 集成更自然,但第三方维护的工具长期更新有不确定性。
在项目初期,手写简单 Mock 是最直接的选择——不需要引入工具依赖,逻辑简单清晰。当项目规模扩大,需要 Mock 的接口变多时,再引入代码生成工具。不要过早引入工具,工具会增加维护成本,特别是在接口频繁变更的初期阶段。
11. 接口设计与可测试性的深层关联
写了很多 Mock 测试之后,你会发现一个规律:容易 Mock 的代码,往往也是设计得好的代码。这不是巧合,而是因为"可测试性"和"好的软件设计"有共同的底层原则——低耦合、单一职责、依赖抽象而不是依赖具体实现。
当你发现某段代码很难 Mock 时,这通常是一个设计信号:这段代码可能违反了单一职责(做了太多事)、直接依赖了具体实现(用 new 创建依赖而不是依赖注入)、没有清晰的边界(输入输出不明确)。这时候,不是"想办法 Mock 掉这段代码",而是"重新设计这段代码"。
测试驱动的思维方式,正是利用了这个反向关系:先想"怎么测试",再想"怎么实现",让测试的难易程度成为设计质量的即时反馈。这就是为什么 TDD(测试驱动开发)的支持者说"TDD 改进的不只是测试,而是代码设计"——因为在实践过程中,设计和测试是互相塑造的。
你不需要严格遵循 TDD,但养成"在写实现之前想清楚怎么测"的习惯,会让你的代码设计自然地更好,Mock 测试也会更容易写。
12. Mock 测试的长期价值:可维护的测试套件
当一个代码库有大量使用 gomock 的测试时,接口变更会触发级联的 Mock 更新——这是技术债务积累的信号。保持 Mock 的维护成本在可接受范围内,需要几个工程纪律:
接口要稳定,不要频繁修改接口签名。每次接口变更都需要更新所有 Mock,这是维护成本的主要来源。如果接口经常变,说明设计还不够成熟,应该先稳定设计再引入 Mock。
Mock 生成脚本要纳入版本控制。不要在每个开发者的机器上手动跑 mockgen,而是把生成命令写进 Makefile 或 go generate 注释,确保每个人用同样的命令生成同样的 Mock。
定期审计 Mock 的数量。如果 Mock 文件的数量超过了被测文件的数量,可能是过度 Mock 的信号。重新审视哪些 Mock 是真正必要的,哪些是可以用更轻量的方式(比如测试替身、简单的 struct 实现)替代的。
Mock 测试的最终价值,不是测试覆盖率数字,而是在接口不变的情况下,能够安全地重构实现逻辑。好的 Mock 测试是重构的安全网,而不是重构的负担。
学会了 Mock,下一步是学会不滥用 Mock。这个进阶的关键,是对"什么是单元"有清晰的认识:单元不是最小的代码片段,而是一个具有明确职责的组件。组件内部的协作不需要 Mock,组件之间的依赖才需要 Mock。这个判断能力,比任何 Mock 工具的语法都重要。
写在最后
好的 Mock 测试,根源在于好的代码设计。interface 是 Go 可测试性的基石,没有 interface 的依赖注入,Mock 框架再强大也无从发力。
从我带过的几十个项目来看,单测写得差的团队,90% 的根本原因不是"不会用 gomock",而是代码里充满了直接 new 的具体类型——数据库对象直接 new,HTTP 客户端直接 new,一旦涉及这些依赖,测试就只能跑真实的外部服务,速度慢、不稳定、难以控制。
改代码设计,比学 Mock 框架更重要。让代码依赖 interface,让依赖从外部注入,测试自然就好写了。
写好 Mock 测试之后,下一步自然是集成测试——你终究要验证那些真实的数据库操作、真实的 HTTP 调用是否正确工作。我们下一篇聊 Testcontainers-go,用真实容器做集成测试。
