20
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RSpecのカスタムマッチャーに組込みマッチャーのfailure_messageを再利用する+マッチャ処理の大まかな流れについて

Last updated at Posted at 2014-08-08

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を使うだけで書ける。

20
17
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?