4、Stub模式入门:用简单结构体实现Stub,硬编码返回值替换真实依赖

在测试金字塔的底层,单元测试要求我们隔离被测试单元。当被测试函数依赖外部服务、数据库或复杂计算模块时,Stub(桩)是最轻量的替身技术。本章将带你从零实现一个Go结构体Stub,理解其核心思想:用硬编码返回值替换真实依赖。

核心认知:Stub不验证行为(不检查“是否被调用”),只提供预设数据。它是“哑对象”的升级版——有返回值,但无真实逻辑。

4.1 为什么需要Stub?——真实依赖的测试困境

假设我们有一个OrderService,它依赖InventoryClient查询库存。真实客户端会发起HTTP请求,导致测试变慢、不稳定且依赖外部环境。

问题维度 真实依赖 Stub替身
执行速度 慢(网络I/O) 极快(内存返回)
结果确定性 不确定(库存可能变化) 完全确定(硬编码)
环境依赖 需要外部服务运行 无依赖
测试隔离性 差(共享数据库/服务) 完美(独立运行)

4.2 Stub模式的结构:接口 + 结构体 + 硬编码

Go语言中实现Stub的标准三步法:

  1. 定义接口:抽取真实依赖的行为契约。
  2. 创建Stub结构体:实现该接口,方法内部直接返回预设值。
  3. 注入Stub:在测试中替换真实依赖。
关键原则:Stub结构体不包含任何业务逻辑、条件分支或外部调用。它的所有方法体都是return 固定值。
Stub模式结构:接口 + 结构体 + 硬编码 接口(契约) InventoryChecker CheckStock(sku string) bool Stub结构体 InventoryStub CheckStock() → true 真实实现 RealInventoryClient CheckStock() → HTTP调用 测试时注入Stub,生产环境注入真实实现 Stub不验证调用次数、不记录调用历史——只返回预设数据 这是Stub与Mock的核心区别(Mock会验证行为)

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的基础。
Stub vs Mock:关注点对比 Stub(桩) 关注:返回值 “给我数据,我不关心怎么来的” 实现:硬编码 return 验证:断言结果状态 典型:InventoryStub.CheckStock() → true Mock(模拟对象) 关注:交互行为 “我验证你调用了,且参数正确” 实现:记录调用 + 期望匹配 验证:断言调用次数/参数 典型:mock.Expect("SendEmail").With(...) vs

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 + 行为验证