rubyrspecrspec3rspec-expectations

Can I alias a nested RSpec matcher?


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.


Solution

  • 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