RSpec Custom Matchers: A Deep Dive

What’s a Matcher?

expect(dan.current_zipcode).to eq(10002)

In the line above, the matcher is the method eq because RSpec will check to see if the value of dan.current_zipcode matches 10002.

Let’s say that this was a line you wrote for as part of a test for a location tracking app. You want your code to be as clear as possible for collaborators or even for your future self.

Wouldn’t it sound even better if you could write this?

expect(dan).to be_in_zipcode(10002)

This requires be_in_zipcode to be a method in RSpec’s matcher library. I’m going to briefly explain how to create this new matcher, but for alternate explanations, check out the Rspec Github Wiki and Chapter 7 of Aaron Sumner’s Everyday Rails Testing with RSpec.

My intention is not to write another Matcher tutorial (though this might inevitably happen). After reading Sumner’s chapter, I could imagine how matchers were created but ultimately, I felt a bit uncertain. With a little bit of Pry magic and doc-digging, I think I’ve finally put most of the puzzle together which I’m looking forward to sharing below! Constructive feedback and deeper insights are always welcome. :)

Match-Maker, Match-Maker, Make Me a Match!

Let’s write some code so that we can use the method be_in_zipcode(10002) from above.

General syntax at bare minimum first:

syntax.rb
1
2
3
4
5
6
7
8
9
10
RSpec::Matchers.define :new_matcher_name do |expected|
  match do |actual|
    # Lines of code you want this matcher to run
  end

  #optional description and failure message definition blocks
end

# From the describe block:
# expect(actual).to matcher_method(expected)

NOTE: Store your matcher files in spec/support/matchers/ and require them in spec/spec_helper.rb with the following line:

Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f}

Now onto our example:

be_in_zipcode.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RSpec::Matchers.define :be_in_zipcode do |zipcode|
  match do |friend|
    friend.in_zipcode?(zipcode)
  end

  # Optional failure messages
  failure_message_for_should do |actual|
    "expected friend to be in zipcode"
  end

  failure_message_for_should_not do |actual|
    "expected friend not to be in zipcode"
  end

  # Optional method description
  description do
    "checks user's current zipcode"
  end
end

Again, the failure messages and description are optional blocks. The match block is obviously mandatory. So now that we have our new matcher, let’s check out what’s going on behind the scenes.

Under the hood

RSpec::Matchers.define :be_in_zipcode do |zipcode|

The define method, extended from the DSL module, does the following:

define.rb RSpec::Matchers::DSL#define
1
2
3
4
5
6
7
8
9
10
def define(name, &declarations)
  matcher_template = RSpec::Matchers::DSL::Matcher.new(name, &declarations)
  define_method name do |*expected|    # => expected for us is the var zipcode
    matcher = matcher_template.for_expected(*expected)
    matcher.matcher_execution_context = @matcher_execution_context ||= self
    matcher
  end
end

# name = be_in_zipcode

The second line setting matcher_template initializes an RSpec::Matchers::DSL::Matcher object which stores the method name and the declarations block.

define_method then creates the be_in_zipcode method. The next line’s for_expected(*expected) method sets the zipcode as the expected value and your failure messages/description from the matcher template in the second line. The messages and description are stored in the @messages attribute hash.

The next line I’m not sure about since matcher_execution_context refers to an attribute accessor. I don’t see it in the Matcher module either. Any insight here would be great!

Moving onto the last piece: the match block.

1
2
3
  match do |friend|
    friend.in_zipcode?(zipcode)
  end

RSpec::Match#match stores the given block in @match_block, so essentially, @match_block = friend.in_zipcode?(zipcode).

Later, when you run your specs with the new matcher, the match_block will get called from the method RSpec::Match#matches?.

matches?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def matches?(actual)
  @actual = actual
  if @expected_exception
    begin
      instance_exec(actual, &@match_block)
      true
    rescue @expected_exception
      false
    end
  else
    begin
      instance_exec(actual, &@match_block)
    rescue Spec::Expectations::ExpectationNotMetError
      false
    end
  end
end

And that’s it!

So if I were to run the original line, expect(dan).to be_in_zipcode(10002), the following would happen:

  1. expect(dan) returns an RSpec::Expectations::ExpectationTarget object storing the object dan. Specifically: => #<RSpec::Expectations::ExpectationTarget:0x007fbf511e4c78 @target=#<User id: 1, name: dan>>
  2. the to method passes in be_in_zipcode(10002) and calls a method called handle_matcher
  3. … which checks if be_in_zipcode has been defined as a matcher.
  4. Since we’ve defined be_in_zipcode, matches? gets called our block, which we saw above.
  5. handle_matcher then figures out how to respond, given the messages you passed in when you defined the new matcher.

That’s our deep dive into RSpec’s Custom Matchers. Again, feedback always welcome. Hope you enjoyed the complexity as much as I did! :)

Comments