No More Seat Costs: Semaphore Plans Just Got Better!

    21 Jul 2015 · Software Engineering

    How to Use Custom RSpec Matchers to Specify Behaviour

    8 min read
    Contents

    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.

    P.S. Would you like to learn how to build sustainable Rails apps and ship more often? We’ve recently published an ebook covering just that — “Rails Testing Handbook”. Learn more and download a free copy.

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Avatar
    Writen by:
    Dad, historian, developer, team leader and all-round geek. I help companies deliver successful software using lean concepts, self-organisation and emoji.