Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

44.3 mock:专用于行为观察和验证的替身

和fake、stub替身相比,mock替身更为强大:它除了能提供测试前的预设置返回结果能力之外,还可以对mock替身对象在测试过程中的行为进行观察和验证。不过相比于前两种替身形式,mock存在应用局限(尤指在Go中)。

  • 和前两种替身相比,mock的应用范围要窄很多,只用于实现某接口的实现类型的替身。
  • 一般需要通过第三方框架实现mock替身。Go官方维护了一个mock框架——gomock(https://github.com/golang/mock),该框架通过代码生成的方式生成实现某接口的替身类型。

mock这个概念相对难于理解,我们通过例子来直观感受一下:将上面例子中的fake替身换为mock替身。首先安装Go官方维护的go mock框架。这个框架分两部分:一部分是用于生成mock替身的mockgen二进制程序,另一部分则是生成的代码所要使用的gomock包。先来安装一下mockgen:

$go get github.com/golang/mock/mockgen

通过上述命令,可将mockgen安装到$GOPATH/bin目录下(确保该目录已配置在PATH环境变量中)。

接下来,改造一下mocktest/mailer/mailer.go源码。在源码文件开始处加入go generate命令指示符:

// chapter8/sources/mocktest/mailer/mailer.go
//go:generate mockgen -source=./mailer.go -destination=./mock_mailer.go -package=mailer Mailer

package mailer

type Mailer interface {
    SendMail(subject, sender, destination, body string) error
}

接下来,在mocktest目录下,执行go generate命令以生成mailer.Mailer接口实现的替身。执行完go generate命令后,我们会在mocktest/mailer目录下看到一个新文件——mock_mailer.go:

// chapter8/sources/mocktest/mailer/mock_mailer.go

// Code generated by MockGen. DO NOT EDIT.
// Source: ./mailer.go

// mailer包是一个自动生成的 GoMock包
package mailer

import (
    gomock "github.com/golang/mock/gomock"
    reflect "reflect"
)

// MockMailer是Mailer接口的一个模拟实现
type MockMailer struct {
    ctrl     *gomock.Controller
    recorder *MockMailerMockRecorder
}

// MockMailerMockRecorder 是 MockMailer的模拟recorder
type MockMailerMockRecorder struct {
    mock *MockMailer
}

// NewMockMailer创建一个新的模拟实例
func NewMockMailer(ctrl *gomock.Controller) *MockMailer {
    mock := &MockMailer{ctrl: ctrl}
    mock.recorder = &MockMailerMockRecorder{mock}
    return mock
}

// EXPECT返回一个对象,允许调用者指示预期的使用情况
func (m *MockMailer) EXPECT() *MockMailerMockRecorder {
    return m.recorder
}

// SendMail模拟基本方法
func (m *MockMailer) SendMail(subject, sender, destination, body string) error {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "SendMail", subject, sender, destination, body)
    ret0, _ := ret[0].(error)
    return ret0
}

// SendMail表示预期的对SendMail的调用
func (mr *MockMailerMockRecorder) SendMail(subject, sender, destination, body interface{}) *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMail", reflect.TypeOf((*MockMailer)(nil).SendMail), subject, sender, destination, body)
}

有了替身之后,我们就以将其用于对ComposeAndSend方法的测试了。下面是使用了mock替身的mailclient_test.go:

// chapter8/sources/mocktest/mocktest/mailclient_test.go
package mailclient

import (
    "errors"
    "testing"

    "github.com/bigwhite/mailclient/mailer"
    "github.com/golang/mock/gomock"
)

var senderSigns = map[string]string{
    "tonybai@example.com":  "I'm a go programmer",
    "jimxu@example.com":    "I'm a java programmer",
    "stevenli@example.com": "I'm a object-c programmer",
}

func TestComposeAndSendOk(t *testing.T) {
    old := getSign
    sender := "tonybai@example.com"
    timestamp := "Mon, 04 May 2020 11:46:12 CST"

    getSign = func(sender string) string {
        selfSignTxt := senderSigns[sender]
        return selfSignTxt + "\n" + timestamp
    }
    defer func() {
        getSign = old //测试完毕后,恢复原值
    }()

    mockCtrl := gomock.NewController(t)
    defer mockCtrl.Finish() //Go 1.14及之后版本中无须调用该Finish

    mockMailer := mailer.NewMockMailer(mockCtrl)
    mockMailer.EXPECT().SendMail("hello, mock test", sender,
     "dest1@example.com",
     "the test body\n"+senderSigns[sender]+"\n"+timestamp).Return(nil).Times(1)
    mockMailer.EXPECT().SendMail("hello, mock test", sender,
     "dest2@example.com",
     "the test body\n"+senderSigns[sender]+"\n"+timestamp).Return(nil).Times(1)

    mc := New(mockMailer)
    _, err := mc.ComposeAndSend("hello, mock test",
      sender, []string{"dest1@example.com", "dest2@example.com"}, "the test body")
    if err != nil {
        t.Errorf("want nil, got %v", err)
    }
}
...

上面这段代码的重点在于下面这几行:

mockMailer.EXPECT().SendMail("hello, mock test", sender,
    "dest1@example.com",
    "the test body\n"+senderSigns[sender]+"\n"+timestamp).Return(nil).Times(1)

这就是前面提到的mock替身具备的能力:在测试前对预期返回结果进行设置(这里设置SendMail返回nil),对替身在测试过程中的行为进行验证。Times(1)意味着以该参数列表调用的SendMail方法在测试过程中仅被调用一次,多一次调用或没有调用均会导致测试失败。这种对替身观察和验证的能力是mock区别于stub的重要特征。

gomock是一个通用的mock框架,社区还有一些专用的mock框架可用于快速创建mock替身,比如:go-sqlmock(https://github.com/DATA-DOG/go-sqlmock)专门用于创建sql/driver包中的Driver接口实现的mock替身,可以帮助Gopher简单、快速地建立起对数据库操作相关方法的单元测试。

小结

本条介绍了当被测代码对外部组件或服务有强依赖时可以采用的测试方案,这些方案采用了相同的思路:为这些被依赖的外部组件或服务建立替身。这里介绍了三类替身以及它们的适用场合与注意事项。

本条要点如下。

  • fake、stub、mock等替身概念之间并非泾渭分明的,对这些概念的理解容易混淆。比如标准库net/http/transfer_test.go文件中的mockTransferWriter类型,虽然其名字中带有mock,但实质上它更像是一个fake替身。
  • 我们更多在包内测试应用上述替身概念辅助测试,这就意味着此类测试与被测代码是实现级别耦合的,这样的测试健壮性较差,一旦被测代码内部逻辑有变化,测试极容易失败。
  • 通过fake、stub、mock等概念实现的替身参与的测试毕竟是在一个虚拟的“沙箱”环境中,不能代替与真实依赖连接的测试,因此,在集成测试或系统测试等使用真实外部组件或服务的测试阶段,务必包含与真实依赖的联测用例。
  • fake替身主要用于被测代码依赖组件或服务的简化实现。
  • stub替身具有有限范围的、在测试前预置返回结果的控制能力。
  • mock替身则专用于对替身的行为进行观察和验证的测试,一般用作Go接口类型的实现的替身。