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,这显然是错误的(或者更确切地说,是 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)

虽然运算符匹配器使用起来很直观,但为了让它们正常工作,它们需要 RSpec 对其进行特殊处理,因为 Ruby 的优先级规则。此外,should == 会产生一个 ruby 警告[^foot1],人们偶尔会对 should != 的工作方式感到惊讶,因为它并不像他们预期的那样工作[^foot2]。

新语法使我们有机会从运算符匹配器的不一致中干净地断开,而不必担心破坏现有的测试套件,因此我们决定在新语法中不支持运算符匹配器。以下列出了每个旧运算符匹配器(与 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 年多前添加的!