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.
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.
I suggest putting that method in a helpfully named file stored in
spec/support
. Possibly something such asspec/support/rgeo_matchers.rb
. As written, this will define the helper onmain
, mixing it intoKernel
, 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:Since the matcher is in a module, you'll need to add
include MyProject::RGeo::Matchers
inside yourRSpec.describe
block.An alternative is to make it a shared context:
With a shared context you'll need to use
include_context
in place ofinclude
: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":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: