RSpec 3.12 + rspec-rails 6.0.2にて、change + have_attributesマッチャを使うとエラーになるため、回避策を試してみた

RSpecchange マッチャを使うと、処理前後における値の変化を検証できます。
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.

https://github.com/rspec/rspec-rails/issues/1173

 
そこで、次はWorkaroundsに書かれていた内容を試してみます。

 

回避策

[成功] dup

ActiveRecorddup を使います。

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)

続いて、ActiveRecordattributesRSpeca_hash_including (include)を使います。

 
差し替えて実行します。

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 を使わないと動かない旨の記載がありました。

 
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.

https://github.com/rails/rails/blob/7c70791470fc517deb7c640bead9f1b47efb5539/activesupport/lib/active_support/hash_with_indifferent_access.rb#L256-L260

とあり、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


  1. もし厳密にチェックしたいのであれば、 from も一緒に使っても良いです。