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.