RSpec 的新期望語法

Myron Marston

2012 年 6 月 15 日

長期以來,RSpec 一直採用類似可讀英文的語法來設定期望

foo.should eq(bar)
foo.should_not eq(bar)

RSpec 2.11 將包含此語法的新變體

expect(foo).to eq(bar)
expect(foo).not_to eq(bar)

這個新語法有一些動機,我想透過寫部落格來傳播這個觀念。

委派問題

method_missingBasicObject 和標準函式庫的 delegate 之間,Ruby 擁有非常豐富的工具來建構委派或代理物件。不幸的是,RSpec 的 should 語法,儘管讀起來很優雅,但在測試委派/代理物件時,很容易產生奇怪且令人困惑的錯誤。

考慮一個簡單的代理物件,它是 BasicObject 的子類別

# fuzzy_proxy.rb
class FuzzyProxy < BasicObject
  def initialize(target)
    @target = target
  end

  def fuzzy?
    true
  end

  def method_missing(*args, &block)
    @target.__send__(*args, &block)
  end
end

很簡單;它定義了一個 #fuzzy? 謂詞,並將所有其他方法呼叫委派給目標物件。

這是一個簡單的規格來測試它的模糊性

# fuzzy_proxy_spec.rb
describe FuzzyProxy do
  it 'is fuzzy' do
    instance = FuzzyProxy.new(:some_object)
    instance.should be_fuzzy
  end
end

令人驚訝的是,這會失敗

  1) FuzzyProxy is a fuzzy proxy
     Failure/Error: instance.should be_fuzzy
     NoMethodError:
       undefined method `fuzzy?' for :some_object:Symbol
     # ./fuzzy_proxy.rb:11:in `method_missing'
     # ./fuzzy_proxy_spec.rb:6:in `block (2 levels) in <top (required)>'

Finished in 0.01152 seconds
1 example, 1 failure

問題在於 rspec-expectations 在 Kernel 上定義了 should,而 BasicObject 不包含 Kernel…因此 instance.should 會觸發 method_missing 並委派給目標物件。結果實際上是 :some_object.should be_fuzzy,這顯然是 false(或者更確切地說,是一個 NoMethodError)。

當在標準函式庫中使用 delegate 時,情況會變得更加混亂。它有選擇地包含 Kernel 的一些方法…這表示如果 rspec-expectations 在 delegate 之前載入,則 should 將在委派物件上正常運作,但如果 delegate 先載入,它將像我們上面的 FuzzyProxy 範例一樣代理 should 呼叫。

根本問題是 RSpec 的 should 語法:為了讓 should 正常運作,它必須在系統中的每個物件上定義… 但 RSpec 並不擁有系統中的每個物件,無法確保它始終一致地運作。正如我們所看到的,它在代理物件上的運作方式不如 RSpec 預期的那樣。請注意,這不僅是 RSpec 的問題;這也是 minitest/spec 的 must_xxx 語法的問題。

我們提出的解決方案是新的 expect 語法

# fuzzy_proxy_spec.rb
describe FuzzyProxy do
  it 'is fuzzy' do
    instance = FuzzyProxy.new(:some_object)
    expect(instance).to be_fuzzy
  end
end

這不依賴於系統中所有物件上存在的任何方法,因此完全避免了根本問題。

(幾乎)支援所有匹配器

新的 expect 語法看起來與舊的 should 語法不同,但在底層,它們本質上是相同的。您將匹配器傳遞給 #to 方法,如果它不匹配,則測試會失敗。

支援所有匹配器,但有一個重要的例外:expect 語法不直接支援運算子匹配器。

# rather than:
foo.should == bar

# ...use:
expect(foo).to eq(bar)

雖然運算子匹配器使用起來很直觀,但由於 Ruby 的優先規則,它們需要在 RSpec 中進行特殊處理才能正常運作。此外,should == 會產生一個 ruby 警告[^foot_1],並且人們偶爾會對 should != 的運作方式與他們預期的不同而感到驚訝[^foot_2]。

新的語法讓我們有機會徹底擺脫運算子匹配器的不一致性,而不會有破壞現有測試套件的風險,因此我們決定不支援新語法的運算子匹配器。以下是每個舊運算子匹配器(與 should 一起使用)及其 expect 對應項的列表

foo.should == bar
expect(foo).to eq(bar)

"a string".should_not =~ /a regex/
expect("a string").not_to match(/a regex/)

[1, 2, 3].should =~ [2, 1, 3]
expect([1, 2, 3]).to match_array([2, 1, 3])

您可能已經注意到我沒有列出比較匹配器(例如 x.should < 10)——那是因為它們可以運作,但從未被推薦過。誰會說「x 應該小於 10」?它們的本意始終是與 be 一起使用,這樣讀起來更好,而且也能繼續運作

foo.should be < 10
foo.should be <= 10
foo.should be > 10
foo.should be >= 10
expect(foo).to be < 10
expect(foo).to be <= 10
expect(foo).to be > 10
expect(foo).to be >= 10

統一區塊與數值語法

expect 實際上在 RSpec 中已經存在很長一段時間了[^foot_3],以有限的形式存在,作為區塊期望更具可讀性的替代方案

# rather than:
lambda { do_something }.should raise_error(SomeError)

# ...you can do:
expect { something }.to raise_error(SomeError)

在 RSpec 2.11 之前,expect 不接受任何普通參數,也不能用於數值期望。隨著 2.11 中的更改,對於兩種期望類型都使用相同的語法是很不錯的。

組態選項

預設情況下,shouldexpect 語法都可用。但是,如果您只想使用一種語法,則可以設定 RSpec

# spec_helper.rb
RSpec.configure do |config|
  config.expect_with :rspec do |c|
    # Disable the `expect` sytax...
    c.syntax = :should

    # ...or disable the `should` syntax...
    c.syntax = :expect

    # ...or explicitly enable both
    c.syntax = [:should, :expect]
  end
end

例如,如果您要啟動一個新專案,並且想要確保為了保持一致性只使用 expect,則可以完全停用 should。當其中一種語法被停用時,相應的方法將被取消定義。

未來,我們計劃更改預設值,以便除非您明確啟用 should,否則只會提供 expect。我們可能會在 RSpec 3.0 時就執行此操作,但我們希望給使用者充足的時間來熟悉它。

請告訴我們您的想法!

[^foot_1]:正如 Mislav 報告,當警告開啟時,您可能會收到「在空上下文中無用使用 ==」的警告。

[^foot_2]:在 ruby 1.8 上,x.should != y!(x.should == y) 的語法糖,RSpec 無法區分 should ==should !=。在 1.9 上,我們可以區分它們(因為 != 現在可以定義為一個獨立的方法),但在 1.9 上支援它,但在 1.8 上不支援它會令人困惑,因此我們 決定只引發錯誤

[^foot_3]:它最初是 3 年多前加入的!