Can I alias a nested RSpec matcher?

1.2k views Asked by At

I have several RSpec examples that share the following complex expectation, with the array records and the floating point numbers min_long, max_long, min_lat, max_lat varying between these examples.

  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)
          )
        )
      )
    )
  )

(The expectation checks whether all records the respective test produced have a shape (an RGeo Polygon, in my case) completely contained in a test-specific bounding box.)

To reduce repetition and to make the intend of the complex expectation clearer by sticking a name on it, I extracted it into a method:

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

This works fine, but now I have to call that method with e.g.

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

in my examples.

This looks foreign in RSpec's specification DSL. I'd prefer to be able to write

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

or

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

instead.

Is there a recommended way to achieve this?

I don't think I can use RSpec's matcher aliasing facilities for this, as they only seem to map matcher names onto other matcher names, not complete matcher invocations with arguments. Though, maybe the options argument of alias_matcher is meant for that?

Of course, I could also implement a custom matcher, but then I probably would be forced to provide an implementation that returns a boolean which contradicts it being composed of already existing matchers. (Not that it'd be hard, but I like the implementation using stuff like all and be_between.)

Lastly, I could also monkey-patch the class of the element of valid_records to have a in_bbox?(min_long, max_long, min_lat, max_lat) attribute, so that RSpec would automatically provide the corresponding be_in_bbox(min_long, max_long, min_lat, max_lat) matcher.

2

There are 2 answers

0
Aaron K On BEST ANSWER

Sure you can do that. Make it a helper method.

Helper Methods

These are just normal Ruby methods. You can define them in any example group. These helper methods are exposed to examples in the group in which they are defined and groups nested within that group, but not parent or sibling groups.

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

I suggest putting that method in a helpfully named file stored in spec/support. Possibly something such as spec/support/rgeo_matchers.rb. As written, this will define the helper on main, mixing it into Kernel, which makes it available to every object in Ruby. You'll need to make sure this helper file is required in all the necessary spec files with: require 'support/rgeo_matchers'.

Instead of defining helpers on main, I suggest placing them in a module to prevent global leakage:

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

Since the matcher is in a module, you'll need to add include MyProject::RGeo::Matchers inside your RSpec.describe block.

An alternative is to make it a shared context:

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

With a shared context you'll need to use include_context in place of include: include_context "RGeo matchers".

Complex Matchers

While the matcher you describe is rather nested, if it fits your domain models and describes a coherent "unit" then that's acceptable in my book. The "test one thing" does not, necessarily, mean test only a single attribute. It means test a "coherent concept" or "unit". What that means depends on the domain model.

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

Alternatives

Per your suggestion, perhaps remove the all from the helper so that the matcher only describes being "in a bounding box":

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

This makes the matcher more re-usable. As it really describe "one thing" (e.g. "inside a bounding box"). This allows you to use it as a standalone matcher or compose it with other matchers:

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
0
Ninigi On

First of all, as a rule of thumb, you should never have a complex expectation, but should rather split your all-in-one test into one, for each nested expected value.

This not only be closer to what rspec tries to do, but also give you a better hint on where your expectation broke.

However you can add your own expectations to the matchers module:

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

I am aware this is only a hint on what to do, you will have to figure out the actual code by yourself... But I do not think this is the approach you want to go for ;)

I think, although this might seem counter intuitive to you, writing your spec files a little more excessively will actually clear out what a single test case is actually supposed to do more clearly.

This is actually a heavily discussed suspect in the programming community, but I really prefer this way. Something roughly related to this topic