Custom Expectation Matchers in RSpec

We’ve been using rspec on a couple of projects. There’s no question that it has made our code cleaner and more DRY. Best of all, it has been easy to make our tests more DRY while giving us better error messages at the same time.

This example will just barely scratch the surface of what you can do with custom expectation matchers. We’ll dig in a little deeper down the road. Here’s a sample spec for a basic test for a contact model in an application we’re building. This uses the newer rspec syntax, and should be relatively easy to read

def new_contact(options={}) 
  Contact.new({:email=>"mike@example.com",:name=>"Mike Mangino"}.merge(options))
end

describe "A new contact" do

  it "Should create when valid" do
    new_contact.should be_valid
    new_contact.save.should be_true
  end

  it "Should require an email address" do
    contact=new_contact(:email=>nil)
    new_contact.should_not be_valid
    new_contact.errors.on(:email).should_not be_nil
  end

end

That’s enough code to do a basic test of the creation of a contact. It runs, and works, but it isn’t very user friendly. For instance, if you missed a required field in new_contact, you simply get an error message saying that valid? should be true, but isn’t. This message comes from the default rspec expectation matcher, where if it finds a method called be_something, it sends something? to the caller and checks the result. This is slick in a lot of ways because it lets you say things like job.should be_active without having to spend a ton of time writing custom assertions. Unfortunately, we miss the detail you get from assert_valid.

Luckily for us, we can fix this. RSpec provides an interface for building custom matchers, that let us give our own descriptions and error messages. The interface is quite simple, and consists of just a view methods. Here’s an example implementation of a matcher to replace assert_valid?

class BeValid
  def initialize
    
  end
  
  def matches?(model)
    @model=model
    return @model.valid?
  end
  
  def description
    "be valid"
  end
  
  def failure_message
    " expected to be valid, but had the following errors: #{@model.errors.full_messages.to_sentence}"
  end
  
  def negative_failure_message
    " expected to not be valid, but was (missing validation?)"
  end
end

def be_valid
  BeValid.new
end

There are two main parts to the custom matcher. The first is a class the implements matches?, description, failure_message and optionally negative_failure_message. Matches? tells rspec whether your test passed, and the other fields are there to give descriptive messages. If you omit negative_failure_message, your code will still work, but you will not be able to say contact.should_not be_valid

The second part of the matcher is the be_valid function. This overrides the default behavior, and returns an instance of our class to be used by rspec. Once you have both of these things, you will now get much better messages from validation failures.

While that helps the error message, the syntax for the error check is a little rough. Wouldn’t it be nice if we could just say contact.should have_error_on(:email)? We’ve got a matcher for that too!

class HaveErrorOn
  def initialize(field)
    @field=field
  end
  
  def matches?(model)
    @model=model
    @model.valid?
    !@model.errors.on(@field).nil?
  end
  
  def description
    "have error on #{@field}"
  end
  
  def failure_message
    " expected to have error on #{@field} but doesn't"
  end
end

def have_error_on(field)
  HaveErrorOn.new(field)
end

This example looks very similar to the previous example. The only real difference is that the initialize method takes a parameter. That is the parameter passed to have_error_on.

The more testing you do, the bigger your library of these types of matchers. We probably have 20 or 30, including some that are very specific to our applications. They make life a lot easier, and help keep your tests DRY.