4、Stub模式入门:用简单结构体实现Stub,硬编码返回值替换真实依赖
在测试金字塔的底层,单元测试要求我们隔离被测试单元。当被测试函数依赖外部服务、数据库或复杂计算模块时,Stub(桩)是最轻量的替身技术。本章将带你从零实现一个Go结构体Stub,理解其核心思想:用硬编码返回值替换真实依赖。
核心认知:Stub不验证行为(不检查“是否被调用”),只提供预设数据。它是“哑对象”的升级版——有返回值,但无真实逻辑。
4.1 为什么需要Stub?——真实依赖的测试困境
假设我们有一个OrderService,它依赖InventoryClient查询库存。真实客户端会发起HTTP请求,导致测试变慢、不稳定且依赖外部环境。
| 问题维度 | 真实依赖 | Stub替身 |
|---|---|---|
| 执行速度 | 慢(网络I/O) | 极快(内存返回) |
| 结果确定性 | 不确定(库存可能变化) | 完全确定(硬编码) |
| 环境依赖 | 需要外部服务运行 | 无依赖 |
| 测试隔离性 | 差(共享数据库/服务) | 完美(独立运行) |
4.2 Stub模式的结构:接口 + 结构体 + 硬编码
Go语言中实现Stub的标准三步法:
- 定义接口:抽取真实依赖的行为契约。
- 创建Stub结构体:实现该接口,方法内部直接返回预设值。
- 注入Stub:在测试中替换真实依赖。
关键原则:Stub结构体不包含任何业务逻辑、条件分支或外部调用。它的所有方法体都是
return 固定值。
4.3 实战:为订单服务编写InventoryStub
我们通过一个完整示例,展示从接口定义到测试验证的全过程。
步骤1:定义接口(真实项目中的契约)
// inventory.go (真实项目)
type InventoryChecker interface {
CheckStock(sku string) (bool, error)
}
步骤2:编写Stub结构体(测试文件内)
// inventory_stub.go (测试文件)
type InventoryStub struct {
// 硬编码返回值
StockAvailable bool
CheckErr error
}
func (s *InventoryStub) CheckStock(sku string) (bool, error) {
// 忽略输入参数,直接返回预设值
return s.StockAvailable, s.CheckErr
}
注意:Stub方法签名必须与接口完全一致,但方法体忽略所有输入参数。这是Stub的典型特征——不关心输入,只返回预设输出。
步骤3:在测试中注入Stub
// order_service_test.go
func TestCreateOrder_StockAvailable(t *testing.T) {
// 准备Stub
stub := &InventoryStub{
StockAvailable: true,
CheckErr: nil,
}
// 注入Stub到被测试对象
svc := NewOrderService(stub)
// 执行测试
err := svc.CreateOrder("SKU-001", 2)
if err != nil {
t.Errorf("期望无错误,得到: %v", err)
}
}
func TestCreateOrder_StockUnavailable(t *testing.T) {
stub := &InventoryStub{
StockAvailable: false,
CheckErr: nil,
}
svc := NewOrderService(stub)
err := svc.CreateOrder("SKU-001", 2)
if err == nil {
t.Error("期望库存不足错误,但没有得到")
}
}
4.4 Stub的变体:多场景预设
一个Stub结构体可以通过字段组合,覆盖多个测试场景:
| 场景 | StockAvailable | CheckErr | 测试目标 |
|---|---|---|---|
| 库存充足 | true |
nil |
验证订单创建成功 |
| 库存不足 | false |
nil |
验证返回库存不足错误 |
| 查询失败 | false |
errors.New("timeout") |
验证错误传播逻辑 |
4.5 Stub vs Mock:一张表看清区别
| 维度 | Stub(桩) | Mock(模拟对象) |
|---|---|---|
| 核心目的 | 提供预设数据 | 验证交互行为 |
| 是否验证调用次数 | 否 | 是(如AssertCalled) |
| 是否验证调用参数 | 否(通常忽略输入) | 是(匹配期望参数) |
| 实现复杂度 | 低(几行硬编码) | 中高(需要记录调用历史) |
| 适用场景 | 查询类方法、状态验证 | 命令类方法、行为验证 |
经验法则:当测试只关心“返回值是什么”时,用Stub;当测试关心“方法是否被调用、调用了几次、传了什么参数”时,用Mock。本章的Stub是后几章Mock的基础。
4.6 使用Stub的常见陷阱
- 陷阱1:Stub中引入条件逻辑——如果Stub根据输入参数返回不同值,它就变成了Fake(伪对象),增加了维护成本。保持Stub的“无脑”特性。
- 陷阱2:Stub覆盖所有路径——Stub只覆盖测试关心的路径。不要试图用Stub模拟所有异常情况,那会导致Stub本身需要测试。
- 陷阱3:在Stub中记录调用——一旦在Stub中添加
callCount或lastArgs,它就变成了Spy(间谍),不再是纯粹的Stub。保持职责单一。
最佳实践:将Stub定义在测试文件内(
_test.go),不要放在生产代码包中。使用go:build !production构建标签可以进一步隔离。
4.7 小结:Stub模式的核心要点
| 要点 | 说明 |
|---|---|
| 定义 | 实现接口的结构体,方法体硬编码返回预设值 |
| 目的 | 替换真实依赖,提供确定性的测试数据 |
| 实现方式 | 结构体字段控制返回值,方法忽略输入参数 |
| 验证方式 | 断言被测试对象的输出状态,不验证Stub的调用行为 |
| 与Mock关系 | Stub是Mock的简化版基础,Mock = Stub + 行为验证 |