How to Use Custom RSpec Matchers to Specify Behaviour

Learn how to use custom RSpec matchers to write better, less repetitive tests.

Brought to you by

Semaphore

Introduction

Test code needs to be both run by computers and read by humans. It therefore needs to be well-factored and use the domain language. At the end of this tutorial, you will have seen how you can use use RSpec's custom matchers to write better tests that are DRY, quick to write and easy to understand.

Example: Matching JSON Documents to JsonPath Expressions

Let's say we have an API that responds in a JSON format. We want to assert that sensitive information is omitted from the JSON document for unauthorised users. To do that, we can use the JsonPath library, that provides an Xpath-like language to query JSON documents. We could write such a test as follows:

require 'jsonpath'

context "for unauthorised users" do
  it "omits the team sales figures" do
    get "/api/stats.json"
    json_path = JsonPath.new('$.stats.team.sales')
    expect(json_path.on(response.body)).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.json"
    json_path = JsonPath.new('$.stats.team.sales')
    expect(json_path.on(response.body)).not_to be_empty
  end
end

This example uses RSpec 3.3. The specs are fair enough, but there are 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.json"
    expect(response.body).not_to have_json_path("$.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.json"
    expect(response.body).to have_json_path("$.stats.team.sales")
  end
end

The have_json_path 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's default spec_helper.rb file will load all Ruby files under ./spec/support before running any tests. We can therefore create a new file ./spec/support/matchers/have_json_path.rb, and define our custom matcher there.

An empty matcher looks like this:

RSpec::Matchers.define :have_json_path 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_json_path("..."). This is the place where we implement our custom logic:

require 'jsonpath'

RSpec::Matchers.define :have_json_path do |json_path_expression|
  match do |str|
    JsonPath.new(json_path_expression).on(str).count > 0
  end
end

Note that we can change the block argument names to match our domain. Accepting a string in our match block — rather than a response object — helps keep our matcher generic enough that 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 the following:

expected ""{\"stats\":{ ... }}"" to have json path "$.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 JSON document into the error output is not all that readable. Let's customise the error message:

RSpec::Matchers.define :have_json_path do |json_path_expression|
  match do |str|
    JsonPath.new(json_path_expression).on(str).count > 0
  end

  failure_message do |str|
    "Expected path #{json_path_expression.inspect} to match in:\n" +
      JSON.pretty_generate(JSON.parse(str))
  end

  failure_message_when_negated do |str|
    "Expected path #{json_path_expression.inspect} not to match in:\n" +
      JSON.pretty_generate(JSON.parse(str))
  end
end

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

Expected path "$.stats.team.sales" to match in:
{
  "stats": {
    "player": {
      "scores": 10
    },
    "team": {
      "scores": 500
    }
  }
}

We now see our JSON document pretty-printed, so we can scan it more easily 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, it is easier to first convert it to a Ruby class and 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 HaveJsonPathMatcher
  def initialize(json_path_expression)
    @json_path_expression = json_path_expression
  end

  def matches?(str)
    @str = str
    JsonPath.new(@json_path_expression).on(str).count > 0
  end

  def failure_message
    "Expected path #{@json_path_expression.inspect} to match in:\n" +
      JSON.pretty_generate(JSON.parse(@str))
  end

  def failure_message_when_negated
    "Expected path #{@json_path_expression.inspect} not to match in:\n" +
      JSON.pretty_generate(JSON.parse(@str))
  end
end

This is admittedly more code, and arguably less obvious than the DSL-version — but it is easier to refactor. This matcher is also just a Ruby class, for which you could write tests if you wanted to go really meta.

Refactoring Our Matcher

Let's extract a pretty_json method from the failure_message and failure_message_when_negated methods:

class HaveJsonPathMatcher
  def initialize(json_path_expression)
    @json_path_expression = json_path_expression
  end

  def matches?(str)
    @str = str
    JsonPath.new(@json_path_expression).on(str).count > 0
  end

  def failure_message
    "Expected path #{@json_path_expression.inspect} to match in:\n" +
      pretty_json
  end

  def failure_message_when_negated
    "Expected path #{@json_path_expression.inspect} not to match in:\n" +
      pretty_json
  end

  private

  def pretty_json
    JSON.pretty_generate(JSON.parse(@str))
  end
end

Refactorings like these are simpler to reason about when our object is not clouded by metaprogramming cleverness — even though there's nothing in here that would not have been possible with the DSL.

Introducing a Helper Method

With the class-version of our matcher, we need to write our expectation as follows:

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

After all, RSpec's to method expects to receive a matcher object. The DSL-version looked much better. We can introduce a helper method to make things pretty again:

def have_json_path(json_path_expression)
  HaveJsonPathMatcher.new(json_path_expression)
end

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

So, class-based matchers usually come with helper methods to make specifications readable and hide implementation details from the reader. To make such a helper method available in your RSpec tests, you can combine the class and the method in a module and include that using RSpec's configuration:

# spec/support/matchers/have_json_path.rb
module HaveJsonPathMatcher
  class HaveJsonPathMatcher
    # ...
  end

  def have_json_path(json_path_expression)
    HaveJsonPathMatcher.new(json_path_expression)
  end
end

RSpec.configure do |config|
  config.include HaveJsonPathMatcher
end

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 in 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 JSON, JsonPath expressions and the structure of our JSON document. This would be a fair point, and whenever this particular itch comes up, you could scratch it not with a more specific matcher, but with another helper method around your generic matcher:

def have_json_path(json_path_expression)
  HaveJsonPathMatcher.new(json_path_expression)
end

def include_team_sales_figures
  have_json_path("$.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

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, exercise and verification. If you need more than a single line to write the verification code, it's time to write a custom matcher. You and your colleagues will thank you for it in a few weeks' time.

241eb3a089132c5a0c65e765558a6735
Arjan van der Gaag

Arjan is a thirty-something software developer, historian and all-round geek who enjoys writing Ruby. He is a happily married father-of-one and he likes explaining history trivia to his genial dog — who, frankly, is the only one who'll listen.

on this tutorial so far.
User deleted author {{comment.createdAt}}

Edited on {{comment.updatedAt}}

Cancel

Sign In You must be logged in to comment.