Flexible Matcher Klasse

Dank rspec-expectations, ist es ziemlich leicht, in RSpec einfache eigene Matcher zu definieren. Die Matcher können sogar in einer sehr kurzen Macroartigen Notation verwendet werden.
Wenn Matcher Logik allerdings etwas komplexer wird, oder aber auch für andere Ruby Testumgebungen (z.B. MiniTest) gültig sein soll, muss eine eigene Matcherklasse gebaut werden.

Eine Klasse und seine Spec

Zunächst die Ausgangssituation: eine Product Spec, die einen Alias testet:

# 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

Das Produkt hat einen Namen (name) und den Alias to_s:

class Product
  attr_reader :name
  alias_method :name, :to_s

  def initialize(name)
    @name = name
  end
end

PORO Matcher Klasse

Die Matcher Klasse ist ein PORO, das mindestens 4 Methoden implementiert:

  1. #description: Beschreibung des Matchers
  2. #failure_message: Nachricht, wenn der Test fehl schlägt (expect(subject).to match(something))
  3. #negative_failure_message: Nachricht, wenn der negierte Test fehl schlägt (expect(subject).to_not match(something))
  4. #matches?: der eigentliche Vergleich
# 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

Der Matcher profitiert von einer einfachen Technik. Setter Methoden (with) werden verkettbar, indem sie das Matcher Objekt zurückgeben.
Erst am Ende der Methodenkette wird automatisch matches? aufgerufen.
Eine Helper Funktion muss das Matcher Objekt instantiieren:

# support/matchers/alias_method_matcher.rb
def alias_method(method)
  AliasMethodMatcher.new(method)
end

Die aktuelle Implementierung von Shoulda Matchers verwendet den selben Ansatz.
Die gleiche Spec, aber mit dem 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

Der Helper alias_method gibt eine Instanz des AliasMethodMatcher zurück. An das Matcher Objekt wird with mit dem Argument to_s gesendet. Diese Methode gibt ebenfalls das Matcher Objekt (sich selbst) zurück. Em Ende des it Blocks wird automatisch matches? an das Matches Objekt gesendet und der Vergleich ausgeführt.

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

Vielen Dank an Arthus Shagall, für hilfreiche Hinweise, wie das Beispiel verbessert werden kann.