RSpec Mocks 构建状态 代码质量

rspec-mocks 是一个用于 rspec 的测试替身框架,支持在生成的测试替身和真实对象上进行方法存根、伪造和消息预期。

安装

gem install rspec       # for rspec-core, rspec-expectations, rspec-mocks
gem install rspec-mocks # for rspec-mocks only

想要针对 main 分支运行?你需要包含依赖的 RSpec 库。将以下内容添加到你的 Gemfile

%w[rspec-core rspec-expectations rspec-mocks rspec-support].each do |lib|
  gem lib, :git => "https://github.com/rspec/#{lib}.git", :branch => 'main'
end

贡献

设置好环境后,你需要 cd 到你想工作的任何库的工作目录中。从那里你可以运行规格和黄瓜功能,并制作补丁。

注意:你不需要使用 rspec-dev 来处理特定的 RSpec 库。你可以将每个 RSpec 库视为一个独立的项目。

有关向 RSpec 贡献的更多信息,请参阅以下 Markdown 文件

测试替身

测试替身是在代码示例中代替系统中另一个对象的物体。使用 double 方法,传入一个可选的标识符,来创建一个。

book = double("book")

大多数情况下,你希望对你的替身与系统中现有对象类似有一定的信心。为了这个目的提供了验证替身。如果现有对象可用,它们将阻止你添加对不存在或参数数量不正确的方法的存根和预期。

book = instance_double("Book", :pages => 250)

验证替身有一些巧妙的技巧,让你可以在隔离情况下进行测试,而无需加载依赖项,同时仍然能够针对真实对象进行验证。更多细节可以在 它们的文档 中找到。

验证替身也可以接受自定义标识符,就像 double() 一样,例如

books = []
books << instance_double("Book", :rspec_book, :pages => 250)
books << instance_double("Book", "(Untitled)", :pages => 5000)
puts books.inspect # with names, it's clearer which were actually added

方法存根

方法存根是返回预定值的实现。方法存根可以在测试替身或真实对象上使用相同的语法声明。rspec-mocks 支持 3 种声明方法存根的形式

allow(book).to receive(:title) { "The RSpec Book" }
allow(book).to receive(:title).and_return("The RSpec Book")
allow(book).to receive_messages(
    :title => "The RSpec Book",
    :subtitle => "Behaviour-Driven Development with RSpec, Cucumber, and Friends")

你也可以使用这个快捷方式,它在一个语句中创建一个测试替身并声明一个方法存根

book = double("book", :title => "The RSpec Book")

第一个参数是名称,用于文档并在失败消息中显示。如果你不关心名称,可以省略它,使组合的实例化/存根声明非常简洁

double(:foo => 'bar')

这在向迭代其元素的方法提供测试替身列表时特别有用

order.calculate_total_price(double(:price => 1.99), double(:price => 2.99))

存根方法链

你可以在 receive 的位置使用 receive_message_chain 来存根一连串的消息

allow(double).to receive_message_chain("foo.bar") { :baz }
allow(double).to receive_message_chain(:foo, :bar => :baz)
allow(double).to receive_message_chain(:foo, :bar) { :baz }
# Given any of the above forms:
double.foo.bar # => :baz

链可以任意长,这使得以暴力方式违反 Demeter 定律变得非常容易,因此你应该将任何使用 receive_message_chain 的情况视为代码异味。虽然并非所有代码异味都表明存在实际问题(例如流畅接口),但 receive_message_chain 仍然会导致脆弱的示例。例如,如果你在规范中写了 allow(foo).to receive_message_chain(:bar, :baz => 37),然后实现调用了 foo.baz.bar,存根将不起作用。

连续返回值

当存根可能被调用多次时,你可以向 and_return 提供额外的参数。调用将循环遍历列表。最后一个值将在任何随后的调用中返回

allow(die).to receive(:roll).and_return(1, 2, 3)
die.roll # => 1
die.roll # => 2
die.roll # => 3
die.roll # => 3
die.roll # => 3

要在单次调用中返回数组,请声明一个数组

allow(team).to receive(:players).and_return([double(:name => "David")])

消息预期

消息预期是对测试替身在示例结束前的一段时间内将收到消息的预期。如果收到消息,则预期满足。如果没有,则示例失败。

validator = double("validator")
expect(validator).to receive(:validate) { "02134" }
zipcode = Zipcode.new("02134", validator)
zipcode.valid?

测试间谍

验证给定对象在测试过程中是否收到了预期的消息。要验证消息,必须设置给定对象以对其进行间谍,方法是让它显式存根或成为空对象替身(例如 double(...).as_null_object)。提供便利方法以轻松地为此目的创建空对象替身

spy("invitation") # => same as `double("invitation").as_null_object`
instance_spy("Invitation") # => same as `instance_double("Invitation").as_null_object`
class_spy("Invitation") # => same as `class_double("Invitation").as_null_object`
object_spy("Invitation") # => same as `object_double("Invitation").as_null_object`

以这种方式验证接收到的消息实现了测试间谍模式。

invitation = spy('invitation')
user.accept_invitation(invitation)
expect(invitation).to have_received(:accept)
# You can also use other common message expectations. For example:
expect(invitation).to have_received(:accept).with(mailer)
expect(invitation).to have_received(:accept).twice
expect(invitation).to_not have_received(:accept).with(mailer)
# One can specify a return value on the spy the same way one would a double.
invitation = spy('invitation', :accept => true)
expect(invitation).to have_received(:accept).with(mailer)
expect(invitation.accept).to eq(true)

请注意,当传递的参数在间谍记录接收到的消息后被修改时,have_received(...).with(...) 无法正常工作。例如,这不能正常工作

greeter = spy("greeter")
message = "Hello"
greeter.greet_with(message)
message << ", World"
expect(greeter).to have_received(:greet_with).with("Hello")

术语

模拟对象和测试存根

模拟对象和测试存根这两个名称暗示了专门的测试替身。例如,测试存根是一个仅支持方法存根的测试替身,而模拟对象是一个支持消息预期和方法存根的测试替身。

这里有很多重叠的术语,并且这些模式有很多变体(伪造、间谍等)。请记住,大多数时候我们谈论的是方法级别的概念,这些概念是方法存根和消息预期的变体,我们将其应用于一种通用类型的对象:测试替身。

测试特定扩展

也称为部分替身,测试特定扩展是系统中真实对象的扩展,在测试上下文中使用测试替身类似的行为进行检测。这种技术在 Ruby 中非常常见,因为我们经常看到类对象充当方法的全局命名空间。例如,在 Rails 中

person = double("person")
allow(Person).to receive(:find) { person }

在这种情况下,我们正在检测 Person,以便在它收到 find 消息时返回我们定义的 Person 对象。我们还可以设置消息预期,以便如果未调用 find,则示例将失败

person = double("person")
expect(Person).to receive(:find) { person }

RSpec 将我们正在存根或模拟的方法替换为它自己的测试替身类似方法。在示例结束时,RSpec 将验证所有消息预期,然后恢复原始方法。

预期参数

expect(double).to receive(:msg).with(*args)
expect(double).to_not receive(:msg).with(*args)

如果你需要,可以对同一消息设置多个预期

expect(double).to receive(:msg).with("A", 1, 3)
expect(double).to receive(:msg).with("B", 2, 4)

参数匹配器

传递给 with 的参数使用 === 与接收到的实际参数进行比较。在你想指定参数本身之外的其他情况时,可以使用任何与 rspec-expectations 一起提供的匹配器。它们并不都具有语法意义(它们主要设计用于与 RSpec::Expectations 一起使用),但你可以自由地创建自己的自定义 RSpec::Matchers。

rspec-mocks 还添加了一些关键字符号,你可以使用它们来指定特定类型的参数

expect(double).to receive(:msg).with(no_args)
expect(double).to receive(:msg).with(any_args)
expect(double).to receive(:msg).with(1, any_args) # any args acts like an arg splat and can go anywhere
expect(double).to receive(:msg).with(1, kind_of(Numeric), "b") #2nd argument can be any kind of Numeric
expect(double).to receive(:msg).with(1, boolean(), "b") #2nd argument can be true or false
expect(double).to receive(:msg).with(1, /abc/, "b") #2nd argument can be any String matching the submitted Regexp
expect(double).to receive(:msg).with(1, anything(), "b") #2nd argument can be anything at all
expect(double).to receive(:msg).with(1, duck_type(:abs, :div), "b") #2nd argument can be object that responds to #abs and #div
expect(double).to receive(:msg).with(hash_including(:a => 5)) # first arg is a hash with a: 5 as one of the key-values
expect(double).to receive(:msg).with(array_including(5)) # first arg is an array with 5 as one of the key-values
expect(double).to receive(:msg).with(hash_excluding(:a => 5)) # first arg is a hash without a: 5 as one of the key-values
expect(double).to receive(:msg).with(start_with('a')) # any matcher, custom or from rspec-expectations
expect(double).to receive(:msg).with(satisfy { |data| data.dig(:a, :b, :c) == 5 }) # assert anything you want

接收计数

expect(double).to receive(:msg).once
expect(double).to receive(:msg).twice
expect(double).to receive(:msg).exactly(n).time
expect(double).to receive(:msg).exactly(n).times
expect(double).to receive(:msg).at_least(:once)
expect(double).to receive(:msg).at_least(:twice)
expect(double).to receive(:msg).at_least(n).time
expect(double).to receive(:msg).at_least(n).times
expect(double).to receive(:msg).at_most(:once)
expect(double).to receive(:msg).at_most(:twice)
expect(double).to receive(:msg).at_most(n).time
expect(double).to receive(:msg).at_most(n).times

排序

expect(double).to receive(:msg).ordered
expect(double).to receive(:other_msg).ordered
  # This will fail if the messages are received out of order

这可以包括具有不同参数的相同消息

expect(double).to receive(:msg).with("A", 1, 3).ordered
expect(double).to receive(:msg).with("B", 2, 4).ordered

设置响应

无论你是设置消息预期还是方法存根,你都可以告诉对象如何精确地响应。最通用的方法是向 receive 传递一个块

expect(double).to receive(:msg) { value }

当替身收到 msg 消息时,它将评估该块并返回结果。

expect(double).to receive(:msg).and_return(value)
expect(double).to receive(:msg).exactly(3).times.and_return(value1, value2, value3)
  # returns value1 the first time, value2 the second, etc
expect(double).to receive(:msg).and_raise(error)
  # `error` can be an instantiated object (e.g. `StandardError.new(some_arg)`) or a class (e.g. `StandardError`)
  # if it is a class, it must be instantiable with no args
expect(double).to receive(:msg).and_throw(:msg)
expect(double).to receive(:msg).and_yield(values, to, yield)
expect(double).to receive(:msg).and_yield(values, to, yield).and_yield(some, other, values, this, time)
  # for methods that yield to a block multiple times

所有这些响应也可以应用于存根

allow(double).to receive(:msg).and_return(value)
allow(double).to receive(:msg).and_return(value1, value2, value3)
allow(double).to receive(:msg).and_raise(error)
allow(double).to receive(:msg).and_throw(:msg)
allow(double).to receive(:msg).and_yield(values, to, yield)
allow(double).to receive(:msg).and_yield(values, to, yield).and_yield(some, other, values, this, time)

任意处理

有时你会发现可用的预期不能解决你试图解决的特定问题。假设你希望消息带有长度特定的数组参数,但你并不关心它里面有什么。你可以这样做

expect(double).to receive(:msg) do |arg|
  expect(arg.size).to eq 7
end

如果被存根的方法本身接受一个块,你需要以特殊的方式对其进行 yield,你可以使用这个

expect(double).to receive(:msg) do |&arg|
  begin
    arg.call
  ensure
    # cleanup
  end
end

委托给原始实现

在处理部分模拟对象时,你可能偶尔想要设置消息预期,而不干扰对象对消息的响应方式。你可以使用 and_call_original 来实现这一点

expect(Person).to receive(:find).and_call_original
Person.find # => executes the original find method and returns the result

组合预期细节

通过将消息名称与特定参数、接收计数和响应相结合,你可以在预期中获得相当多的细节

expect(double).to receive(:<<).with("illegal value").once.and_raise(ArgumentError)

虽然这在真正需要时是一件好事,但你可能并不真正需要它!注意只指定对代码行为重要的东西。

存根和隐藏常量

有关此功能的信息,请参阅 更改常量自述文件

使用 before(:example),而不是 before(:context)

不支持 before(:context) 中的存根。原因是所有存根和模拟将在每个示例后被清除,因此在 before(:context) 中设置的任何存根将在该组中碰巧运行的第一个示例中工作,但不会在其他示例中工作。

不要使用 before(:context),而是使用 before(:example)

在类的任何实例上设置模拟或存根

rspec-mocks 提供了两种方法,allow_any_instance_ofexpect_any_instance_of,它们允许你存根或模拟类的任何实例。它们用于代替 allowexpect

allow_any_instance_of(Widget).to receive(:name).and_return("Wibble")
expect_any_instance_of(Widget).to receive(:name).and_return("Wobble")

这些方法会向 Widget 的所有实例添加适当的存根或预期。

在处理遗留代码时,此功能有时很有用,但总的来说,我们出于以下几个原因建议不要使用它

  • rspec-mocks API 是为单个对象实例设计的,但此功能适用于整个类对象。因此,存在一些语义上令人困惑的边缘情况。例如,在 expect_any_instance_of(Widget).to receive(:name).twice 中,不清楚每个特定实例是否预期接收 name 两次,或者是否预期总共接收两次。(它是前者。)
  • 使用此功能通常是设计异味。可能是你的测试试图做太多事,或者被测对象过于复杂。
  • 它是 rspec-mocks 中最复杂的功能,历史上也收到了最多的错误报告。(核心团队没有一个人积极使用它,这并没有帮助。)

进一步阅读

关于模拟和存根的含义,有很多不同的观点。如果你有兴趣了解更多信息,这里有一些推荐的读物

另请参阅