Rails + RSpecにて、changeマッチャまわりをいろいろ試してみた

前回に続き、RSpecのchangeマッチャに関する記事です。

値の変化を検証する時は change マッチャが便利です。

ただ、「こんな時どうするんだっけ」と調べることが多かったことから、 change マッチャまわりをいろいろ試してみたときのメモを残します。

 
目次

 

環境

 
なお、今回は AuthorBlog の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

 

メソッドスタイルとブロックスタイル

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

 
fromto は変化の前後の値を確認できます。

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_leastby_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)

RSpecnot_toto_not が使えます。なお、 to_notnot_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)

RSpecand を使って、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 with RSpec::Matchers.define_negated_matcher and use expect(...).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! で生成したインスタンスの変数 authorchange の検証対象とする場合、何もしないと変更がないとみなされてしまいます。

it 'pass' do
  author = Author.create!(name: 'thinkAmi')
  expect { Author.find(author.id).update!(name: 'Foo') }.not_to change(author, :name)
end

 
そのため、ActiveRecordreload を使って、再度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する

changereload について調べていたところ、次のような記載がありました。

Change Matcher のブロックは2度評価される

(中略)

初めの疑問、change のブロックの中に reload を書くか、書かないか、はどちらが良いのでしょうか?

changeブロックが2回評価されることを考えると、1度目のreloadは無意味なのにDBアクセスを起こすので、changeの外にだすべきなのかな、と個人的に思います。

探検!Changeマッチャ - MUGENUP技術ブログ

 
他にも調べたところ、以下の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の最初の評価時のみになります。

読みやすいRSpecを書くためのTips - 弥生開発者ブログ

とありました。

 
ただ、実際には他のスペックでも同様です。

例えば、以下の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)

例えば、

  • 自分自身のattributeを更新しつつ、外部APIを呼ぶ
  • 外部APIでエラーが返ってきても、自分自身のattributeはロールバックしない

を行う 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