RSpec 3 中的新功能:可组合的匹配器

Myron Marston

2014 年 1 月 14 日

RSpec 3 的一大新功能是发布 3.0.0.beta2:可组合的匹配器。此功能支持更强大、更不易出错的预期,并开辟了新的可能性。

示例

在 RSpec 2.x 中,我在很多情况下都写过这样的代码

# background_worker.rb
class BackgroundWorker
  attr_reader :queue

  def initialize
    @queue = []
  end

  def enqueue(job_data)
    queue << job_data.merge(:enqueued_at => Time.now)
  end
end
# background_worker_spec.rb
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue.size).to eq(2)
    expect(worker.queue[0]).to include(:klass => "Class1", :id => 37)
    expect(worker.queue[1]).to include(:klass => "Class2", :id => 42)
  end
end

在 RSpec 3 中,可组合的匹配器允许您将匹配器作为参数传递(或嵌套在作为参数传递的数据结构中)传递给其他匹配器,从而简化诸如此类的规范

# background_worker_spec.rb
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue).to match [
      a_hash_including(:klass => "Class1", :id => 37),
      a_hash_including(:klass => "Class2", :id => 42)
    ]
  end
end

我们确保这些情况下的失败消息易于阅读,选择使用提供的匹配器的 description 而不是 inspect 输出。例如,如果我们通过注释掉 queue << ... 行来“破坏”此规范测试的实现,它将失败并显示以下信息:

1) BackgroundWorker puts enqueued jobs onto the queue in order
   Failure/Error: expect(worker.queue).to match [
     expected [] to match [(a hash including {:klass => "Class1", :id => 37}), (a hash including {:klass => "Class2", :id => 42})]
     Diff:
     @@ -1,3 +1,2 @@
     -[(a hash including {:klass => "Class1", :id => 37}),
     - (a hash including {:klass => "Class2", :id => 42})]
     +[]

   # ./spec/background_worker_spec.rb:19:in `block (2 levels) in <top (required)>'

匹配器别名

您可能已经注意到,上面的示例使用 a_hash_including 代替 include。RSpec 3 为所有内置匹配器提供了类似的别名,这些别名在语法上更易读,并提供更好的失败消息。

例如,比较此预期和失败消息

x = "a"
expect { }.to change { x }.from start_with("a")
expected result to have changed from start with "a", but did not change

…到

x = "a"
expect { }.to change { x }.from a_string_starting_with("a")
expected result to have changed from a string starting with "a",
but did not change

虽然 a_string_starting_withstart_with 更冗长,但它生成的失败消息实际上易于阅读,因此您不会遇到奇怪的语法结构。我们为所有 RSpec 的内置匹配器提供了一个或多个类似的别名。我们尝试使用一致的措辞(通常是“a [对象类型] [动词]ing”),这样它们很容易猜测。您将在下面看到很多示例,RSpec 3 文档将提供完整列表。

还有一个公共 API,它使定义您自己的别名变得非常简单(无论是针对 RSpec 的内置匹配器还是自定义匹配器)。以下是 rspec-expectations 中提供 a_string_starting_withstart_with 别名的代码部分

RSpec::Matchers.alias_matcher :a_string_starting_with, :start_with

复合匹配器表达式

Eloy Espinaco 贡献了一个新功能,它提供了另一种组合匹配器的方法:复合 andor 匹配器表达式。例如,而不是编写以下代码:

expect(alphabet).to start_with("a")
expect(alphabet).to end_with("z")

…您可以将它们组合成一个预期

expect(alphabet).to start_with("a").and end_with("z")

您可以对 or 做同样的事情。虽然不太常见,但这对于表达有效值列表中的一个值很有用(例如,当确切值不确定时)

expect(stoplight.color).to eq("red").or eq("green").or eq("yellow")

我认为这对于使用 Jim Weirich 的 rspec-given 表达不变量可能特别有用。

复合匹配器表达式也可以作为参数传递给另一个匹配器

expect(["food", "drink"]).to include(
  a_string_starting_with("f").and ending_with("d")
)

注意:在此示例中,ending_withend_with 匹配器的另一个别名。

哪些匹配器支持匹配器参数?

在 RSpec 3 中,我们更新了许多匹配器以支持接收匹配器作为参数,但并非所有匹配器都支持。一般来说,我们更新了所有我们认为有意义的匹配器。不支持匹配器的匹配器是那些具有精确匹配语义且不允许使用匹配器参数的匹配器。例如,eq 匹配器在文档中说明,当且仅当 actual == expected 时才会通过。eq 不支持接收匹配器参数[^foot_1] 并没有意义。

我在下面列出了所有支持接收匹配器作为参数的内置匹配器。

change

change 匹配器的 by 方法可以接收一个匹配器

k = 0
expect { k += 1.05 }.to change { k }.by( a_value_within(0.1).of(1.0) )

您也可以将匹配器传递给 fromto

s = "food"
expect { s = "barn" }.to change { s }.
  from( a_string_matching(/foo/) ).
  to( a_string_matching(/bar/) )

contain_exactly

contain_exactlymatch_array 的一个新别名。与 match_array 相比,语义更清晰(现在 match 也可以匹配数组,但 match 要求顺序匹配,而 match_array 则不需要)。它还允许您将数组元素作为单独的参数传递,而不是像 match_array 所期望的那样强制传递单个数组参数。

expect(["barn", 2.45]).to contain_exactly(
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
)

# ...which is the same as:

expect(["barn", 2.45]).to match_array([
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
])

include

include 允许您匹配集合中的元素、哈希的键或哈希中键值对的子集

expect(["barn", 2.45]).to include( a_string_starting_with("bar") )

expect(12 => "twelve", 3 => "three").to include( a_value_between(10, 15) )

expect(:a => "food", :b => "good").to include(
  :a => a_string_matching(/foo/)
)

match

除了将字符串与正则表达式或另一个字符串匹配之外,match 现在还可以匹配任意数组/哈希数据结构,嵌套深度可以是任意深度的。匹配器可以在该嵌套的任何级别使用

hash = {
  :a => {
    :b => ["foo", 5],
    :c => { :d => 2.05 }
  }
}

expect(hash).to match(
  :a => {
    :b => a_collection_containing_exactly(
      an_instance_of(Fixnum),
      a_string_starting_with("f")
    ),
    :c => { :d => (a_value < 3) }
  }
)

raise_error

raise_error 可以接受一个匹配器来匹配异常类,或一个匹配器来匹配消息,或者两者都接受。

RSpec::Matchers.define :an_exception_caused_by do |cause|
  match do |exception|
    cause === exception.cause
  end
end

expect {
  begin
    "foo".gsub # requires 2 args
  rescue ArgumentError
    raise "failed to gsub"
  end
}.to raise_error( an_exception_caused_by(ArgumentError) )

expect {
  raise ArgumentError, "missing :foo arg"
}.to raise_error(ArgumentError, a_string_starting_with("missing"))

startwith 和 endwith

这些非常不言自明

expect(["barn", "food", 2.45]).to start_with(
  a_string_matching("bar"),
  a_string_matching("foo")
)

expect(["barn", "food", 2.45]).to end_with(
  a_string_matching("foo"),
  a_value < 3
)

throw_symbol

您可以将一个匹配器传递给 throw_symbol 来匹配随附的参数

expect {
  throw :pi, Math::PI
}.to throw_symbol(:pi, a_value_within(0.01).of(3.14))

yieldwithargs 和 yieldsuccessiveargs

匹配器可以用来指定这些匹配器的 yield 参数

expect { |probe|
  "food".tap(&probe)
}.to yield_with_args( a_string_starting_with("f") )

expect { |probe|
  [1, 2, 3].each(&probe)
}.to yield_successive_args( a_value < 2, 2, a_value > 2 )

结论

这是 RSpec 3 中我最期待的新功能之一,我希望您能明白原因。这应该有助于通过使您能够精确地指定您所期望的(仅此而已)来更轻松地避免编写脆弱的规范。

[^foot_1]: 当然,您可以将一个匹配器传递给 eq,但它会像对待任何其他对象一样:它会使用 == 将其与 actual 进行比较,如果返回 true(即,如果它们是同一个对象),则预期将通过。