In Progress
Unit 1, Lesson 1
In Progress

Rspec Compound Matchers

Video transcript & code

Let's say we're working our way through the Roman Numerals kata, an exercise that asks us to construct a class that convert roman numerals to integers, and vice-versa. We're using RSpec tests to drive our implementation.

As I said, we're partway through implementation of roman numeral parsing. Here are the passing tests that we have so far.

require "rspec/autorun"

class RomanNumeralConverter
  def convert(input)
    case input
    when /I/ then input.size
    when "V" then 5
    when "X" then 10
    when "L" then 50
    when "C" then 100
    when "D" then 500
    when "M" then 1000
    end
  end
end

RSpec.describe RomanNumeralConverter do
  let(:converter) { RomanNumeralConverter.new }

  it "converts I to 1" do
    expect(converter.convert("I")).to eq(1)
  end

  it "converts II to 2" do
    expect(converter.convert("II")).to eq(2)
  end

  it "converts III to 3" do
    expect(converter.convert("III")).to eq(3)
  end

  it "converts V to 5" do
    expect(converter.convert("V")).to eq(5)
  end

  it "converts X to 10" do
    expect(converter.convert("X")).to eq(10)
  end

  it "converts L to 50" do
    expect(converter.convert("L")).to eq(50)
  end

  it "converts C to 100" do
    expect(converter.convert("C")).to eq(100)
  end

  it "converts D to 500" do
    expect(converter.convert("D")).to eq(500)
  end

  it "converts M to 1000" do
    expect(converter.convert("M")).to eq(1000)
  end

end

# >> .........
# >>
# >> Finished in 0.00154 seconds (files took 0.0812 seconds to load)
# >> 9 examples, 0 failures
# >>

We've organized these tests according to conventional advice, which says to have just one assertion per test. Scrolling down these specs, it's hard to escape the feeling that they are over-verbose for what we are actually testing. With examples like these, the docstrings honestly seem superfluous. The examples themselves are pretty self-documenting.

So, let's go ahead and convert this to one big example that incorporates all of the assertions we have so far.

RSpec.describe RomanNumeralConverter do
  let(:converter) { RomanNumeralConverter.new }

  it "converts roman numerals to the correct integers" do
    expect(converter.convert("I")).to eq(1)
    expect(converter.convert("II")).to eq(2)
    expect(converter.convert("III")).to eq(3)
    expect(converter.convert("V")).to eq(5)
    expect(converter.convert("X")).to eq(10)
    expect(converter.convert("L")).to eq(50)
    expect(converter.convert("C")).to eq(100)
    expect(converter.convert("D")).to eq(500)
    expect(converter.convert("M")).to eq(1000)
  end

end

This seems a lot more manageable. But now, let's introduce a regression into the code.

By making the first case match any possible string, we've made it impossible to hit any of the other cases.

When we run the specs again, we can see a failure for the test of the roman numeral "V". But that's the only failure we get. In fact, all of the examples for numerals other than "I" are now broken. But the first failure is hiding all later failures. We can't see the big picture, and so we can't learn anything from the pattern of failures.

require "rspec/autorun"

class RomanNumeralConverter
  def convert(input)
    case input
    when // then input.size
    when "V" then 5
    when "X" then 10
    when "L" then 50
    when "C" then 100
    when "D" then 500
    when "M" then 1000
    end
  end
end

RSpec.describe RomanNumeralConverter do
  let(:converter) { RomanNumeralConverter.new }

  it "converts roman numerals to the correct integers" do
    expect(converter.convert("I")).to eq(1)
    expect(converter.convert("II")).to eq(2)
    expect(converter.convert("III")).to eq(3)
    expect(converter.convert("V")).to eq(5)
    expect(converter.convert("X")).to eq(10)
    expect(converter.convert("L")).to eq(50)
    expect(converter.convert("C")).to eq(100)
    expect(converter.convert("D")).to eq(500)
    expect(converter.convert("M")).to eq(1000)
  end

end

# >> F
# >>
# >> Failures:
# >>
# >>   1) RomanNumeralConverter converts roman numerals to the correct integers
# >>      Failure/Error: $SiB.record_result(24, (expect(converter.convert("V"...
# >>
# >>        expected: 5
# >>             got: 1
# >>
# >>        (compared using ==)
# >>      # xmptmp-in27856TQZ.rb:24:in `block (2 levels) in <main>'
# >>
# >> Finished in 0.00083 seconds (files took 0.09189 seconds to load)
# >> 1 example, 1 failure
# >>
# >> Failed examples:
# >>
# >> rspec  # RomanNumeralConverter converts roman numerals to the correct in...
# >>

That's the biggest drawback of grouping multiple assertions in a single test case. It's the reason that coding standards often specify a single assertion per test.

So the question is: can we have concise examples, but also see multiple failures at once? Here's a spoiler: yes, we can. But first, we need to go on a little side trip.

Let's create a custom matcher for roman numeral conversions. "custom matchers" might sound like a very advanced topic, but they are actually quite easy to define.

We'll start by creating an example of the syntax we want to use. For each example, we want to say something like this:

expect(converter).to convert("I").into(1)

In order to do that, we declare a matcher. We call it :convert. The matcher definition block receives an argument which is the argument to the matcher method, which in our example is "I", the roman numeral to be converted. We'll name this the input.

Inside the definition, we say match, and pass a block to define the match semantics. It receives the object being tested, the converter. In order to determine if this matcher matches, we'll tell the converter to convert the input. Then we'll take the return value and compare it to @expected_output.

What is @expected_output? We haven't defined that yet. But now we will. Our expected_output  comes from a method called into that we chained onto our matcher. We define this by saying chain followed by the name of the chained method. We then pass a block, which will receive the argument to the into method. This argument is the expected_output, which we assign to the @expected_output instance variable. So now we know where that value comes from.

Let's add a failing example, and then run this code. We can see that the code fails, as expected, on the second assertion.

RSpec.describe RomanNumeralConverter do
  # ...

  matcher :convert do |input|
    match {|converter| converter.convert(input) == @expected_output}
    chain :into do |expected_output|
      @expected_output = expected_output
    end
  end

  it "converts roman numerals to the correct integers" do
    expect(converter).to convert("I").into(1)
    expect(converter).to convert("V").into(5)
  end

  # ...

end

# >> F*
# >>
# >> Pending: (Failures listed here are expected and do not affect your suite...
# >>
# >>   1) RomanNumeralConverter converts roman numerals to the correct integers
# >>      # Temporarily skipped with xit
# >>      # xmptmp-in27856GNH.rb:32
# >>
# >> Failures:
# >>
# >>   1) RomanNumeralConverter converts roman numerals to the correct integers
# >>      Failure/Error: $SiB.record_result(29, (expect(converter).to convert...
# >>        expected #<RomanNumeralConverter:0x007f0abc1052e8> to convert "V"
# >>      # xmptmp-in27856GNH.rb:29:in `block (2 levels) in <main>'
# >>
# >> Finished in 0.00054 seconds (files took 0.08532 seconds to load)
# >> 2 examples, 1 failure, 1 pending
# >>
# >> Failed examples:
# >>
# >> rspec  # RomanNumeralConverter converts roman numerals to the correct in...
# >>

Unfortunately, the failure message kind of sucks. Let's improve that. We go back to the custom matcher definiton. First, we modify the match block to capture the @actual result for later. Then we add a failure_message definition. We have it use the input, expected output, and actual output, to generate a readable failure message. We test it out, and it looks a lot better.

RSpec.describe RomanNumeralConverter do
  # ...
  matcher :convert do |input|
    match {|converter|
      @actual = converter.convert(input)
      @actual == @expected_output
    }
    chain :into do |expected_output|
      @expected_output = expected_output
    end
    failure_message do
      "expected '#{input}' to be converted to #{@expected_output} but got #{@actual}"
    end
  end

  it "converts roman numerals to the correct integers" do
    expect(converter).to convert("I").into(1)
    expect(converter).to convert("V").into(5)
  end

  # ...
end

# >> F*
# >>
# >> Pending: (Failures listed here are expected and do not affect your suite...
# >>
# >>   1) RomanNumeralConverter converts roman numerals to the correct integers
# >>      # Temporarily skipped with xit
# >>      # xmptmp-in27856IBt.rb:35
# >>
# >> Failures:
# >>
# >>   1) RomanNumeralConverter converts roman numerals to the correct integers
# >>      Failure/Error: $SiB.record_result(32, (expect(converter).to convert...
# >>        expected 'V' to be converted to 5 but got 1
# >>      # xmptmp-in27856IBt.rb:32:in `block (2 levels) in <main>'
# >>
# >> Finished in 0.00052 seconds (files took 0.08428 seconds to load)
# >> 2 examples, 1 failure, 1 pending
# >>
# >> Failed examples:
# >>
# >> rspec  # RomanNumeralConverter converts roman numerals to the correct in...
# >>

So now we have a nice pretty custom matcher. But how does this help us get multiple failures at once?

For that, we're going to use a feature added to RSpec in version 3, called compound expectations. Instead of spelling out a full example for each scenario, we say "expect converter to convert 'I' into 1" and then follow that with an & operator. On the next line, we just say "convert 'V' into 5".

Instead of re-establishing the object we are making assertions about each time, we are retaining the same test subject, and making a series of assertions about it.

RSpec.describe RomanNumeralConverter do
  # ...
  it "converts roman numerals to the correct integers" do
    expect(converter).to convert("I").into(1) &
                         convert("V").into(5)
  end
  #...
end

We run this, and get the same result as before. Now let's go ahead and add the rest of our original assertions.

RSpec.describe RomanNumeralConverter do
  # ...
  it "converts roman numerals to the correct integers" do
    expect(converter).to convert("I").into(1) &
                         convert("II").into(2) &
                         convert("III").into(3) &
                         convert("V").into(5) &
                         convert("X").into(10) &
                         convert("L").into(50) &
                         convert("C").into(100) &
                         convert("D").into(500) &
                         convert("M").into(1000)
  end
end

When we run this, we can see the failure messages for all of the failed assertions concatenated together, all at once.

 $ ruby kata.rb
F

Failures:

  1) RomanNumeralConverter converts roman numerals to the correct integers
     Failure/Error: expect(converter).to convert("I").into(1) &
       expected 'V' to be converted to 5 but got 1 and expected 'X' to be converted to 10 but got 1 and expected 'L' to be converted to 50 but got 1 and expected 'C' to be converted to 100 but got 1 and expected 'D' to be converted to 500 but got 1 and expected 'M' to be converted to 1000 but got 1
     # kata.rb:32:in `block (2 levels) in <main>'

Finished in 0.00333 seconds (files took 0.08032 seconds to load)
1 example, 1 failure

Failed examples:

rspec  # RomanNumeralConverter converts roman numerals to the correct integers

We now have a concise syntax for specifying many different variations on the theme of converting roman numerals into integers. And we when we break the code, we can see every failure at once.

There's a third advantage here that we haven't talked about yet. Our RomanNumeralConverter takes no appreciable time to instantiate. But if we were working on a test where the object under test was heavy-weight and took a long time to create, our compound test would be a lot faster than repeatedly instantiating an object and making an assertion on it, over and over again.

And that's it for today. Happy hacking!

Responses