Day 27 RSpec 的 Mock & Stub

该文章同步发布於:我的部落格

在我一开始学习写 Rails 测试时,会有很常见的问题,就是到底什麽是 mocks & stubs,然後我要怎麽使用它们?

如果你对 mocks & stubs 也觉得很困惑,其实真的很正常,所以我自己也看了蛮多不同文章对於对於 mocks & stubs 的见解,希望今天可以厘清这个问题,特别是在 Rails/RSpec 的背景下。

关於测试术语的问题

昨天的文章也有提到,测试术语可能有一百万种,但对於含义却没有一个完全的共识。像是昨天提到的 end-to-end 以及 验收测试是同一回事吗?有些人是,有些人不是。而且也没有一个中央权威机构说什麽是对的,什麽是错的,这就是测试术语上会遇到的问题。

尤其在 mock & stub 上,有些人说 mock 就是 stub,反之。

这样的结果就是,关於 mock & stub 的解释都变得非常的混乱!

Test Double

我在网路上看过 Gerard Meszaros 的 xUnit Test Patterns: Refactoring Test Code 这本书时提过,mocks & stubs 都是 double 的特殊类型时,我好像懂了些什麽。

这种理解对我来说就比较不容易被动摇,因为理解过 double 能够让我做模式比对,所以我知道这不太可能再以後被我看到的新知识给推翻,感觉就像是经验值真正的提升了一点!

所以什麽是 double ? 在之前我只提过她它在 RSpec 的用法,在书中,作者把 double 比喻成好莱坞的特技替身。

当电影想要拍摄一些对主角来说有风险的事情时,他们会雇用一个 特技替身 来代替演员在场景中的动作。特技替身是一个受过严格训练的人,能够满足画面的需求,他们可能不会演戏,但他们知道怎麽从高处坠落、撞车等等。特技替身通常要在身材和某些角度和演员很相似!

等等的文章会用第三方支付当作例子。当我们在测试第三方支付这件事情时,不会真的希望它每测试一次就收一次钱吧?我们希望用替身来代替真正的支付流程。

Stub 的解释

我在网路上有看到 这篇文章 是一个叫做 Michal Lipski 的人写的。

根据他的文章,我也有了自己的解释方式。

Test stub 是一个假的对象,用来代替一个真正的对象,目的是让程序码可以按照我们需要的方式来进行测试。Stub 有很大一部分工作是在我们原本的程序码被呼叫时直接调用。

你也可以看看 Gerard Meszaros 的 xUnit Test Patterns: Refactoring Test Code 关於 Test stub 的讲法,可以获得更详细和精准的解释,我可能没有办法讲得很好,因为我的英文没有好到可以翻译的很准确。

我没办法告诉你怎麽样可以去看到这本书,但如果是我可能会先上网 google 之後在买书。

至於什麽时候要用 Stub,等等就会看到了!

Mock 的解释

Mock 也是一个假的对象,用来代替一个真实的对象,目的是为了监听这个 Mock 上面被呼叫的方法。Mock 对象的主要工作是确保正确的方法被调用到他身上。

这和 Stub 明显有一点点不一样,但如果想更理解的话,去看书吧!

等等也会使用到 Mock!

Mock & Stub 的不同

根据我的理解,用一个广泛的说法,Stub 帮忙输入, Mock 帮忙输出。

Stub 是你做一个假的东西,直接插在你的程序码里面,骗他这是真的。

Mock 是你做一个假的东西,放在旁边,在你程序码不能直接测试的地方监视并且替代。

希望这样简单的说法能够让人比较快理解,但我相信这不是准确的答案,还是看看例子比较实际!

实际的应用程序例子

下面是一个 Ruby 的例子,让这个例子能够满足 Mock & Stub。

我知道这例子可能漏洞百出,但我是为了示范 Mock & Stub 而写的~

以下会有三个类别:

  1. Payment: 模拟 ActiveRecord
  2. ECpayKey: 模拟第三方支付
  3. Logger: 纪录支付情况
class Payment
  attr_accessor :total_amount

  def initialize(ecpay_key, logger)
    @ecpay_key = ecpay_key
    @logger = logger
  end

  def save
    response = @ecpay_key.charge(total_amount)
    @logger.record_payment(response[:payment_id])
  end
end

class ECpayKey
  def charge(total_amount)
    p "这会触发真正的付款"

    { payment_id: rand(1000) }
  end
end

class Logger
  def record_payment(payment_id)
    p "Payment id: #{payment_id}"
  end
end

RSpec.describe Payment do
  it 'records the payment' do
    ecpay_key = ECpayKey.new
    logger = Logger.new

    payment = Payment.new(ecpay_key, logger)
    payment.total_amount = 1800
    payment.save
  end
end

我们的最下方的测试有两个问题,他改变了资料( 这边没有真的写出来,你可以假装 这会触发真正的付款 那段是真的向某人收了钱 )

另一个问题是,这个测试没有验证付款记录有没有被记录下来,就算我把 @logger.record_payment(response[:payment_id]) 给注解掉,测试还是会通过。

这会触发真正的付款
Payment id: 334
.

Finished in 0.00255 seconds (files took 0.09594 seconds to load)
1 example, 0 failures

首先我们先用 Stub 来取代 ECpayKey 这件事情。

Stub 的范例

下面我把 ecpay_key = ECpayKey.new 改成 ecpay_key = double()

而且我告诉他,他会期望收到一个 charge 的方法,当它收到时,回传一个 3333 的 ID

RSpec.describe Payment do
  it 'records the payment' do
    ecpay_key = double()
    allow(ecpay_key).to receive(:charge).and_return(payment_id: 3333)

    logger = Logger.new

    payment = Payment.new(payment_gateway, logger)
    payment.total_cents = 1800
    payment.save
  end
end

这时候我们在 Run 一次测试,就不会再看到 这会触发真正的付款 这样的讯息,这是因为我们的测试没有去 call ECpayKey 的 charge 方法,而是 call 了 Stub 物件身上的方法。

这时候我们的 ecpay_key 也不再是 ECpayKey 的实体,而是 RSpec::Mocks::Double 的一个实体了!

Payment id: 3333
.

Finished in 0.00877 seconds (files took 0.09877 seconds to load)
1 example, 0 failures

Mock 的范例

现在让我补上 纪录支付资讯 的测试,我们把 logger = Logger.new 换成 logger = double()

RSpec 本身没有区分 mocks 以及 stubs 的分别。他们都是 Test Doubles,所以我们要怎麽区分,RSpec 让我们自己决定~

我们还告诉了 Mock 物件,他必须接受一个值为 3333 的 record_payment 的方法!

RSpec.describe Payment do
  it 'records the payment' do
    ecpay_key = double()
    allow(ecpay_key).to receive(:charge).and_return(payment_id: 3333)

    logger = double()
    expect(logger).to receive(:record_payment).with(3333)

    payment = Payment.new(payment_gateway, logger)
    payment.total_cents = 1800
    payment.save
  end
end

为了验证 Mock 物件有没有在监视这段程序码,我们把 @logger.record_payment(response[:payment_id]) 给注解掉,就会看到以下的错误讯息!

Failures:

  1) Payment records the payment
     Failure/Error: expect(logger).to receive(:record_payment).with(3333)
     
       (Double (anonymous)).record_payment(3333)
           expected: 1 time with arguments: (3333)
           received: 0 times

事实证明没有错,他确实有去监视是不是真的有执行这行程序码,和 Stub 直接插入不一样的做法!

小结

但讲了这麽多,我其实也真的没有在测试中写过几次的 Mock & Stub。

主要原因是因为也不是常常遇到只需要 Mock & Stub 就能解决的问题。在 Rails 中我们并没有做到真正的 Unit Test,因为我们没有真正的把一个物件和其他物件完全隔离开来进行测试,至少我现在还没遇过,取而代之的是,我们写的 Model spec 会去触发资料库,并且能够访问到应用程序里面的每一个物件,这样做是好是坏我不清楚,但这样的方式基本上消除了对於 Mock & Stub 的需求。

另一个原因是,我自己觉得 Mock & Stub 的测试基本上就是重述了被测试的物件。像刚刚的程序码 @logger.record_payment,测试写 expect(logger).to receive(:record_payment)

看起来真的有种我到底写了什麽的感觉,这很像是在测试期待,而不是测试结果,但可以的话,我宁愿只测试结果,而且在 Rails 里面,大部分都可以测试结果,而不是测试期待~


<<:  Day27 [实作] 一对一视讯通话(7): 使用 Docker 封装

>>:  大共享时代系列_025_迷你仓(共享仓储)

故事的例子

先说明:今天加班到现在,所以先写一点点,後续再补上。 前天的文章有一位读者提问: 如果将团队在组织中...

#22 JS: HTML DOM Fundamentals

What is the HTML DOM? "The HTML DOM is a stan...

D-15 过滤器 ? filter ? attribute

filter 眼尖的小光在昨日的内容中看到了一个有趣的东西,就是MiddlewareFilter,所...

HTML笔记(01)-前端、後端和全端

那天跟朋友聊天,我跟他说我的同学是打程序的所以薪水很高。他很不解,什麽叫做打程序的?我就说就资料探勘...

[第09天]理财达人Mx. Ada-订阅盘中交易

前言 本文说明如何进行订阅盘中交易作业。 程序实作 选择订阅标的 # 取得长荣股票报价,长荣代号:2...