前回に続き、RSpecのchangeマッチャに関する記事です。
値の変化を検証する時は change
マッチャが便利です。
ただ、「こんな時どうするんだっけ」と調べることが多かったことから、 change
マッチャまわりをいろいろ試してみたときのメモを残します。
目次
- 環境
- ドキュメントについて
- メソッドスタイルとブロックスタイル
- 増減を確認する(by, from, to)
- 増減の幅(最小・最大)を検証 (by_at_least, by_at_most)
- 変化がないことを検証 (not_to + change)
- andとchangeの組み合わせ
- changeとreloadについて
- 1つのitの中で subject + change は複数回使えない
- 例外を検証しつつ、変化も検証する (raise_error + and + change)
- ソースコード
環境
なお、今回は Author
と Blog
の2つのモデルを使います。
それらのモデルは以下のコマンドで生成しました。
$ bin/rails g model Author name:string $ bin/rails g model Blog name:string published:boolean author:references
また、Author : Blog = 1 : n の関係とするため、お互いのモデルを以下のように関連付けしています。
Author
class Author < ApplicationRecord has_many :blogs end
Blog
class Blog < ApplicationRecord belongs_to :author end
ドキュメントについて
最近RSpecの公式ドキュメントが新しくなったようですので、そちらへのリンクを置いておきます。
リニューアルしたRSpecの公式ドキュメント(旧Relish)を読む方法 - Qiita
- RDoc
- Examples
メソッドスタイルとブロックスタイル
change
マッチャは、メソッドスタイルあるいはブロックスタイルで記述できます。
attributeを検証するのであればどちらでも可能ですが、 .
でチェーンしていくときはブロックスタイルになりそうです。
例えば、Authorモデルの件数を数える count
メソッドを使う場合、以下のように書けます。
# メソッドスタイル change(Author, :count) # ブロックスタイル change { Author.count }
一方、逆の書き方はできません。メソッドスタイルは以下のようにエラーとなりますし、ブロックスタイルの場合はシンタックスエラーになります。
pending 'attribute' do it 'pass' do # ArgumentError: `change` requires either an object and message (`change(obj, :msg)`) or a block (`change { }`). # You passed an object but no message. expect { Author.create!(name: 'thinkAmi') }.to change(Author.count) end end
また、メソッドスタイルではインスタンスのattributeも検証できます。
context 'instance' do let!(:author) { create(:author, name: 'thinkAmi') } it 'pass' do expect { author.update!(name: 'Foo') }.to change(author.reload, :name) end end
また、RuboCopにはスタイルを統一するようなCopも用意されています。デフォルトで有効にした場合のスタイルはメソッドのようです。
https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecexpectchange
増減を確認する(by, from, to)
by
はどれだけ増減したかを検証します。
context 'by' do it 'pass' do expect { Author.create!(name: 'thinkAmi') }.to change(Author, :count).by(1) end end
from
と to
は変化の前後の値を確認できます。
context 'from to' do it 'pass' do expect { Author.create!(name: 'thinkAmi') }.to change(Author, :count).from(0).to(1) end end
増減の幅(最小・最大)を検証 (by_at_least, by_at_most)
例えば、Authorモデルに random_create
というランダムな数のインスタンスを生成するクラスメソッドがあったとします。
class Author < ApplicationRecord has_many :blogs attr_accessor :age def self.random_create rand(1..2).times do |i| create!(name: "Name #{i}") end end end
このクラスメソッドに対して change
マッチャを使って検証するには、 by_at_least
や by_at_most
が使えます。
describe 'by_at_least' do it 'pass' do 100.times do expect { Author.random_create }.to change(Author, :count).by_at_least(1) end end end describe 'by_at_most' do it 'pass' do 100.times do expect { Author.random_create }.to change(Author, :count).by_at_most(2) end end end
変化がないことを検証 (not_to + change)
RSpecの not_to
か to_not
が使えます。なお、 to_not
は not_to
のエイリアスです。
https://rspec.info/documentation/3.12/rspec-expectations/RSpec/Expectations/ExpectationTarget/InstanceMethods.html#not_to-instance_method
また、RuboCopでは not_to
がデフォルトのようです。
https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnottonot
describe 'not change' do context 'not_to' do it 'pass' do author = Author.create(name: 'thinkAmi') expect { author.update!(name: author.name) }.not_to change(Author, :count) end end context 'to_not' do it 'pass' do author = Author.create(name: 'thinkAmi') expect { author.update!(name: author.name) }.to_not change(Author, :count) end end end
andとchangeの組み合わせ
2つとも変化あり (change and change)
RSpecの and
を使って、2つの change
をつなげます。
https://rspec.info/features/3-12/rspec-expectations/compound-expectations/
context 'change and change' do it 'pass' do expect { author = Author.create!(name: 'thinkAmi') Blog.create!(name: 'My blog', author: author) }.to change(Author, :count).by(1).and change(Blog, :count).by(1) end end
1つは変化あり、もう1つは変化なし (and + define_negated_matcher)
まずは、 not_to
+ change
+ and
+ change
で書いてみます。
it 'NotImplementedError' do author = Author.create!(name: 'thinkAmi') expect { Blog.create!(name: 'My blog', author: author) }.not_to change(Author, :count).and change(Blog, :count).by(1) end
テストを実行するとエラーになりました。
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 withRSpec::Matchers.define_negated_matcher
and useexpect(...).to matcher.and matcher
.
エラーメッセージにある通り、 RSpec::Matchers.define_negated_matcher
を使うことで、マッチャの反転させたものを定義できます。
https://rspec.info/features/3-12/rspec-expectations/define-negated-matcher/
そのため、 change
を反転させたものとして not_change
を定義し、テストコードで使ってみたところ、テストがパスしました。
context 'with define_negated_matcher' do RSpec::Matchers.define_negated_matcher :not_change, :change context 'to not_change' do it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Blog.create!(name: 'My blog', author: author) }.to not_change(Author, :count).and change(Blog, :count).by(1) end end end
なお、今回は説明のために context
にて define_negated_matcher
を定義しています。
ただ、実際には rails_helper
などで定義し、RSpec全体で使うのが良さそうです。
changeとreloadについて
更新のあったモデルのインスタンスを検証するときは reload する
例えば、 Author.create!
で生成したインスタンスの変数 author
を change
の検証対象とする場合、何もしないと変更がないとみなされてしまいます。
it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Author.find(author.id).update!(name: 'Foo') }.not_to change(author, :name) end
そのため、ActiveRecordの reload
を使って、再度DBからデータを読み込んでから検証します。
https://api.rubyonrails.org/v7.0.4/classes/ActiveRecord/Persistence.html#method-i-reload
it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Author.find(author.id).update!(name: 'Foo') }.to change { author.reload.name }.to('Foo') end
changeの中では2回reloadが呼ばれるので、expectの中でreloadする
change
と reload
について調べていたところ、次のような記載がありました。
Change Matcher のブロックは2度評価される
(中略)
初めの疑問、change のブロックの中に reload を書くか、書かないか、はどちらが良いのでしょうか?
changeブロックが2回評価されることを考えると、1度目のreloadは無意味なのにDBアクセスを起こすので、changeの外にだすべきなのかな、と個人的に思います。
他にも調べたところ、以下のissueにも expect
の中で書くと良さそうなコメントがありました。
Expect action to change object attribute to something · Issue #2254 · rspec/rspec-rails
そこで、以下のテストコードを使い、実際に発行されるSQLを確認してみます。
context 'instance' do around(:example) do |example| ActiveRecord::Base.logger = Logger.new(STDOUT) example.run ActiveRecord::Base.logger = nil end context 'without reload' do it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Author.find(author.id).update!(name: 'Foo') }.not_to change(author, :name) end end context 'with reload' do it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Author.find(author.id).update!(name: 'Foo') }.to change { author.reload.name }.to('Foo') end end context 'with single reload' do it 'pass' do author = Author.create!(name: 'thinkAmi') expect { Author.find(author.id).update!(name: 'Foo'); author.reload }.to change { author.name }.to('Foo') end end end
まずは with reload
contextのSQLを確認します。
Author Create (0.2ms) INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?) [["name", "thinkAmi"], ["created_at", "2023-05-04 06:05:14.804990"], ["updated_at", "2023-05-04 06:05:14.804990"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] TRANSACTION (0.0ms) SAVEPOINT active_record_1 Author Update (0.1ms) UPDATE "authors" SET "name" = ?, "updated_at" = ? WHERE "authors"."id" = ? [["name", "Foo"], ["updated_at", "2023-05-04 06:05:14.807763"], ["id", 1]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 Author Load (0.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] TRANSACTION (0.1ms) rollback transaction
次は with single reload
contextのSQLです。 Author Create
に続く Author Load
が1回に減っています。
Author Create (0.1ms) INSERT INTO "authors" ("name", "created_at", "updated_at") VALUES (?, ?, ?) [["name", "thinkAmi"], ["created_at", "2023-05-04 06:06:31.521800"], ["updated_at", "2023-05-04 06:06:31.521800"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] TRANSACTION (0.0ms) SAVEPOINT active_record_1 Author Update (0.1ms) UPDATE "authors" SET "name" = ?, "updated_at" = ? WHERE "authors"."id" = ? [["name", "Foo"], ["updated_at", "2023-05-04 06:06:31.528457"], ["id", 1]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 Author Load (0.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] TRANSACTION (0.1ms) rollback transaction
reloadしてもインスタンス変数の値はそのままになる
reload
の挙動について調べていたところ、以下の記事がありました。
ActiveRecord::Base#reload はインスタンス変数をクリアしない - アクトインディ開発者ブログ
そこで、RSpecの中でも同じなのか試してみます。
まずはAuthorモデルに age
という attr_accessor
を追加します。
class Author < ApplicationRecord has_many :blogs attr_accessor :age end
次に、テストコードを書いて確認してみます。
すると、 reload
後は
- DBに存在するattribute
name
は更新 - モデルだけに存在するattribute
age
はreload前と変わらず
となり、RSpecでも同じ結果になりました。
context 'attr_accessor' do RSpec::Matchers.define_negated_matcher :not_change, :change it 'pass' do author = Author.create!(name: 'thinkAmi') author.age = 20 expect { Author.find(author.id) do |a| a.name = 'Foo' a.age = 10 a.save! end author.reload }.to change { author.name }.to('Foo').and not_change { author.age }.from(20) end end
関連先だけをreloadし、changeで検証することもできる
モデル間で belongs_to
を定義しておくと、自動的に reload_association
メソッドも定義されます。
4.1.1 belongs_toで追加されるメソッド | Active Record の関連付け - Railsガイド
今回、Blogモデルに belongs_to :author
と定義してあることから、関連先だけreloadして検証できるかを試してみます。
すると、たしかに関連先のみreloadして検証することができました。
it 'pass' do author = Author.create!(name: 'thinkAmi') blog = Blog.create!(name: 'Foo', published: true, author: author) expect { Author.find(author.id).update!(name: 'Bar') Blog.find(blog.id).update!(name: 'Baz') blog.reload_author }.to change { blog.author.name }.to('Bar').and not_change { blog.name } end
複数の項目の変更を確認
この内容については前回の記事に書いたため、ここではテストコードだけメモしておきます。
RSpec 3.12 + rspec-rails 6.0.2にて、change + have_attributesマッチャを使うとエラーになるため、回避策を試してみた - メモ的な思考的な
ちなみに、その方法として
- change + and でつなげる
- change + attributes.with_indifferent_access + a_hash_including
- change + dup + have_attributes
のいずれかを使えばよいです。
なお、RSpec 3.12 + rspec-rails 6.0.2では change + have_attributes はエラーになります。
context 'multiple attributes' do context 'change chain' do it 'pass' do author = Author.create!(name: 'thinkAmi') blog = Blog.create!(name: 'Foo', published: true, author: author) expect { Blog.find(blog.id).update!(name: 'Bar', published: false) blog.reload }.to change { blog.name }.to('Bar').and change { blog.published }.to(false) end end context 'have_attributes' do pending 'raise error as of 3.12.0' do # https://github.com/rspec/rspec-expectations/issues/1131 it 'error' do author = Author.create!(name: 'thinkAmi') blog = Blog.create!(name: 'Foo', published: true, author: author) # expected `blog` to have changed to # #<RSpec::Matchers::BuiltIn::HaveAttributes:0x00007fe17dc15028 @actual=nil, @expected={ # name: "Bar", published: false }, @negated=false, @respond_to_failed=false, @values={}>, # but did not change expect { Blog.find(blog.id).update!(name: 'Bar', published: false) blog.reload }.to change { blog }.to have_attributes(name: 'Bar', published: false) end end context 'pass as of 3.12.0' do # https://github.com/rspec/rspec-expectations/issues/1131 # https://github.com/benoittgt/testing_6_0_0_rc1/blob/bump-to-last-6/spec/requests/widgets_request_spec.rb#L31 context 'attributes.with_indifferent_access' do it 'pass' do author = Author.create!(name: 'thinkAmi') blog = Blog.create!(name: 'Foo', published: true, author: author) expect { Blog.find(blog.id).update!(name: 'Bar', published: false) blog.reload }.to change { blog.attributes.with_indifferent_access }.to a_hash_including(name: 'Bar', published: false) end end context 'dup' do it 'pass' do author = Author.create!(name: 'thinkAmi') blog = Blog.create!(name: 'Foo', published: true, author: author) expect { Blog.find(blog.id).update!(name: 'Bar', published: false) blog.reload }.to change { blog.dup }.to have_attributes(name: 'Bar', published: false) end end end end end
1つのitの中で subject + change は複数回使えない
そもそも「1つの it
の中で subject
を複数回使うな」という話はありますが、今回は試してみた結果です。
以下の記事ではController Specの文脈で
注意が必要な点として、subject は初回評価時にのみリクエストが実行され、以降はリクエストが実行されず値を返すだけになります。
このためchangeを検査できるのはsubjectの最初の評価時のみになります。
とありました。
ただ、実際には他のスペックでも同様です。
例えば、以下のModel Specはパスします。
describe 'subject' do subject do author = Author.create!(name: 'thinkAmi') Blog.create!(name: 'Foo', published: true, author: author) end pending 'bad' do it 'fail' do expect { subject }.to change(Author, :count) expect { subject }.to change(Blog, :count) end end context 'good' do it 'pass' do expect { subject }.to change(Author, :count).and change(Blog, :count) end end end
例外を検証しつつ、変化も検証する (raise_error + and + change)
例えば、
を行う update_name_with_calling_api!
メソッドがあるとします。
class Author < ApplicationRecord def update_name_with_calling_api!(after_name) self.name = after_name save! # 外部APIを呼んだフリ raise StandardError.new('bad api') end end
このメソッドに対して「nameが更新されていること」の検証をしたい場合、
it 'fail' do expect { author.update_name_with_calling_api!('Foo') author.reload }.to change { author.name }.to('Foo') end
と書いても、 update_name_with_calling_api!
の中で例外が出るため、テストが失敗してしまいます。
そのため、 raise_error + and + change を使うことで、テストをパスさせることができます。
なお、例外クラスとともに例外メッセージを検証する場合は、
- raise_errorに2つの引数(例外クラスとメッセージ)を渡す
- raise_error + with_messageにエラーメッセージの文字列を渡す
- raise_error + with_messageにエラーメッセージの正規表現を渡す
のいずれかが使えます。
https://rspec.info/features/3-12/rspec-expectations/built-in-matchers/raise-error/
describe 'exception' do let!(:author) { create(:author, name: 'thinkAmi') } pending 'bad' do it 'fail' do expect { author.update_name_with_calling_api!('Foo') author.reload }.to change { author.name }.to('Foo') end end context 'good' do context '2 args' do it 'pass' do expect { author.update_name_with_calling_api!('Foo') author.reload }.to raise_error(StandardError, 'bad api').and change { author.name }.to('Foo') end end context 'with_message' do it 'pass' do expect { author.update_name_with_calling_api!('Foo'); author.reload }.to raise_error(StandardError).with_message('bad api').and change { author.name }.to('Foo') end end context 'with_message regex' do it 'pass' do expect { author.update_name_with_calling_api!('Foo'); author.reload }.to raise_error(StandardError).with_message(/ad a/).and change { author.name }.to('Foo') end end end end
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/10