🖥

Rspec — 僕の知らなかったRspec

2023/08/26に公開

普段あまり使っていないものをまとめてみた。

subjectを書かない

超基本だが、普通に知らなかった。
describe に直接 subject の内容を書ける。

describe 1 do
  it { is_expected.to eq 1 }
end

start_with / end_with マッチャ

始まりや終わりを検証できる。(どんな時に使うんだろう?)

describe "ABC" do
  it { is_expected.to start_with 'A' }
  it { is_expected.to end_with 'C' }
  it { is_expected.not_to start_with 'X' }
  it { is_expected.not_to end_with 'Z' }

  # 確かに正規表現よりドキュメンタブルではある
  it { is_expected.to match /^A/ }
  it { is_expected.to match /B$/ }
  it { is_expected.not_to match /^X/ }
  it { is_expected.not_to match /Z$/ }
end

[In My Opinion]

配列の始まりや終わりも、同じマッチャで検証できるところがイイ。

describe 'DAY' do
  subject { [:MORNING, :MIDDAY, :NIGHT] }

  it { is_expected.to start_with :MORNING }

  it { is_expected.to end_with :NIGHT }
end

対象が文字列だろうが配列だろうが関係ない。
「テストがドキュメンタブルであること」の一端があらわれていると思う。

これがもし start_string_with / start_array_with とかいう名前だったら、検証表現ではなくてプログラミング的表現になってしまう気がする。
テストの表現にはあくまで「挙動」を記述したい、プログラミング内の状態を表現したいわけではない、という感じを受けた。

( match_array とほぼ同じ動きをする contain_exactly が出てきたのも「対象が配列かどうかなんて、関係ねーよ」っていう意図があるんじゃないだろうか )

eql マッチャ

eq は内部的に == での比較をおこなうが、eql は eql? での比較をおこなう。

describe 1.0 do
  # 1.0 == 1 は true
  it { is_expected.to eq 1 }

  # 1.0.eql? 1 は false
  it { is_expected.not_to eql 1 }
end

参考: Ruby の文字列比較 [eql?, equal?, ==, ===] の使い分けを汗臭く説明 #ruby

be マッチャ

今まで eq の エイリアスだと思っていたが、内部的には equal? で比較をおこなうようだ。

describe do
  # 同じ文字列は eq でマッチする
  it { expect('ABC').to eq 'ABC' }

  # 同じ文字列だが違うオブジェクトなので be ではマッチしない
  it { expect('ABC').not_to be 'ABC' }
end

cover マッチャ

範囲の中に、値が含まれているかどうかのテストが出来る。

数値

describe (1..10) do
  # 整数
  it { is_expected.to cover 1 }
  it { is_expected.to cover 10 }

  # 小数点も受け付ける
  it { is_expected.to cover 1.0 }
  it { is_expected.to cover 10.0 }

  it { is_expected.not_to cover 0.9 }
  it { is_expected.not_to cover 10.1 }
end

日付

describe Time.now - 60 .. Time.now + 60 do

  # 日付も検証できる
  it { is_expected.to cover Time.now }

  it { is_expected.not_to cover Time.now - 70 }
  it { is_expected.not_to cover Time.now + 70 }
end

and / or

いくつかの条件を組み合わせて検証することが出来る。

describe 'ABC' do
  # and
  it { is_expected.to start_with('A').and include('B').and end_with('C') }

  # or
  it { is_expected.to eq('ABC').or eq('XYZ') }
end

あんまり複雑な条件では検証できない様子。
たとえば A and Not B とか not A and B とかいうのはサポートされていない。

Rspec も「使えそうなのに使えない」ということを心得ているのか、親切なメッセージを出してくれる。

describe 'ABC' do
  it { is_expected.not_to start_with('X').and end_with('C') }

  # NotImplementedError:
  #      `expect(...).not_to matcher.and matcher` is not supported, since it creates a bit of an ambiguity. Instead, define negated versions of whatever matchers you wish to negate with `RSpec::Matchers.define_negated_matcher` and use `expect(...).to matcher.and matcher`.

  it { is_expected.to start_with('A').and.not_to end_with('X') }

  # ArgumentError:
  # wrong number of arguments (given 0, expected 1)
end

satisfy マッチャ

さてぃすふぁい。
ブロックに自分で判定を書ける! 自由度が高そう。

これを使えば、先程できなかった A and not B みたいなマッチも、無理やり書けないことはない。(何の意味があるかは分からないが)

describe 'A' do
  it { is_expected.to eq('A').and satisfy { |string| string != 'X' } }
end

マッチャの組み合わせ

マッチャの中にマッチャを書ける!

配列の順序

describe [[:A,:B], [:X, :Y]] do
 # contain_exactly では配列の順番を考慮しない
 # なので「外側の配列」の順番が違ってもマッチする
 it do
  is_expected.to contain_exactly(
   [:X, :Y],
   [:A, :B]
  )
 end

 # この書き方では「内側の配列」の順序が違うとマッチしない
 it do
  is_expected.not_to contain_exactly(
   [:Y, :X],
   [:B, :A]
  )
 end

 # マッチャを組み合わせれば「内側の配列」が順序が違ってもマッチする
 # contain_exactly の中で、さらに contain_exactly でマッチをおこなう
 it do
  is_expected.to contain_exactly(
   contain_exactly(:Y, :X),
   contain_exactly(:B, :A)
  )
  end

 # 「外側の配列」の順序は考慮し、だが「内側の配列」の順序は考慮しない場合
 it do
  is_expected.to match([
   contain_exactly(:B, :A),
   contain_exactly(:Y, :X)
  ])
  end

 # 「外側の配列」の順序は考慮せず、だが「外側の配列」の順序は考慮しない場合
 it do
  is_expected.to contain_exactly(
   match([:X, :Y]),
   match([:A, :B])
  )
  end
end

検証内容を組み立てる

Rspec って DSL っぽく見えるけど、Ruby的に色々と工夫してDSLっぽく見えるようにしてるだけ、という理解をして良いんだろうか。
つまりはメソッド+引数をうまく渡せば動いてしまう。

# 検証内容自体も、let で定義できる ( 良し悪しはさておき )
describe [1,2,3] do
  describe do
    # to の後に渡すのは要するに引数なので
    # マッチャ名すら直接は書かなくても良いっぽい
    # ただしまったくドキュメンタブルでなくなってしまうので、やめた方が良い
    let(:expect_matches) { match([1,2,3]) }
    it { is_expected.to(expect_matches) }
  end

  # let の中で マッチの内容を組み立てて、さらにそれをマッチャに渡す書き方
  describe do
    let(:expect_equals) { [eq(1),eq(2),eq(3)] }
    it { is_expected.to match(expect_equals) }
  end
end

have_attributes

オブジェクト同士の比較の問題

ActiveRecord のオブジェクト同士を比較した場合、プライマリーキーさえ同じなら true が返ってきてしまう。
Rspec を書く時、意外に落とし穴になるんじゃないだろうか。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'eq' do
    subject { User.new(id: 1, name: 'Alice') }

    # name は違うが id が同じなのでマッチしてしまう
    it { is_expected.to eq User.new(id: 1, name: 'Bob') }
  end

  describe 'include' do
    subject { [User.new(id: 1, name: 'Alice')] }

    # name は違うが id が同じなのでマッチしてしまう
    it { is_expected.to include User.new(id: 1, name: 'Bob') }
  end
end

=> Ruby on Rails | ActiveRecord で オブジェクト同士を==で比較した場合、全属性が同値かどうかは検証しない

同値性を検証しよう

have_attributes ならオブジェクト自体ではなく、各属性の同値性を検証できる。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'have_attributes' do
    subject { User.new(id: 1, first_name: 'Alice', last_name: 'Liddel') }

    # キーを省略した場合は、それを無視してマッチを検証してくれる
    # このため、一部のキーに対してだけの検証をおこなうことが出来る
    # たとえば have_attributes に last_name を渡さない場合は、last_name の検証はおこなわない
    it { is_expected.to have_attributes(id: 1) }
    it { is_expected.to have_attributes(id: 1, first_name: 'Alice') }
    it { is_expected.to have_attributes(id: 1, first_name: 'Alice', last_name: 'Liddel') }

    # キーを渡し、なおかつ値が違う場合は、マッチは失敗する
    # it { is_expected.to have_attributes(id: 1, first_name: 'Alice', last_name: 'Pleasance') }

    # expected #<User id: 1, name: nil, created_at: nil, updated_at: nil, first_name: "Alice", last_name: "Liddel"> to have attributes {:id => 1, :first_name => "Alice", :last_name => "Pleasance"} but had attributes {:id => 1, :first_name => "Alice", :last_name => "Liddel"}
    # Diff:
    # @@ -1,4 +1,4 @@
    #  :first_name => "Alice",
    #  :id => 1,
    # -:last_name => "Pleasance",
    # +:last_name => "Liddel",
  end
end

not_to have_attributes の謎

先程書いた、この落ちるテスト。

# キーを渡し、なおかつ値が違う場合は、マッチは失敗する
it { is_expected.to have_attributes(id: 1, first_name: 'Alice', last_name: 'Pleasance') }

to で落ちるからには not_to にすれば通るだろう」と思いきや、こちらも落ちてしまう。

# it { is_expected.not_to have_attributes(id: 1, first_name: 'Alice', last_name: 'Pleasance') }

# Failure/Error: it { is_expected.not_to have_attributes(id: 1, first_name: 'Alice', last_name: 'Pleasance') }
#       expected #<User id: 1, name: nil, created_at: nil, updated_at: nil, first_name: "Alice", last_name: "Liddel"> not to have attributes {:id => 1, :first_name => "Alice", :last_name => "Pleasance"}

not_to have_attributes のマッチでは、「記述した全ての値が異なる場合」にテストが通るっぽい。
「ひとつの値だけが違う」場合には、テスト自体が落ちるようだ。(分かりにくい)

RSpec.describe User, type: :model do
  describe 'have_attributes' do
    subject { User.new(id: 1, first_name: 'Alice', last_name: 'Liddel') }

    # id と first_name と last_name が全て違うため、テストが通る
    it { is_expected.not_to have_attributes(id: 2, first_name: 'Bob', last_name: 'Pleasance') }

    # last_name が異なるが、id と first_name が同じなので、テストが落ちる 
    # it { is_expected.not_to have_attributes(id: 1, first_name: 'Alice', last_name: 'Pleasance') }
  end
end

オブジェクトの配列に対して、(各属性の)同値性を検証したい

User.all とかの内容を検証したい場合、 単純に have_attributes は使えない。
かと言って contain_exactly を使うと、オブジェクト同士の == での比較になってしまう。

この場合、動的にマッチャを組み立てる事もできる。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'multiple have_attributes' do
    subject { User.all }

    # 名字のないユーザーを生成する
    let!(:users) do
      [
       User.create(first_name: 'Alice'),
       User.create(first_name: 'Bob'),
       User.create(first_name: 'Carol')
      ]
    end

    # 「全員分のフルネーム」を期待するマッチャのリストを作る
    let(:expect_full_names) do
      users.map do |user|
        have_attributes(first_name: user.first_name, last_name: 'Liddel')
      end
    end

    # expect_full_names には、Rspec のマッチ用オブジェクトが配列に収められた状態になる
    # => [#<RSpec::Matchers::BuiltIn::HaveAttributes:0x007f9b0fef9690
    #   @expected={:first_name=>"Alice", :last_name=>"Liddel"},
    #   @negated=false,
    #   @respond_to_failed=false,
    #   @values={}>,
    #  #<RSpec::Matchers::BuiltIn::HaveAttributes:0x007f9b0fef9528
    #   @expected={:first_name=>"Bob", :last_name=>"Liddel"},
    # ..

    # 全員に名字を付ける
    before do
      User.update_all(last_name: 'Liddel')
    end

    # 動的に生成したマッチオブジェクトを、さらにマッチャに与える
    # こうして「オブジェクトの配列」全体を検証する
    it { is_expected.to contain_exactly(*expect_full_names) }
  end
end

ただマッチ内容自体を let で作るのは気持ち悪いので、実際にはマッチャの中で直接展開した方が良いかもしれない。

it do
  is_expected.to contain_exactly(*
    users.map do |user|
      have_attributes(first_name: user.first_name, last_name: 'Liddel')
    end
  )
end

include + have_attributes

合わせて使える。

require 'rails_helper'

RSpec.describe User, type: :model do
  describe 'include have_attributes' do
    subject { User.all }

    # みんなで遊ぼう
    let!(:users) do
      [
       User.create(first_name: 'Alice'),
       User.create(first_name: 'Bob'),
       User.create(first_name: 'Carol'),
       User.create(first_name: 'Dave')
      ]
    end

    # ボブとキャロルはいるよ
    it 'includes' do
      is_expected.to include(
        have_attributes(first_name: 'Bob'),
        have_attributes(first_name: 'Carol')
      )
    end

    # エリックとフランクはいないよ
    it 'does not include' do
      is_expected.not_to include(
        have_attributes(first_name: 'Eric'),
        have_attributes(first_name: 'Frank')
      )
    end

    # アリスはいるけどエリックはないよ ( FAILURE )
    #   expected #<ActiveRecord::Relation [#<User id: 1, name: nil, created_at: "2017-02-14 06:16:39", updated_at: "20..._at: "2017-02-14 06:16:39", updated_at: "2017-02-14 06:16:39", first_name: "Dave", last_name: nil>]> to include (have attributes {:first_name => "Eric"})
    #  Diff:
    #   -[(have attributes {:first_name => "Alice"}),
    #   - (have attributes {:first_name => "Eric"})]
    #
    #  it 'includes' do
    #   is_expected.to include(
    #     have_attributes(first_name: 'Alice'),
    #     have_attributes(first_name: 'Eric')
    #   )
    # end

    # エリックはいないけどアリスはいるよ ( FAILURE )
    # expected #<ActiveRecord::Relation [#<User id: 1, name: nil, created_at: "2017-02-14 06:16:39", updated_at: "20..._at: "2017-02-14 06:16:39", updated_at: "2017-02-14 06:16:39", first_name: "Dave", last_name: nil>]> not to include (have attributes {:first_name => "Alice"})
    #
    # Diff:
    # -[(have attributes {:first_name => "Alice"),
    # - (have attributes {:first_name => "Eric"})]    it 'does not include' do
    # it 'does not include' do
    #   is_expected.not_to include(
    #     have_attributes(first_name: 'Alice'),
    #     have_attributes(first_name: 'Eric')
    #   )
    end
  end
end

柔軟な have_attributes

ちなみにこの have_attributes はちょっと柔軟に出来ていて、同値検証以外のことも出来る。

  describe 'progressive have_attributes' do
    subject { User.new(first_name: 'Alice') }

    it { is_expected.to have_attributes(first_name: start_with('A')) }
    it { is_expected.to have_attributes(first_name: include('lic')) }
    it { is_expected.to have_attributes(first_name: end_with('e')) }

    # have_attributes の中でも and / or 条件を書ける
    it do
       is_expected.to have_attributes(
         first_name: (start_with('A').and include('lic').and end_with('e'))
       )
     end
   end
  end

環境

  • rspec-rails (3.5.2)
  • Rails 5.0.1

参考

チャットメンバー募集

何か質問、悩み事、相談などあればLINEオープンチャットもご利用ください。

https://line.me/ti/g2/eEPltQ6Tzh3pYAZV8JXKZqc7PJ6L0rpm573dcQ

Twitter

https://twitter.com/YumaInaura

公開日時

2017-02-14

Discussion