一般来说,模拟是对某事物的复制或模仿。RSpec模拟,在同样的意义上,是对返回值或方法实现的一种模仿。进行这种模仿的能力使我们有可能设置特定的消息被一个对象接收的期望。很神奇,对吗?😃 RSpec mocks创建了测试替身,我们可以用它来实现我们的模仿,尽管这些模仿也可以在属于自己系统的对象上进行。
什么是测试双胞胎?
根据xUnit模式这本书,我相信它提供了一个完美的解释,测试替身可以被比喻为特技替身。
当电影业想要拍摄一些对主角来说有潜在风险或危险的事情时,他们会雇佣一个 "特技替身 "来代替演员在场景中的位置。特技替身是一个受过高度训练的人,能够满足场景的具体要求。他们可能不会演戏,但他们知道如何从高处坠落,撞车,或任何场景所要求的。特技替身需要与演员有多大的相似度,取决于场景的性质。通常情况下,可以安排一个在身材上与演员大致相似的人代替他们。
例如,如果一个人需要在某个类中调用一个外部API,那么在测试过程中,很可能要避免这个API调用。在这种情况下,可以创建一个 "特技替身 "来模拟API的响应。
创建我的第一个测试替身
让我们先在一个名为rspec_mocks 的文件夹中创建两个名为crypto.rb 和trader_spec.rb 的新文件。在这个文件夹中,我们安装所需的依赖项。
gem install rspec # for rspec-core, rspec-expectations, rspec-mocks
gem install rspec-mocks # for rspec-mocks only
在我们的trader_spec.rb 文件中,让我们创建测试,以确定某个拥有当前币值的加密货币交易者是否能够购买一个新币,并通过参加促销活动使该币的价值翻倍。当然,这将反过来导致他的总币值增加。
# trader_spec.rb
require_relative 'crypto'
describe Trader do
before(:each) do
@trader = Trader.new(50)
end
describe '#partake in promo' do
it 'should increase his coin worth' do
new_coin = Coin.new(10)
@trader.partake_in_promo(new_coin)
expect(@trader.coin_worth).to eq(70)
end
end
end
在上面的测试中,我们要测试的是,一个交易者被创建时的初始币值为50 ;他购买了一个价值为10 的硬币,并参加了一个促销活动,使硬币的价值翻了一番,达到了20 ,这反过来又导致了总的硬币价值为70 (50+20)。
在rspec_mocks文件夹中,让我们使用命令rspec trader_spec.rb ,运行我们的测试。没有交易员类和任何方法,我们的测试当然会返回一个错误。现在,让我们继续写代码,使这个测试通过。在我们的crypto.rb 文件中,让我们创建交易员类,如下所示:
class Trader
attr_accessor :coin_worth
def initialize(coin_worth)
@coin_worth = coin_worth
end
def partake_in_promo(coin)
coin_new_value = coin.win_promo
@coin_worth += coin_new_value
end
end
我们还必须创建相应的Coin类。
class Coin
attr_accessor :value
def initialize(value)
@value = value
end
def win_promo
@value = @value * 2
end
end
这时运行我们的测试,给出的结果是全绿。
然而,在测试时,我们不希望我们的测试根据被测类范围外的对象的行为而失败或通过。例如,如果win_promo 方法在Coin 类中发生变化,value * 2 变成了value + 2 ,我们的测试就会失败。这是令人沮丧的,因为失败只是由于依赖关系中的实现变化,而不是交易者类中的任何不一致。硬币价值翻倍的测试应该留在硬币规范中,而不是在交易员规范中进行,这样我们就能在范围内保持我们的测试。
new_coin = double('Coin')让我们更进一步,用new_coin = Coin.new(10) ,为我们的钱币创建一个双倍数。再次运行我们的测试,会出现以下错误:

这个错误告诉我们,我们的double不能访问某个win_promo 方法;这就是为什么它被称为一个意外信息。因此,我们需要将这个消息传递给我们的double,并表示预期的响应,在我们的例子中是20。
new_coin = double('Coin', :win_promo => 20)
再次运行我们的测试,我们得到了一个绿色的💪。
让我们进行一个小实验。让我们继续下去,把win_promo 方法的名称改为win_a_promo 。令人惊讶的是,我们的测试仍然通过,但是对于一个真正的对象,我们应该得到一个no_method 的错误。发生这种情况是因为doubles 并不是它们所模仿的对象的精确复制品。然而,RSpec提供了instance_double 方法,它可以验证双胞胎是否是它们 "冒充 "的对象的精确代表。使用instance_double 方法,我们有以下情况。
new_coin = instance_double('Coin', :win_promo => 20)
运行我们的测试会产生以下错误:

因此,我们可以在范围内测试一个对象,而不测试依赖关系的行为,并且仍然对真实对象进行验证,这样它们就会抛出与真实对象预期相同的错误。将方法名称还原为win_promo ,会使测试通过。
方法存根
方法存根是对一个对象(真实的或测试用的双数)的指令,以返回一个特定的值来响应一个消息。让我们通过尝试提取一些硬币来将其付诸实施。在我们的案例中,在任何提款之前,我们应该确保一个交易员是经过验证的。
describe "withdraw some coins" do
it 'should be successful if trader is verified' do
allow(@trader).to receive(:verified?) { true }
@trader.withdraw(20)
expect(@trader.coin_worth).to eq(30)
end
it 'should not be successful if trader is unverified' do
allow(@trader).to receive(:verified?) { false }
@trader.withdraw(20)
expect(@trader.coin_worth).to eq(50)
end
end
上面,我们写了一个测试案例,当交易员被验证时,提款成功,当他没有被验证时,提款不成功。语句allow(@trader).to receive(:verified?) { false } ,翻译为允许交易员对象接收消息verified? ,这又调用了一个verified? 方法,并返回一个响应false 。这意味着我们在支配一个对象或一个双数上调用特定方法的结果。现在运行我们的测试会导致失败,因为我们还没有为交易员类实现withdraw 方法。
随后的代码可以这样:
# in crypto.rb, within the trader class
def withdraw(value)
return unless verified?
@coin_worth -= value
end
当我们运行我们的测试时,即使我们的交易员类中没有实际的verified? 方法,它们也会通过。这是因为我们在存根中指定了方法名称和要返回的结果;因此,RSpec认为没有必要在类中调用该方法来获得结果。
但在现实生活中,这个方法是存在的,也许在这个方法中,还有其他与被测类完全无关的依赖关系。因此,我们可以说,当我们有其他不想测试的依赖关系时,方法存根就特别有用,比如外部API。我们可以通过支配一个已知的值作为该方法调用的结果来跳过这些测试,这要感谢方法存根!
消息的期望
消息和方法这两个词经常被当作同义词使用,但它们确实有一点区别。在面向对象的编程中,对象通过向彼此发送消息来进行交流。当一个对象收到一个消息时,它会调用一个与该消息同名的方法。下面是一个例子:
class User
def save
end
end
user 当一个名为User 的类的实例被创建时,我们可以通过user.save 将一个save 消息传递给它。这又会调用定义在User 类中的save 方法。
消息期望是一种期望,即一个对象在例子结束前应该收到某个消息。类似于user.save 的例子,我们将测试user 对象是否收到save 消息。让我们用我们的交易员类来实现这一点。
还记得我们的verified? 方法吗?让我们继续前进并实际实现验证。让我们假设在注册成为交易员的过程中,必须选择一个验证中心,在那里提交所有的文件并进行一些生物识别分析。这个外部服务将负责检查一个交易员是否已经被验证(即,提交了所有文件并完成了生物识别)。
我们的before_each 方法将改变以纳入新的变化。
before(:each) do
@verification_center = VerificationCenter.new
@trader = Trader.new(50, @verification_center)
end
我们应该向验证中心发送一个verify 消息,并把要验证的交易员作为一个参数。相应的测试会是这样的。
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
expect(@verification_center).to receive(:verify).with(@trader)
@trader.verified?
end
end
在上面的测试中,我们是说当verified? 消息被发送时,我们期望verified? 方法应该发送一个verify 消息给验证中心,并把交易员作为参数。如果这句话不是很清楚,请检查上面的内容,并阅读关于消息和方法的内容。
为了使这个测试通过,我们将对我们的代码进行以下修改。
attr_accessor :coin_worth, :verification_center
def initialize(coin_worth, verification_center)
@coin_worth = coin_worth
@verification_center = verification_center
end
def verified?
@verification_center.verify(self)
end
让我们添加验证中心类。
class VerificationCenter
def verify(trader)
# checks if the trader is verified using an external api
end
end
现在,我们的测试通过了。我们已经能够确认验证中心收到了以交易员为参数的verify 消息。这很有用,因为我们此时并不关心测试验证中心类;我们只是测试消息是否被收到,以及正确的参数是否被发送。
如果我们想在测试中设置验证消息中除nil 以外的其他响应,可以使用and_return ,或者将响应作为一个块来传递。
expect(@verification_center).to receive(:verify).with(@trader).and_return("return value")
expect(@verification_center).to receive(:verify).with(@trader) {"return value"}
什么时候会需要设置响应呢?如果我们想测试在一个特定的会话中,我们不会尝试验证一个交易者超过一次,我们可以检查verify 方法只被调用一次。
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
expect(@verification_center).to receive(:verify).with(@trader).and_return(true).once
@trader.verified?
@trader.verified?
end
end
现在运行测试的结果如下:

为了使这个测试通过,我们必须更新我们的verified? 方法,如果它以前被调用过,就使用一个实例变量。
def verified?
@verified ||= @verification_center.verify(self)
end
我们的测试现在通过了,因为通过返回true ,我们给我们的实例变量@verified 赋了一个值。我们只去了一次验证中心,我们可以用消息期望来测试这一点。
测试间谍
通过消息期望,我们用未来的方式说话;我们已经声明了我们对将要收到的消息的期望,然后我们执行了使之成为可能的行动。这就是为什么我们在trader.verified? 行动之前有expect 声明。如果我们想先执行动作,然后在这个过程中监视对象,使我们能够以准确的计数来检查消息是否被收到呢? 这就是测试间谍的作用;相对于测试一个对象将收到一个消息,我们测试一个对象是否收到一个消息。要做到这一点(验证一个消息是否被收到),必须对给定的对象进行设置,以监视它。
使用间谍实现前面的测试,我们将创建一个间谍验证中心,并指定一个消息的返回值verify 。
describe 'verification is carried out' do
it 'should call the verify method to verify a trader' do
verification_center = spy("Verification center", :verify => true)
@trader.verification_center = verification_center
@trader.verified?
@trader.verified?
expect(verification_center).to have_received(:verify).once.with(@trader)
end
end
如上所见,测试间谍确定是否收到了消息,而消息期望测试是否会收到消息。
结论
RSpec给我们提供了大量的工具,使我们能够测试对象的行为。Mocks在测试对象之间的交互时非常有用,它可以验证哪些消息被发送或不被发送,并确保它们按照预期进行通信。当你对对象的结果而不是行为更感兴趣时,存根在模仿对象的返回值方面特别有用,例如外部API。间谍也测试对象之间的交互,但进行的是事件发生的检查,而不是测试它们将在未来某个时候发生。在任何时候都能够确定要测试哪些行为和它们的依赖关系,以及测试这些依赖关系是否相关,这对确定每次使用什么工具有很大帮助。