should を捨て expect を使うようになった、たった一つの理由

皆さんこんにちは。スーパーのビニール袋は濡れタオルを触ってから開ける方、MUGENUP の osada です。 今回はexpect について調査したことを書きたいと思います。 ターゲットは、rspecが上手く書けなくて、気が付くと3時間もテストを書いている、というようなrspec初心者です(実話)。

中間管理職 expect さん

では、本題のexpectです。まずは問1から。5秒で答えてください。

describe Person do
  it "should be a instance" do
    expect{Person.new}.to be_a(Person)
  end

  it "should be a instance" do
    expect(Person.new).to be_a(Person)
  end
end 

問1

  1. 両方テストが通る
  2. 上のテストが通らない
  3. 下のテストが通らない
  4. 両方通らない









できました?正解は

解答: 2. 上のテストが通らない

  1) Person should be a instance
     Failure/Error: expect{Person.new}.to be_a Person
       expected #<Proc:0x007fbcf91a75a8@/Users/osada/projects/ruby_test/rspec_test/spec/person_spec.rb:9> to be a kind of Person
     # ./spec/person_spec.rb:9:in `block (2 levels) in <top (required)>' 

では、問2

上のテストが通らない理由を説明してください

違いは、{}()です。しかし前回のchange マッチャで使っていたのは{}のブロックでした。今回はなぜダメなんでしょうか?

調べていきましょう。

ソースと共にあらんことを

ソースコードを読んで分かるように書くべきである、という考え方にあるように、Rubyist は常にソースと共にあるようです。 では、expect の深淵に足を踏み入れたいと思います。

私の環境では、ruby/2.0.0/gems/rspec-expectations-2.14.2/lib/rspec/expectations/syntax.rb が入り口のようです。

expect と to

          def expect(*target, &target_block)
            target << target_block if block_given?
            raise ArgumentError.new("You must pass an argument or a block to #expect but not both.") unless target.size == 1
            ::RSpec::Expectations::ExpectationTarget.new(target.first)
          end

block のときは、targetに追加してから、target.firstを委譲しています。ということは、引数とブロックを同時に渡せるのか?、とおもいきや、target.size==1以外受け付けないので、ちょっと不思議な構造です。

::RSpec::Expectations::ExpectationTargetは同じ階層のexpectation_target.rbにあります。

    # @example
    #   expect(something) # => ExpectationTarget wrapping something 

    class ExpectationTarget

      # @api private
      def initialize(target)
        @target = target
      end 

とあるように、expectはただのラッパーのようです。 そして target はそのまま @targetに代入されています。ブロックだからといって、特別扱いはしていませんね。

では、続いて toを見てみます。

      def to(matcher=nil, message=nil, &block)
        prevent_operator_matchers(:to, matcher)
        RSpec::Expectations::PositiveExpectationHandler.handle_matcher(@target, matcher, message, &block)
      end 

引数を見て驚きました。toにはmessageblockが渡せるのですね。そしてその先は、PositiveExpectationHandlerに委譲しているようです。

なんと expect がしているのはこれだけです!シンプルですね。 この先はhandlerを読まなければなりません。

PositiveExpectationHandler

PositiveExpectationHandler があるのは、同じ階層の handler.rb です。

ruby/2.0.0/gems/rspec-expectations-2.14.2/lib/rspec/expectations/handler.rb

    class PositiveExpectationHandler < ExpectationHandler

      def self.handle_matcher(actual, matcher, message=nil, &block)
        check_message(message)
        ::RSpec::Matchers.last_should = :should
        ::RSpec::Matchers.last_matcher = matcher
        return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) if matcher.nil?

いきなり困りました。 コロン2つで始まる式なんてありましたっけ? そのことについては後述することにして先に進めます。

        match = matcher.matches?(actual, &block)
        return match if match

ここが判定の部分です。 前回 changeマッチャ で見ましたが、matchermatches? メソッドを呼びだしています。 こうゆうの(↓)ですね。 to からくる block を渡せる matcher と 渡せない matcher があるようです。change は渡せない方だったのですね。

         def matches?(event_proc)
          raise_block_syntax_error if block_given?

          @actual_before = evaluate_value_proc
          event_proc.call
          @actual_after = evaluate_value_proc

        (!change_expected? || changed?) && matches_before? && matches_after? && matches_expected_delta? && matches_min? && matches_max?
        end

結局のところ、expectで渡したtarget はマッチャーの所まで、何の加工もなくそのまま委譲されるということが分かりますね。そしてbe_aマッチャ(実体はbe_kind_ofマッチャ) は、こんな感じです。

      class BeAKindOf < BaseMatcher
        def match(expected, actual)
          actual.kind_of? expected
        end
      end

やっと{}で書くと失敗する理由がわかった気がします。 つまり、{Person.new}.kind_of? PersonProc.kind_of? Personですので、ProcオブジェクトはPersonインスタンスではない、といことで失敗しているのでした。===で評価しているマッチャであれば、成功するに違いありません。

ということで、expect に何を渡すかは、マッチャによるという身も蓋も無い話になってしまいましたが、ここを意識して書いていくことで、もっとスラスラと rspec が書けると良いなと思っています。

message は失敗したときの表示

せっかくなので続きを見ていきましょう。

match で true になったら、return するので、以降は match しなかった時の処理のようです。

        message = message.call if message.respond_to?(:call)

        message ||= matcher.respond_to?(:failure_message_for_should) ?
                    matcher.failure_message_for_should :
                    matcher.failure_message

to の第2引数、message はここで使われるようです。 オリジナルのエラーメッセージを返すことができるんですね。試してみます。

  it "should be a instance" do
    expect{Person.new}.to be_a(Person), "通らないヨーダ"
  end
  1) Person should be a instance
     Failure/Error: expect{Person.new}.to be_a(Person), "通らないヨーダ"
       通らないヨーダ
     # ./spec/person_spec.rb:10:in `block (2 levels) in <top (required)>' 

おお、本当に変わりました。

指定しないときはいつものエラーメッセージですが、 それらは matcher が返していたようです。

message.callMethodクラスのことのようですが、奥が深いのでスルーします(えっ)。 エラーメッセージを返すメソッドを作成して、 それを rspec の引数に渡すような使い方を想定しているのかもしれません。

diffable

ここで最後です。diffable? とは何でしょうか?

        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

これを持っている matcher を探してみると、base_matcher.rbfalse, eq.rb, eql.rb, equal.rbtrue、およびinclude.rb はメソッド呼び出しがありました。

> rspec-expectations-2.14.2/lib/rspec/matchers/built_in/base_matcher.rb
      def diffable?
         false
      end 

> rspec-expectations-2.14.2/lib/rspec/matchers/built_in/eq.rb
        def diffable?; true; end 

> rspec-expectations-2.14.2/lib/rspec/matchers/built_in/include.rb
        def diffable?
          # Matchers do not diff well, since diff uses their inspect
          # output, which includes their instance variables and such.
          @expected.none? { |e| RSpec::Matchers.is_a_matcher?(e) }
        end 

比較可能な場合において、期待値と実測値を表示するのに使うようですね。

まとめ

今回の調査でわかったのは下記の2点です。

  1. expectmatcherにそのまま委譲する。expect{}.toで書けるのは、matcher 側がブロックを評価しているときのみ
  2. toにはmessage&blockが渡せる

ということで、matcher を知ることが、expect を書けるようになる条件なんですね。 基本の matcher はBuilt in matchers - RSpec Expectations - RSpec - Relishにありましたので、勉強していきたいと思います。

誤差をOKにするbe_with_in、範囲を比較する cover、他にも色々あるので、便利に使っていきたいです。 初めて知ったマッチャについてご紹介だけしておきます。

  • be_with_in 誤差をOKにするマッチャ

    should be_within(0.5).of(27.9)

  • exist exist? を判定するマッチャ。

    expect(obj).to exist

  • match 正規表現のマッチャ =~と同じ

    expect("a string").to match(/str/)

  • respond_to クラスのメソッドが呼び出せるかどうか。しかも引数の呼び出し個数までマッチできる!

    expect([1, 2, 3]).to respond_to(:take).with(1).argument

  • satisfy ブロックが渡せるマッチャ。何でもできちゃう。

    expect(10).to satisfy { |v| v % 5 == 0 }

  • throw throw で渡ってきたシンボルを確認するマッチャ

    expect { throw :foo }.to throw_symbol(:foo)

  • yield 一言では説明できないのでパス! yield matchers - Built in matchers - RSpec Expectations - RSpec - Relish
  • cover 範囲をマッチ。というか、range に cover なんてメソッドあったんですね。

    expect(1..10).to cover(5)

  • start_with, end_with。先頭か末尾をマッチ。というか、ファイル名がstart_and_end_with.rbなんですが

    expect("this string").to end_with "string"

使えそうなもの、使えなさそうなもの。色々あって、楽しいですね。 これからも良いrspecを書いていきたいと思います。

あ、表題shouldを捨てた理由、が書いてありませんでしたね。

  1. shouldself を対象にした handler 呼び出しでしかないから。
        syntax_host.module_eval do
          def should(matcher=nil, message=nil, &block)
            ::RSpec::Expectations::PositiveExpectationHandler.handle_matcher(self, matcher, message, &block)
          end

やってること同じじゃん、ということで、これなら expect で書いたほうがクラスを開く必要がない分が良いな、と思った次第です。文字数増えるのはイヤなんですけどね〜。

では皆さん。良いspec ライフをお送り下さい。

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

(補講) ::って何よ?

さて、ちょっと後に回していた、::についての調査結果です。

        ::RSpec::Matchers.last_should = :should

あるクラスまたはモジュールで定義された定数を外部から参照する ためには::演算子を用います。 またObjectクラスで 定義されている定数(トップレベルの定数と言う)を確実に参照する ためには左辺無しの::演算子が使えます。 親クラスとネストの外側のクラスで同名の定数が定義されているとネストの外 側の定数の方を先に参照します。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fvariables.html#const

# -*- coding: utf-8 -*-
CONSTANT = "トップの定数だよ"

class Person
  CONSTANT = "内部の定数だよ"
  def top_level_constant
    ::CONSTANT
  end

  def my_class_constant
    CONSTANT
  end
end
describe Person do
  it do
    expect(Person.new.my_class_constant).to eq "内部の定数だよ"
  end

  it do
    expect(Person.new.top_level_constant).to eq "トップの定数だよ"
  end
end

(補講2) ボクの考えた最強のマッチャ

最後に皆さんに課題です。3秒で選んでください。

  it "should be true" do
    expect{Person.new}.to be_true
  end 
  1. 通る
  2. 通らない