Custom RSpec matcher

RSpec ships with reasonable and quite often sufficient matcher set (rspec-expectations).
But once a certain expectation pattern repetition is recognized over several tests, it can make sense to introduce a dedicated custom matcher.
That is how repeating test logic over and over again can be avoided by hiding it behind a meaningful matcher.

Starting point

Let’s assume tests for an ActiveRecord object factory, which have to make sure, that all objects generated by the same factory are duplicates. Duplicates have equal attributes, except id and the timestamps (created_at and updated_at):

require 'rails_helper'

RSpec.describe CookieFactory do
  subject { CookieFactory.build :citrone_waffle }

  context 'when same factory' do
    it 'is a duplicate' do
      attributes = %w(id created_at updated_at)
      subject_attributes  = subject.attributes.except(*value_attributes)
      clone_attributes     = CookieFactory.create(:citrone_waffle).attributes.except(*value_attributes)
      expect(subject_attributes).to eq(clone_attributes)
    end
  end

  context 'when different factory' do
    it 'is not a duplicate' do
      attributes = %w(id created_at updated_at)
      subject_attributes  = subject.attributes.except(*value_attributes)
      clone_attributes     = CookieFactory.create(:chocolate_cookie).attributes.except(*value_attributes)
      expect(subject_attributes).to_not eq(clone_attributes)
    end
  end
end

At least either tests consist of the same testing logic. It makes sense to extract it into a custom matcher.

Custom matcher

Each custom matcher consists of 4 pieces:

  1. the matcher logic
  2. a description
  3. the failure message for the matcher (expect(subject).to match(something))
  4. the failure message for the negated matcher (expect(subject).to_not match(something))

RSpec matchers should be defined with a meaningful name, which is used in the tests themselves and get a block passed. That block receives the assigned argument from the test. However a matcher also could receive multiple arguments, if necessary.
In this exemplary matcher there is an ActiveRecord object expected (expected):

# support/matchers/be_duplicate_of_matcher.rb
require 'rspec/expectations'

RSpec::Matchers.define :be_duplicate_of do |expected|
  excluded_attributes = %w(id created_at updated_at)
  expected_attributes = expected.attributes.except(*excluded_attributes)

  match do |actual|
    actual_attributes = actual.attributes.except(*excluded_attributes)
    actual_attributes == expected_attributes
  end

  description { "is a duplicate of #{expected.inspect}" }
  failure_message { ": is expected to be a duplicate of #{expected.inspect}" }
  failure_message_when_negated {
    ": is expected to not be a duplicate of #{expected.inspect}"
  }
end

The actual comparison takes place in the Spec::Matchers#match block. It receives the expected object (actual). The matcher logic is Inside the block.
The failure message of the particular failing test is defined with failure_message and failure_message_when_negated.

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', 'be_duplicate_of_matcher.rb')

Refactored spec

The matcher testing logic can be reused in any similar test case. Additionally the specification gains readability a lot:

require 'rails_helper'

RSpec.describe CookieFactory do
  subject { CookieFactory.build :citrone_waffle }

  context 'when same factory' do
    it 'is a duplicate' do
      expect(subject).to be_duplicate_of(CookieFactory.create :citrone_waffle)
    end
  end

  context 'when different factory' do
    it 'is not a duplicate' do
      expect(subject).to_not be_duplicate_of(CookieFactory.create :chocolate_cookie)
    end
  end
end

Or even shorter in a macro like notation:

require 'rails_helper'

RSpec.describe CookieFactory do
  subject { CookieFactory.build :citrone_waffle }

  context 'when same factory' do
    it { is_expected.to be_duplicate_of(CookieFactory.create :citrone_waffle) }
  end

  context 'when different factory' do
    it { is_expected.to_not be_duplicate_of(CookieFactory.create :chocolate_cookie) }
  end
end