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.
Sure you can do that. Make it a helper method.
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"
.
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.
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