RSpecのカスタムマッチャーを利用して、複数の要素のアサーションを一つにまとめることがあります。
何に対してマッチさせたいのかをより説明的に書くことが出来、複雑なアサーションを使い回すことができて便利です。
RSpec::Matchers.define :create_user_list do |*usernames|
match do |actual|
actual.all? { |u| u.is_a?(User) } &&
actual.all?(&:persisted)
actual.map(&:name).sort == usernames.sort
end
end
まあ、これぐらいの内容だったらそんなに問題になりません。
しかし、もうちょっとやる事が複雑になると、このマッチャーのどこでどう失敗したのかを細かく知りたくなります。
とは言え、自分でfailure_message
を定義して色々とdiffを出したりするのは面倒です。
一方で、最近のRSpecの組み込みマッチャーは組み合わせが可能になっていて、失敗時にはそれなりに分かり易い情報を出力してくれます。
(本当はpower assertが欲しいですが)
組込みのマッチャーを組み合わせて利用し、そのfailure_message
を再利用できれば、手軽により分かりやすいエラーメッセージが得られそうです。
というわけで実現する方法が無いかRSpecの中を調べてみました。
RSpecのマッチャ処理はrspec-expectationsに書かれています。
# lib/rspec/expectations/expectation_target.rb
# ...
# Runs the given expectation, passing if `matcher` returns true.
# @example
# expect(value).to eq(5)
# expect { perform }.to raise_error
# @param [Matcher]
# matcher
# @param [String or Proc] message optional message to display when the expectation fails
# @return [Boolean] true if the expectation succeeds (else raises)
# @see RSpec::Matchers
def to(matcher=nil, message=nil, &block)
prevent_operator_matchers(:to) unless matcher
RSpec::Expectations::PositiveExpectationHandler.handle_matcher(@target, matcher, message, &block)
end
# ...
# lib/rspec/expectations/handler.rb
# ...
def self.handle_failure(matcher, message, failure_message_method)
message = message.call if message.respond_to?(:call)
message ||= matcher.__send__(failure_message_method)
if matcher.respond_to?(:diffable?) && matcher.diffable?
::RSpec::Expectations.fail_with message, matcher.expected, matcher.actual
else
::RSpec::Expectations.fail_with message
end
end
end
# @private
class PositiveExpectationHandler
def self.handle_matcher(actual, initial_matcher, message=nil, &block)
matcher = ExpectationHelper.setup(self, initial_matcher, message)
return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) unless initial_matcher
matcher.matches?(actual, &block) || ExpectationHelper.handle_failure(matcher, message, :failure_message)
end
# ...
# lib/rspec/expectations/fail_with.rb
# ...
# Raises an RSpec::Expectations::ExpectationNotMetError with message.
# @param [String] message
# @param [Object] expected
# @param [Object] actual
#
# Adds a diff to the failure message when `expected` and `actual` are
# both present.
def fail_with(message, expected=nil, actual=nil)
unless message
raise ArgumentError, "Failure message is nil. Does your matcher define the " \
"appropriate failure_message[_when_negated] method to return a string?"
end
diff = differ.diff(actual, expected)
message = "#{message}\nDiff:#{diff}" unless diff.empty?
raise RSpec::Expectations::ExpectationNotMetError, message
end
# ...
大体の流れを追っかけるとこんな感じです。
つまり、マッチャークラスで定義された#matches?
がfalseを返すと、differという奴がいい感じにdiffを取ってメッセージに組み込んだ上で、RSpec::Expectations::ExpectationNotMetError
という例外を起こすようです。
ここから先はrspec-coreのformatter辺りの仕事になります。
なので、カスタムマッチャーの中で普通にRSpecのアサーション処理を行い、RSpec::Expectations::ExpectationNotMetError
例外を引っ掛けて例外オブジェクトからメッセージを引っ込ぬいてfailure_message
に利用してしまえば狙い通りのことができそうです。
具体的には以下のように書きます。
RSpec::Matchers.define :create_user_list do |*usernames|
match do |actual|
begin
expect(actual).to all(be_a(User)).and all(be_persisted)
expect(actual.map(&:name)).to contain_exactly(usernames)
rescue RSpec::Expectations::ExpectationNotMetError => e
failure_position = e.backtrace.find {|str| str =~ /#{File.expand_path(__FILE__)}/}
@failure_message = "#{e.message}\nat #{failure_position}"
false
end
end
failure_message do
@failure_message
end
end
これで、説明的な名前のカスタムマッチャーを利用しつつ、RSpec3のコンポーザブルマッチャやエラーメッセージを利用することができます。
ついでにbegin rescue
が邪魔なので、書き方を提携化したDSLも用意してみた。モンキーパッチだけど。
module RSpec::Matchers::DSL::Macros
def match_with_builtin(&match_block)
define_user_override(:matches?, match_block) do |actual|
begin
@actual = actual
super(*actual_arg_for(match_block))
rescue RSpec::Expectations::ExpectationNotMetError => e
failure_position = e.backtrace.find {|str| str =~ /#{File.expand_path(__FILE__)}/}
@failure_message = "#{e.message}\nat #{failure_position}"
false
end
end
failure_message { @failure_message }
end
end
これをspec/supportsの下に置いておけば、match
の代わりにmatch_with_builtin
を使うだけで書ける。