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 的所有內建匹配器提供了一個或多個類似的別名。我們嘗試使用一致的措辭(通常是「一個 [物件類型] [動詞]ing」),以便它們容易猜測。您將在下面看到許多範例,並且 RSpec 3 文件將包含完整列表。

還有一個公開的 API,可以輕鬆定義您自己的別名(適用於 RSpec 的內建匹配器或自訂匹配器)。以下是 rspec-expectations 中提供 start_with 的別名 a_string_starting_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(也就是說,如果它是同一個物件),期望就會通過。