Due to rspec-expectations, it is dead easy to define simple custom matcher in RSpec. The matchers even can be used in a concise macro like notation.
In the case the matcher logic complexity increases there is the approach to create a custom matcher class. That also applies for custom matchers that have to be available in other Ruby testing environments like MiniTest.
A class and its spec
First of all the starting point: a Product spec, which tests an alias:
# spec/product_spec.rb
require 'rails_helper'
RSpec.describe Product do
subject { Product.new 'Car' }
describe '#to_s' do
it 'returns the name' do
expect(subject.to_s).to eq(subject.name)
end
end
end
The product has a name and the alias to_s:
class Product
attr_reader :name
alias_method :name, :to_s
def initialize(name)
@name = name
end
end
PORO matcher class
The matcher class is a PORO with at least 4 implemented methods:
- #description: the matcher description
- #failure_message: message for failing test (expect(subject).to match(something))
- #negative_failure_message: message for failing negated test (expect(subject).to_not match(something))
- #matches?: the actual comparison
# support/matchers/alias_method_matcher.rb
class AliasMethodMatcher
def initialize(original_method)
@original_method = original_method
end
def with(alias_method)
@alias_method = alias_method
self
end
def matches?(subject)
raise 'No aliased method provided' if @alias_method.nil?
subject.method(@original_method) == subject.method(@alias_method)
end
def failure_message
"Expected ##{@original_method} to be aliased by ##{@alias_method}, but it is not"
end
def failure_message_when_negated
"Expected ##{@original_method} to not be aliased by ##{@alias_method}, but it is"
end
def description
"#{@alias_method} should be an alias of method #{@original_method}"
end
end
The matcher benefits from a simple technique. Setter methods (with) are made chainable by returning the matcher object itself.
The comparing matches? is called at the very end of the chain automatically.
A helper function has to return the instantiated matcher object:
# support/matchers/alias_method_matcher.rb
def alias_method(method)
AliasMethodMatcher.new(method)
end
The current Shoulda Matchers implementation also uses the same approach.
The same spec, but with the matcher:
# spec/product_spec.rb
require 'rails_helper'
RSpec.describe Product do
subject { Product.new 'Car' }
it { is_expected.to alias_method(:name).with(:to_s) }
end
The helper alias_method returns an instance of AliasMethodMatcher. The matcher object receives the message with with the argument to_s. This method also returns the matcher object (self). At the end of the it block, matches? is sent to the matcher object automatically and the comparison takes place.
The spec helper
In the case the custom matcher should not be required explicitly in each spec, it can be included globally in the spec helper:
# spec/spec_helper.rb
require File.join(File.dirname(__FILE__), 'support', 'alias_method_matcher.rb')
Many thanks to Arthur Shagall, who gave hints, how to improve the example.