我可以为嵌套的 RSpec 匹配器起别名吗?

Can I alias a nested RSpec matcher?

我有几个 RSpec 示例,它们共享以下复杂的期望,数组 records 和浮点数 min_longmax_longmin_lat, max_lat 这些示例之间有所不同。

  expect(records).to all have_attributes(
    shape: have_attributes(
      exterior_ring: have_attributes(
        points: all(
          have_attributes(
            longitude: be_between(min_long, max_long),
            latitude: be_between(min_lat, max_lat)
          )
        )
      )
    )
  )

(期望检查相应测试产生的所有记录是否具有完全包含在测试特定边界框中的形状(在我的例子中是 RGeo Polygon。)

为了减少重复并通过在上面贴上名称使复杂期望的意图更清楚,我将其提取为方法:

def expect_in_bbox(records, min_long, max_long, min_lat, max_lat)
  expect(records).to all have_attributes(
    shape: have_attributes(
      exterior_ring: have_attributes(
        points: all(
          have_attributes(
            longitude: be_between(min_long, max_long),
            latitude: be_between(min_lat, max_lat)
          )
        )
      )
    )
  )
end

这工作正常,但现在我必须调用该方法,例如

expect_in_bbox(valid_records, 12.55744, 12.80270, 51.36250, 51.63187)

在我的示例中。

这在 RSpec 的规范 DSL 中看起来很陌生。 我希望能够写作

expect(valid_records).to be_in_bbox(12.55744, 12.80270, 51.36250, 51.63187)

expect(valid_records).to all be_in_bbox(12.55744, 12.80270, 51.36250, 51.63187)

代替。

是否有推荐的实现方法?

我认为我不能为此使用 RSpec 的匹配器别名工具,因为它们似乎只能将匹配器 名称 映射到其他匹配器 names,没有完成带参数的匹配器调用。不过,也许 alias_matcheroptions 参数就是为了这个?

当然,我也可以实现一个自定义匹配器,但是我可能会被迫提供一个实现 returns 一个布尔值,这与它由已经存在的匹配器组成相矛盾。 (并不是说它很难,但我喜欢使用 allbe_between 之类的东西来实现。)

最后,我还可以猴子修补 valid_records 元素的 class 以具有 in_bbox?(min_long, max_long, min_lat, max_lat) 属性,以便 RSpec 会自动提供相应的be_in_bbox(min_long, max_long, min_lat, max_lat)匹配器。

首先,根据经验,您永远不应该有复杂的期望值,而应该针对每个嵌套的期望值将一体化测试拆分为一个。

这不仅更接近 rspec 尝试做的事情,而且还可以更好地提示您的预期在哪里失败。

但是您可以将自己的期望添加到匹配器模块中:

RSpec::Matchers.define :be_in_bbox do |expected|
 match do |actual|
   # pseudo code:
   actual.has_attributes( 
     {:shape => has_attributes(
       ...
      )}
   )
 end
end

我知道这只是一个提示,你必须自己弄清楚实际的代码......但我不认为这是你想要的方法;)

我认为,尽管这对您来说似乎违反直觉,但稍微过度地编写规范文件实际上会更清楚地说明单个测试用例实际上应该做什么。

这实际上是编程社区中讨论最多的嫌疑人,但我确实更喜欢这种方式。 Something roughly related to this topic

当然可以。将其设为 helper method.

辅助方法

这些只是正常的 Ruby 方法。您可以在任何示例组中定义它们。这些辅助方法会暴露给定义它们的组中的示例和嵌套在该组中的组,但不会暴露给父组或兄弟组。

def be_in_bbox(min_long, max_long, min_lat, max_lat)
  all(
    have_attributes(
      shape: have_attributes(
        exterior_ring: have_attributes(
          points: all(
            have_attributes(
              longitude: be_between(min_long, max_long),
              latitude: be_between(min_lat, max_lat)
            )
          )
        )
      )
    )
  )
end

我建议将该方法放在存储在 spec/support 中的一个有用的命名文件中。可能是 spec/support/rgeo_matchers.rb 之类的东西。正如所写,这将在 main 上定义助手,将其混合到 Kernel 中,这使得它可用于 Ruby 中的每个对象。您需要确保在所有必要的规范文件中都需要此帮助文件:require 'support/rgeo_matchers'.

我建议 placing them in a module 避免在 main 上定义助手:

module MyProject
  module RGeo
    module Matchers
      def be_in_bbox(...)
        # ...
      end
    end
  end
end

由于匹配器位于模块中,因此您需要在 RSpec.describe 块中添加 include MyProject::RGeo::Matchers

另一种方法是将其设为 shared context:

RSpec.shared_context "RGeo matchers" do
  def be_in_bbox(...)
    # ...
  end
end

对于共享上下文,您需要使用 include_context 代替 includeinclude_context "RGeo matchers"

复杂匹配器

虽然您描述的匹配器相当嵌套,但如果它适合您的域模型并描述连贯的 "unit" 那么这在我的书中是可以接受的。 "test one thing" 并不一定意味着只测试单个属性。意思是测试一个"coherent concept"或"unit"。这意味着什么取决于域模型。

合并 composable matchers with compound expectations, as you have demonstrated, provide an simple, and valid, alternative to writing a custom matcher.

备选方案

根据您的建议,或许可以从助手中删除 all,以便匹配器仅描述为 "in a bounding box":

def be_in_bbox(min_long, max_long, min_lat, max_lat)
  have_attributes(
    # ...
  )
end

这使得匹配器更易于重用。正如它真正描述的那样 "one thing"(例如 "inside a bounding box")。这允许您将其用作独立的匹配器或将其与其他匹配器组合使用:

it "returns matching bounding boxes" do
  expect(valid_records).to all be_in_bbox(12.55744, 12.80270, 51.36250, 51.63187)
end

it "is in bounding box defined by [(12.55744..12.80270), (51.36250..51.63187)]" do
  expect(generated_box).to be_in_bbox(12.55744, 12.80270, 51.36250, 51.63187)
end