Custom negated matchers for RSpec

Posted on August 31, 2017 by Robin Neumann

Keeping RSpec examples as atomic and lean as possible is a good rule of thumb. Nevertheless, sometimes it makes sense to combine multiple expectations into one test case - for example when the evaluation of the expression you want to test is “expensive” in some sense.

Let us study the Ruby class Pirate and add some specs for it:

class Pirate
  attr_accessor :mood

  def initialize
    @mood = 10
  end

  def insult(other_pirate)
    self.mood += 1
    other_pirate.mood -= 1
  end
end

When a pirate instance is insulting another pirate, this is affecting the fighter’s moods:

guybrush, le_chuck = Pirate.new, Pirate.new

guybrush.mood # => 10
le_chuck.mood # => 10

guybrush.insult(le_chuck)

guybrush.mood # => 11
le_chuck.mood # => 9

Let us turn this into proper test cases with RSpec. One way of expressing this is writing down two seperate test cases:

RSpec.describe Pirate do
  let(:guybrush) { Pirate.new }
  let(:le_chuck) { Pirate.new }

  it { expect { guybrush.insult(le_chuck) }.to change { le_chuck.mood }.by(-1) }
  it { expect { guybrush.insult(le_chuck) }.to change { guybrush.mood }.by(1) }
end

But now let’s assume we really want to combine this into a single example where the expression guybrush.insult(le_chuck) is only evaluated once. One way to combine the expectations is wrapping one test into another, like the composition of functions in mathematics:

RSpec.describe Pirate do
  let(:guybrush) { Pirate.new }
  let(:le_chuck) { Pirate.new }

  it "affects the fighting pirate's moods" do
    expect { 
      expect { guybrush.insult(le_chuck) }.to change { le_chuck.mood }.by(-1)
    }.to change { guybrush.mood }.by(1)
  end
end

Unfortunately the probability is high that this strategy results in messy and unreadable test code as soon as you combine 3 or more expectations.

Better (imho) is using the neat method and that RSpec provides:

RSpec.describe Pirate do
  let(:guybrush) { Pirate.new }
  let(:le_chuck) { Pirate.new }

  it "affects the fighting pirate's moods" do
    expect { guybrush.insult le_chuck }
      .to change { le_chuck.mood }.by(-1)
      .and change { guybrush.mood }.by(1)
  end
end

This is really elegant, but problems arise once you want to include tests for the fact that the corresponding expression guybrush.insult(le_chuck) does not affect some other object.

This is a priori not possible with the plain and-method above. But again RSpec has a really nice built-in solution for this problem: Definition of custom negated matchers! It’s pretty simple:

# This could live in your spec_helper.rb or wherever you configure RSpec
RSpec::Matchers.define_negated_matcher :not_change, :change

Afterwards you can happily use the operator not_change, the negated operator to change as we have defined above:

RSpec.describe Pirate do
  let(:guybrush) { Pirate.new }
  let(:le_chuck) { Pirate.new }
  let(:otis)     { Pirate.new }
  let(:carla)    { Pirate.new }

  it "affects the fighting pirate's moods" do
    expect { guybrush.insult le_chuck }
      .to change { le_chuck.mood }.by(-1)
      .and change { guybrush.mood }.by(1)
      .and not_change { otis.mood }
      .and not_change { carla.mood }
  end
end

Give credit where credit is due: I found this solution within a StackOverflow answer. Thanks to the author!

comments powered by Disqus