如何在测试中更好地使用mock

2,191 阅读9分钟

注意:本文大部分内容为翻译 Bob 大叔的文章,原文链接可以在文章底部的参考文档处找到。

什么是 mock

mock 作为名词时表示 mock 对象,在维基百科的解释中如下:

在面向对象程序设计中,模拟对象(英语:mock object,也译作模仿对象)是以可控的方式模拟真实对象行为的假的对象。程序员通常创造模拟对象来测试其他对象的行为。

mock 作为动词时表示编写使用 mock 对象。

mock 多用于测试代码中,对于不容易构造或者不容易获取的对象,使用一个虚拟的对象来方便测试。

mock 的分类

为了使用示例说明各个mock 种类的区别与联系,文章使用 go 语言作为示例,如下为示例的基础代码:

type Authorizer interface {
    authorize(username, password string) bool
}

type System struct {
    authorizer Authorizer
}

func NewSystem(authorizer Authorizer) *System {
    system = new(System)
    system.authorizer = authorizer
    return system
}

func (s *System) loginCount() int {
    // skip
    return 0
}

func (s *System) login(username, password string) error {
    if s.authorizer.authorize(username, password) {
        return nil
    }
    return errors.New("username or password is not right")
}

dummy

当你不关心传入的参数被如何使用时,你就应该使用 dummy 类型的 mock,一般用于作为其他对象的初始化参数。示例如下:

type DummyAuthorizer struct {}
func (d *DummyAuthorizer) authorize(username, password string) bool {
    // return nil
    return false
}

// Test
func TestSystem(t *testing.T) {
    system := NewSystem(new(DummyAuthorizer))
    got := system.loginCount()
    want := 0
    if got != want {
        t.Errorf("got %d, want %d", got, want)
    }
}

在上面的测试示例代码中,DummyAuthorizer 的作为只是为了初始化 System 对象的需要,后续测试中并没有使用该 DummyAuthorizer 对象。

注意:此处的 authorize 方法原文返回了 null ,由于 go 语言不允许为 bool 返回 nil ,因此此处返回了 false

stub

当你只关心方法的返回结果,并且需要特定返回值的时候,这时候你就可以使用 stub 类型的 mock 。比如我们需要测试系统中某些功能是否能正确处理用户登录和不登录的情况,而登录功能我们已经在其他地方经过测试,而且使用真实的登录功能调用又比较的麻烦,我们就可以直接返回已登录或者未登录状态来进行其他功能的验证。

type AcceptingAuthorizerStub struct {}

func (aas *AcceptingAuthorizerStub) authorize(username, password string) bool {
    return true
}

type RefusingAuthorizerStub struct {}

func (ras *RefusingAuthorizerStub) authorize(username, password string) bool {
    return false
}

spy

当你不只是只关心方法的返回结果,还需要检查方法是否真正的被调用了,方法的调用次数等,或者需要记录方法调用过程中的信息。这个时候你就应该使用 spy 类型的 mock ,调用结束后你需要自己检查方法是否被调用,检查调用过程中记录的其他信息。但是请注意,这将会使你的测试代码和被测试方法相耦合,测试需要知道被测试方法的内部实现细节。使用时需要谨慎一些,不要过渡使用,过渡使用可能导致测试过于脆弱。

type AcceptingAuthorizerSpy struct {
    authorizeWasCalled bool
}

func (aas *AcceptingAuthorizerSpy) authorize(username, password string) bool {
    aas.authorizeWasCalled = true
    return true
}

// Test
func TestSystem(t *testing.T) {
    authorizer := new(AcceptingAuthorizerSpy)
    system := NewSystem(authorizer)
    got := system.login("will", "will")
    if got != nil {
        t.Errorf("login failed with error %v", got)
    }
    
    if authorizer.authorizeWasCalled != true {
        t.Errorf("authorize was not called")
    }
}

mock

mock 类型的 mock 可以算作是真正的 ”mock“ 。把 spy 类型的 mock 在测试代码中的断言语句移动到 mock 对象中,这使它更关注于测试行为。这种类型的 mock 对方法的返回值并不是那么的感兴趣,它更关心的是哪个方法被使用了什么参数在什么时间被调用了,调用的频率等。这种类型的 mock 使得编写 mock 相关的工具更加的简单,mock 工具可以帮助你在运行时创建 mock 对象。

type AcceptingAuthorizerVerificationMock struct {
    authorizeWasCalled bool
}

func (aavm *AcceptingAuthorizerVerificationMock) authorize(username, password string) bool {
    aavm.authorizeWasCalled = true
    return true
}

func (aavm *AcceptingAuthorizerVerificationMock) verify() bool {
    return aavm.authorizeWasCalled
}

fake

fake 类型的 mock 与其他类型的 mock 最大的区别是它包含了真实的业务逻辑。当以不同的数据调用时,你会得到不同的结果。随着业务逻辑的改变,它可能也会越来越复杂,最终你也需要为这种类型的 mock 编写单元测试,甚至最后它可能成为了一个真实的业务系统。如果不是必须,请不要使用 fake 类型的 mock 。

type AcceptingAuthorizerFake struct {}

func (aas *AcceptingAuthorizerFake) authorize(username, password string) bool {
    if username == "will" {
    	return true   
    }
    return false
}

总结

mock 是 spy 的一种类型,spy 又是 stub 的一种类型,而 stub 又是 dummy 的一种类型,但是 fake 与其他所有 mock 类型不同,fake 包含了真实的业务逻辑,而其他类型的 mock 都不包含真实的业务逻辑。

根据 Bob 大叔的实践来看,他使用最多的是 spy 和 stub 类型的 mock ,并且他不会经常使用 mock 工具,很少使用 dummy 类型的 mock ,只有在使用 mock 工具时才会使用 mock 类型的 mock 。现在的编程 IDE 中,只需要你定义好接口,IDE 就可以帮你轻松的实现他们,你只需要简单的修改就可以实现 spy 和 stub 类型的 mock ,因此 Bob 大叔很少使用 mock 工具。

mock 的使用时机

mock 对象是一个强大的工具,但是 mock 对象也有两面性,如果使用不正确也可能会带来强大的破坏力。

完全不使用 mock

如果我们完全不使用 mock ,直接使用真实的对象进行测试,这会带来什么问题呢?

  • 测试将会运行缓慢。我们使用真实的数据库,真实的上游服务,由于这些都需要通过网络来进行通信,这会将比程序内部的函数调用慢上几个数量级。当我们修改一行简单的代码,进行测试时,可能需要等待数分钟,数小时,甚至可能要几天才能把测试运行结束。
  • 代码的测试覆盖率可能会降低很多。一些错误和异常在没有使用 mock 的情况下可能根本无法进行测试,例如网络协议的异常。一些危险的测试用例,比如删除文件、删除数据库表很难进行安全的测试。
  • 测试变得异常的脆弱。与测试无关的其他问题可能会导致测试失败,例如由于机器负载导致的网络时延问题,数据库表的结构不正确,配置文件被错误修改等问题。

在完全不使用 mock 对象的情况下,我们的测试会变得缓慢、不完整、脆弱。

过度使用 mock

如果过度使用 mock 对象,所有的测试都使用 mock 对象,这会带来什么问题呢?

  • 测试将会运行缓慢。一些 mock 工具强依赖反射机制,因此会使得测试变慢。
  • mock 所有类之间的交互,会导致你必须创建返回其他 mock 类的 mock 类,你可能需要 mock 整个交互链路上所有的类,这将会导致你的测试异常的复杂,并且所有交互链路上的 mock 类可能都耦合在了一起,当其中一个修改时,可能会导致整个测试失败。
  • 暴露本不需要暴露的接口。由于需要 mock 每一个类之间的交互,就需要为每一个类之间的交互创建接口,这将会导致你需要创建出许多只用于 mock 对象的接口,这是一种过度抽象和可怕的设计损坏。

过度使用 mock 对象,将会使用测试变得缓慢、脆弱、复杂,并且有可能损坏你的软件设计。

mock 的使用建议

在架构的重要边界使用 mock ,不要在边界内部使用 mock

例如可以在数据库、web服务器等所有第三方服务的边界处使用 mock 。可以参考如下的整洁架构图:

可以在最外环的边界处使用 mock 隔离外部依赖,方便测试,这样做可以得到如下的好处:

  • 测试运行速度快。
  • 测试不会因为外部依赖的错误而失败。
  • 更容易的模拟测试外部依赖的所有异常情况。
  • 横跨边界的有限状态机的每条路径都可以被测试。
  • mock 不在需要相互耦合依赖,代码会更整洁。

另一个比较大的好处是它强迫你思考找出软件的重要边界,并且为它们定义接口,这使得你的软件不会强耦合依赖于边界外的组件。因此你可以独立开发部署边界两边的组件。像这样去分离架构关注点是一个很好的软件设计原则。

使用你自己的 mock

mock 工具有它们自己的领域语言,在使用它们之前你必须先学习它。通过前面的 mock 类型介绍,我们已经知道用的最多的 mock 是 stub 和 spy 类型,而由于现在的 IDE 可以很方便的生成这些 mock 代码,我们只需要稍作修改就可以直接使用,所以综合来看,我们一般情况下是不需要使用 mock 工具的。

由于你自己写 mock 时不会使用反射,这将会让你的测试代码运行速度更快。如果你决定使用 mock 工具,请尽量少的使用它。

总结

mock 对象既不能完全不使用,也不能过度使用。我们应该在软件的重要边界处使用 mock ,要尽量少的使用 mock 工具,使用 mock 工具时不要过度依赖它,我们应该尽量使用轻量级的 stub 和 spy 的 mock 类型,并且我们应该自己手写这些简单的 mock 类型。如果你这样做了,你会发现你的测试运行速度更快,更稳定,并且还会有更高的测试覆盖率,你的软件架构设计也会越来越好。

参考文档