RSpecの change
マッチャを使うと、処理前後における値の変化を検証できます。
https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/change/
例えば
# == Schema Information # # Table name: favorites # # id :integer not null, primary key # is_secret :boolean # name :string # created_at :datetime not null # updated_at :datetime not null # class Favorite < ApplicationRecord end
というモデルがあるとします。
このモデルが
項目 | name | is_secret |
---|---|---|
変更前 | 秋映 | true |
変更後 | シナノゴールド | false |
のように変化することを検証したい場合、以下のように書けます。1
it 'pass' do favorite = Favorite.create!(name: '秋映', is_secret: true) expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change { favorite.name }.to('シナノゴールド').and change { favorite.is_secret }.to(false) end
上記では複数のattributeの値を検証するのに and change
でつないでいるものの、もし検証するattributeが増えると手間も増えそうです。
そこで、 have_attributes
マッチャを使うとより簡潔に書けます。
https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/have-attributes/
expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change { favorite }.to have_attributes(name: 'シナノゴールド', is_secret: false)
そんな中、RSpec3.12 + rspec-rails 6.0.2 の環境で change + have_attributesマッチャを使ったところ、エラーになってしまいました。
そこで、回避策を調べて試してみたときのメモを残します。
目次
環境
エラー内容と調査
RSpec3.12 + rspec-rails 6.0.2の環境にて
it 'error' do favorite = Favorite.create!(name: '秋映', is_secret: true) expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change { favorite }.to have_attributes(name: 'シナノゴールド', is_secret: false) end
を実行したところ
expected
favorite
to have changed to #<RSpec::Matchers::BuiltIn::HaveAttributes:0x00007f02f5d99e10 @actual=nil, @expected={ name: "シナノゴールド", is_secret: false }, @negated=false, @respond_to_failed=false, @values={}>, but did not change
というエラーになりました。
Githubのissueを探したところ、rspec-expectationsの以下のコメントにて似たようなエラーの内容がありました。
https://github.com/rspec/rspec-expectations/issues/1131#issuecomment-816564452
また、上記issueからリンクされていたrspec-railsのissueには回避策も記載されていました。
Workarounds
- Call dup on ActiveRecord objects in the change block. This will remove the id resulting in ActiveRecord to fall back on a more intensive attribute match.
- Call ActiveRecord#attributes to get the attribute list and use RSpec's standard hash matchers such as include.
そこで、次はWorkaroundsに書かれていた内容を試してみます。
回避策
[成功] dup
ActiveRecordの dup
を使います。
Duped objects have no id assigned and are treated as new records. Note that this is a “shallow” copy as it copies the object's attributes only, not its associations. The extent of a “deep” copy is application specific and is therefore left to the application to implement according to its need. The dup method does not preserve the timestamps (created|updated)_(at|on).
https://api.rubyonrails.org/classes/ActiveRecord/Core.html#method-i-dup
change
のブロックの中で favorite
としていたところを favorite.dup
に差し替えます。
favorite = Favorite.create!(name: '秋映', is_secret: true) expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change{ favorite.dup # ここだけ変更 }.to have_attributes(name: 'シナノゴールド', is_secret: false)
すると、テストがパスしました。
[失敗] attributes + a_hash_including (include)
続いて、ActiveRecordの attributes
とRSpecの a_hash_including
(include
)を使います。
- ActiveRecord::AttributeMethods#attributes
- a_hash_including
差し替えて実行します。
it 'error' do favorite = Favorite.create!(name: '秋映', is_secret: true) expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change{ favorite.attributes }.to a_hash_including(name: 'シナノゴールド', is_secret: false) end
すると、以下のエラーになってしまいました。
expected
favorite.attributes
to have changed to #< a hash including (name: "シナノゴールド", is_secret: false)>, but is now { "id" => 1, "name" => "シナノゴールド", "is_secret" => false, "created_at" => #<ActiveSupport::TimeWithZone 2023-05-04 16:54:13+(436697/1000000) +09:00 (JST)>, "updated_at" => #<ActiveSupport::TimeWithZone 2023-05-04 16:54:13+(445287/1000000) +09:00 (JST)> }
[成功] attributes + with_indifferent_access + a_hash_including
attributes + a_hash_including
で似たようなことが起きてないかを調べたところ、以下のコメントからリンクされたソースコードに with_indifferent_access
を使わないと動かない旨の記載がありました。
- https://github.com/rspec/rspec-rails/issues/1173#issuecomment-602109310
- https://github.com/benoittgt/testing_6_0_0_rc1/blob/bump-to-last-6/spec/requests/widgets_request_spec.rb#L31
with_indifferent_access()
についてはAPIドキュメントには詳細は記載されていませんでした。
https://api.rubyonrails.org/v7.0.4.2/classes/ActiveSupport/HashWithIndifferentAccess.html#method-i-with_indifferent_access
次に、Githubのソースコードを見ると、内部で dup
メソッドを呼んでいました。
https://github.com/rails/rails/blob/7c70791470fc517deb7c640bead9f1b47efb5539/activesupport/lib/active_support/hash_with_indifferent_access.rb#L60-L62
その dup
の定義を見ると、
Returns a shallow copy of the hash.
とあり、hashのシャローコピーが返ってくるようでした。
そこで、 with_indifferent_access
も合わせて使ってみます。
favorite = Favorite.create!(name: '秋映', is_secret: true) expect { Favorite.find(favorite.id).update!(name: 'シナノゴールド', is_secret: false) favorite.reload }.to change{ favorite.attributes.with_indifferent_access }.to a_hash_including(name: 'シナノゴールド', is_secret: false)
すると、テストがパスしました。
まとめ
change + have_attributesマッチャでエラーになる場合は、
- changeのブロックの中で
dup
を使い、have_attributes
で検証する - changeのブロックの中で
attributes + with_indifferent_access
を使い、a_hash_including
で検証する
のどちらかで回避できそうと分かりました。
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/9
-
もし厳密にチェックしたいのであれば、
from
も一緒に使っても良いです。↩