Composable RSpec matchers
Composable RSpec matchers
While developing RSpec matchers for RailsEventStore we figured out that in some cases it would be good to compose multiple matchers together.
Make it work with include
The core scenario would be checking whether given event is part of a collection. It's a common case in our customers' applications. We want to verify if a certain domain event has been published. We make an assertion on a given RailsEventStore stream and check whether the event is in place. But let's go with a simple example:
expect([FooEvent.new, BarEvent.new, BazEvent.new])
.to include(an_event(BarEvent))
It should work, since it is possible to build an expectation like:
expect([1, 2, 3]).to include(kind_of(Integer))
But nope, not gonna happen:
3) RailsEventStore::RSpec::Matchers should include #<RailsEventStore::RSpec::BeEvent:0x007fb53342bbd0 @differ=#<RSpec::Support::Differ:0x007fb53342bc70 ...y/gems/2.4.0/gems/rspec-support-3.6.0/lib/rspec/support/differ.rb:69 (lambda)>>, @expected=FooEvent>
Failure/Error: specify { expect([FooEvent.new]).to include(an_event(FooEvent)) }
expected [#<FooEvent:0x007fb532b6ffa0 @event_id="7902e78d-8a2f-4563-928b-fafda75491c7", @metadata={}, @data={}>] to include #<RailsEventStore::RSpec::BeEvent:0x007fb53342bbd0 @differ=#<RSpec::Support::Differ:0x007fb53342bc70 ...y/gems/2.4.0/gems/rspec-support-3.6.0
/lib/rspec/support/differ.rb:69 (lambda)>>, @expected=FooEvent>
Diff:
@@ -1,2 +1,2 @@
-[#<RailsEventStore::RSpec::BeEvent:0x007fb53342bbd0 @differ=#<RSpec::Support::Differ:0x007fb53342bc70 @color=true, @object_preparer=#<Proc:0x007fb53342bc20@/Users/fidel/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rspec-support-3.6.0/lib/rspec/support/differ.rb:
69 (lambda)>>, @expected=FooEvent>]
+[#<FooEvent:0x007fb532b6ffa0 @event_id="7902e78d-8a2f-4563-928b-fafda75491c7", @metadata={}, @data={}>]
# ./spec/rails_event_store/rspec/matchers_spec.rb:13:in `block (2 levels) in <module:RSpec>'
Can you see what happened? There's an RSpec matcher in actual collection rather than expected domain event instance. We quickly figured out that our custom matcher is missing some behavior. The one which allows composing it with other matchers. We dived into the RSpec's codebase and found out that RSpec::Matchers::Composable
mixin is our missing block. As docs state: Mixin designed to support the composable matcher features of RSpec 3+. Mix it into your custom matcher classes to allow them to be used in a composable fashion. It works in a quite simple manner by delegating ===
to #matches?
. It allows matchers to be used in composable fashion and also supports using matchers in case statements.
Adding include ::RSpec::Matchers::Composable
to our BeEvent
matcher class made the test passing. That was quick.
Other cases for composability
Sometimes you expect your domain event to contain data provided by a database. It's hard to expect specific value. You can build an expectation using kind_of
built-in matcher:
expect(domain_event)
.to be_an_event(OrderPlaced).with_data(order_id: kind_of(Integer))
or include
:
expect(domain_event)
.to
be_an_event(OrderPlaced)
.with_data(products: include("Domain Driven Rails Book"))
There a cases where we want to be very precise about domain event data. In such situation, we add strict
matcher.
domain_event = OrderPlaced.new(
data: {
order_id: 42,
net_value: BigDecimal.new("1999.0")
}
)
# this would fail as data contains unexpected net_value
expect(domain_event)
.to be_an_event(OrderPlaced).with_data(order_id: 42).strict
Strictness applies both to domain event data and metadata. If you want to be strict for data, but give more room to metadata, you can go with compound and
expression:
expect(domain_event)
.to(
be_event(OrderPlaced)
.with_data(order_id: 42, net_value: BigDecimal.new("1999.0"))
.strict
.and(an_event(OrderPlaced).with_metadata(timestamp: kind_of(Time)))
It's also a part of RSpec's Composable
mixin, together with or
.
More on that topic
There's a very good post on RSpec blog explaining Composable matchers. It's 4 years old now, but still a good read. Especially if you're curious how RSpec internals work.