← back to all talks and articles

Writing custom RSpec matchers

Test code is code like any other code, which needs to be maintained. It therefore needs to be readable and DRY. With RSpec, writing custom matchers can help you write better tests that are both easier to read and easier to write.

Update: an updated version of this article was published as a Semaphore community tutorial, titled How to Use Custom RSpec Matchers to Specify Behaviour.

Example: matching XML to Xpath expression

Let’s say we have an API that responds in an XML format. We want to assert that sensitive information is omitted from the XML for unauthorised users. We could write such a test like so:

context "for unauthorised users" do
  it "omits the team sales figures" do
    get "/api/stats.xml"
    xml_document = Nokogiri::XML(response.body)
    expect(xml_document.xpath("/stats/team/sales")).to be_empty
  end
end

context "for authorised users" do
  before do
    login_in_as users(:authorised_user)
  end

  it "includes the team sales figures" do
    get "/api/stats.xml"
    xml_document = Nokogiri::XML(response.body)
    expect(xml_document.xpath("/stats/team/sales")).not_to be_empty
  end
end

Fair enough, but there’s two different levels of abstraction in play here: making the high-level assertions about the behaviour of our API; and the low-level implementation of verifying such an assertion.

Testing with custom matchers

Custom RSpec matchers can help with this problem. We define our own domain-specific assertions and use them to compose readable specifications. For example, here’s what a specification using such a custom matcher might look like:

context "for unauthorised users" do
  it "omits the team sales figures" do
    get "/api/stats.xml"
    expect(response).not_to have_xpath("/stats/team/sales")
  end
end

context "for authorised users" do
  before do
		login_in_as users(:authorised_user)
  end

  it "includes the team sales figures" do
    get "/api/stats.xml"
    expect(response).to have_xpath("/stats/team/sales")
  end
end

The have_xpath matcher is what we will implement next. There are two ways to implement matchers: using RSpec’s matcher DSL, and writing a Ruby class. We’ll look at both in turn.

Using RSpec’s matcher DSL

RSpec will, by default, load all Ruby files under ./spec/support before running any tests. We can therefore create a new file ./spec/support/matchers/have_xml.rb and define our custom matcher there.

An empty matcher looks like this:

RSpec::Matcher.define :have_xpath do |expected|
  match do |actual|
    # return true or false here
  end
end

It all boils down to coming up with a true or false response to indicate whether the test passed or failed. The match block will be called with the “actual” value as an argument — this is response.body in expect(response.body).to have_xpath("…"). This is the place where we implement our custom logic:

RSpec::Matcher.define :have_xpath do |xpath|
  match do |str|
    Nokogiri::XML(str).xpath(xpath).any?
  end
end

Note that we can change the block argument names to match our domain. We use Nokogiri to make a simple xpath assertion for the string we’re given. I want to stress that accepting a string in our match block rather than a response object helps keep our matcher generic enough so we can re-use it later.

Customising our matcher

Our matcher is basically ready for use, but we can do better. Let’s see what happens when a test using this matcher fails. RSpec will report:

expected "<stats><user>…</user></stats>" to have xpath "/stats/team/sales"

RSpec dumps the actual and expected values and combines them with the name of our matcher to create a generic error message. It’s not too bad, but dumping the entire XML string into the error output is not super readable. Let’s customise the error message:

RSpec::Matcher.define :have_xpath do |xpath|
  match do |str|
    Nokogiri::XML(str).xpath(xpath).any?
  end

  failure_message do |str|
    "Expected xpath #{xpath.inspect} to match in:\n" + Nokogiri::XML(str).to_xml(indent: 2)
  end

  failure_message_when_negated do |str|
    "Expected xpath #{xpath.inspect} not to match in:\n" + Nokogiri::XML(str).to_xml(indent: 2)
  end
end

Using failure_message and failure_message_when_negated we can customise the error message so it now reads as:

Expected xpath "/stats/team/sales" to match in:
<?xml version="1.0"?>
<stats>
  <user>…</user>
</stats>

We now see our XML document pretty-printed so we can more easily scan it to see what’s wrong.

Converting to a Plain Old Ruby Object

Our matcher is quite useful, but we could make it neater. To do so, I find it easier to convert it to a Ruby class an bypass the matcher DSL altogether. For anything non-trivial, it is nice to just deal with plain Ruby.

Matchers can be written as plain old Ruby objects, as long as they conform to a specific API — methods named like the blocks in our previous example. We could write the above matcher as a class as follows:

class HaveXpath
  def initialize(xpath)
    @xpath = xpath
  end

  def matches?(str)
    @str = str
    Nokogiri::XML(@str).xpath(@xpath).any?
  end

  def failure_message
    "Expected xpath #{@xpath.inspect} to match in:\n" + Nokogiri::XML(@str).to_xml(indent: 2)
  end

  def failure_message_when_negated
    "Expected xpath #{@xpath.inspect} not to match in:\n" + Nokogiri::XML(@str).to_xml(indent: 2)
  end
end

This is admittedly more code, and arguably less obvious than the DSL-version — but I do find it easier to refactor.

Refactoring our matcher

Let’s extract a xml_document method:

class HaveXpath
  def initialize(xpath)
    @xpath = xpath
  end

  def matches?(str)
    @str = str
    xml_document.xpath(@xpath).any?
  end

  def failure_message
    "Expected xpath #{@xpath.inspect} to match in:\n" + xml_document.to_xml(indent: 2)
  end

  def failure_message_when_negated
    "Expected xpath #{@xpath.inspect} not to match in:\n" + xml_document.to_xml(indent: 2)
  end

  private

  def xml_document
    @xml_document ||= Nokogiri::XML(@str)
  end
end

Now we can also extract a pretty_printed_xml method:

class HaveXpath
  def initialize(xpath)
    @xpath = xpath
  end

  def matches?(str)
    @str = str
    xml_document.xpath(@xpath).any?
  end

  def failure_message
    "Expected xpath #{@xpath.inspect} to match in:\n" + pretty_printed_xml
  end

  def failure_message_when_negated
    "Expected xpath #{@xpath.inspect} not to match in:\n" + pretty_printed_xml
  end

  private

  def pretty_printed_xml
    xml_document.to_xml(indent: 2)
  end

  def xml_document
    @xml_document ||= Nokogiri::XML(@str)
  end
end

I find these refactorings simpler to reason about when our object is not clouded by metaprogramming cleverness, although there’s nothing in here that would not have been possible with the DSL.

Introducing a helper method

Note that with the class-version of our matcher, we need to write our exception like so:

expect(response.body).to HaveXpath.new("/stats/team/sales")

Which is not as nice as with our DSL-version. We can introduce a helper method to make things pretty again:

def have_xpath(*args)
  HaveXpath.new(*args)
end

expect(response.body).to have_xpath("/stats/team/sales")

So, class-based matchers usually come with helper methods to make our specifications readable and hide implementation details from the reader.

This example has only demonstrated the basics of RSpec matchers. You can find more information on defining fluent, chained matchers, diffable matchers and accepting blocks as arguments n the RSpec documentation.

Re-evaluating levels of abstraction

You might argue that this matcher is not quite high-level enough to actually model our domain. The matcher deals with XML, Xpath expressions and the structure of our XML document. This would be a fair point, and I’d argue that whenever this particular itch comes up, you could scratch it not with a more generic matcher, but with another helper method:

def include_team_sales_figures
  have_xpath("/stats/team/sales")
end

expect(response.body).to include_team_sales_figures

This should demonstrate the difference between a generic, re-usable matcher to hide implementation details from the reader of the code; and modelling your domain language so you can write human-friendly specifications.

When to write custom matchers

I believe you should start writing custom matchers as soon as possible in a project. This helps you build a suite of easily re-usable matchers that the entire team can use. To get into this habit, try to limit yourself to a maximum of three lines per test: setup, excercise and verification. If you need more than a single line to write the verification code, you should write a custom matcher.

Arjan van der Gaag

Arjan van der Gaag

A thirtysomething software developer, historian and all-round geek. This is his blog about Ruby, Rails, Javascript, Git, CSS, software and the web. Back to all talks and articles?

Discuss

You cannot leave comments on my site, but you can always tweet questions or comments at me: @avdgaag.