Eigener RSpec Matcher

RSpec kommt mit einem sinnvollen und meistens auch ausreichenden Satz an Matchern (rspec-expectations).
Sobald ein bestimmtes Erwartungsmuster sich aber über verschiedene Tests wiederholt, kann es Sinn machen, dedizierte Custom Matcher einzuführen.
Dadurch werden Wiederholungen vermieden und Test Logik hinter einem aussagekräftigen Matcher versteckt.

Ausgangssituation

Angenommen, Tests für eine Factory von ActiveRecord Objekten sollen sicher stellen, dass die Objekte der gleichen Factory Duplikate sind. Duplikate haben gleiche Attribute, außer der id und den Zeitstempeln (created_at und 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

Schon diese beiden Tests haben die gleiche Testlogik. Es macht Sinn, die Logik in einen eigenen Matcher zu extrahieren.

Eigener Matcher

Jede Matcherdefinition besteht aus 4 Teilen:

  1. die Matcherlogik
  2. eine Beschreibung (description)
  3. die Fehlernachricht für den Matcher (expect(subject).to match(something))
  4. die Fehlernachricht für den negierten Matcher (expect(subject).to_not match(something))

RSpec Matchers werden definiert mit einem aussagekräftigen Namen, der dann in den Tests verwendet wird und bekommt einen Block. Dieser Block erhält den aus dem Test übergebenen Parameter. Es können aber auch mehrere Argumente an den Matcher übergeben werden, wenn es notwendig ist.
In diesem Beispiel Matcher wird ein ActiveRecord Objekt erwartet (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

Der eigentliche Vergleich befindet sich Spec::Matchers#match Block. Der bekommt das erwartete Objekt (actual) übergeben. Innerhalb des Blocks befindet sich dann die Vergleichslogik.
Mit failure_message und failure_message_when_negated wird die Fehlernachricht definiert, wenn der jeweilige Test fehl schlägt.

Spec Helper

Wenn der Custom Matcher nicht explizit in jeder Spec eingebunden werden soll, kann der Matcher auch global im Spec Helper geladen werden:

# spec/spec_helper.rb
require File.join(File.dirname(__FILE__), 'support', 'be_duplicate_of_matcher.rb')

Refactored Spec

Die Testlogik des Matchers kann in ähnlich gelagerten Testfällen wiederverwendet werden. Aber zusätzlich gewinnt die Spezifikation selber sehr an Lesbarkeit:

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

Oder in Macro-ähnlicher Schreibweise:

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