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:
- die Matcherlogik
- eine Beschreibung (description)
- die Fehlernachricht für den Matcher (expect(subject).to match(something))
- 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