🤖

RSpec の mock が チョットワカル ようになる記事

2024/01/30に公開

はじめまして。HiroVodkaです。
みなさん、RSpec 書いてますか??
自分はテストコードを書くのが大好き人間なので、よく RSpec を書きます。

たまに、ペアプロやコードレビュー等で「mock の使い方がいまいちわからない」みたいな話を聞くので、自分なりに RSpec の mock に関する記事を書いてみようと思いました。

ちなみに以下のような人を対象としています。

  • RSpec は少し書いたことがあるが、 mock に関してはいまいちわかってない。。。
  • コピペで mock を使用したことはあるが、何が起こっているのかはいまいち分からない。。。

RSpec の mock を学ぶ前に頭の片隅に入れておく知識

いきなり RSpec の mock について学ぶのは悪手だと思っています。
簡単なコードであればすぐに動く or コピペで実装できますが、ある程度の知識がない状態で mock を使い続けると、少し複雑なユースケースに遭遇した場合に自分では解決困難になってしまいます。
なので、 mock を理解するために頭の片隅に入れておいてほしい知識を先に書きます🙇‍♀

Ruby は全てオブジェクト

Ruby では(殆ど)全てがオブジェクトとして扱われるため、 RSpec で mock を使用する際には、 現在どのオブジェクトに対して何をしようとしているか がかなり重要です。

逆にこの考えさえ頭の片隅に置いておけば、 mock をうまく扱うことができると思います。

クラスはオブジェクト

クラスがオブジェクトだということを irb で確認してみます。

irb(main):001:0> class Hoge; end
=> nil
irb(main):002:0> Hoge.object_id
=> 15500

object_id というメソッドを利用して、オブジェクトIDを取得することができます。

Rubyでは、(Garbage Collectされていない)アクティブなオブジェクト間で重複しない整数(object_id)が各オブジェクトにひとつずつ割り当てられています。このメソッドはその値を返します。

https://docs.ruby-lang.org/ja/latest/method/Object/i/object_id.html

解説は難しいので簡単に説明すると、Ruby はオブジェクトごとに一意な ID を割り振っているということです。
この ID が同一なら同じオブジェクトということになります。

そして新しく Piyo というクラスを作成し、 object_id を確認すると Hogeクラスとは違う ID であることが分かりました。

irb(main):001:0> class Hoge; end
=> nil
irb(main):002:0> Hoge.object_id
=> 15500

irb(main):004:0> class Piyo; end
=> nil
irb(main):005:0> Piyo.object_id
=> 37640
irb(main):006:0>

なので、クラスオブジェクト(Hoge やPiyo)は一意な object_id を持ったオブジェクトであることが分かりました。

インスタンスもオブジェクト

次にインスタンスもオブジェクトであることを確認します。
Hogeクラスに対して new メソッドを実行して、 Hoge クラスのインスタンスを作成しました。

irb(main):006:0> hoge_instance = Hoge.new
=> #<Hoge:0x000000010530c388>
irb(main):007:0> hoge_instance.object_id
=> 58320

この hoge_instance にも一意の object_id が割り振られています。

では複数個のインスタンスを作成すると、 object_id はどうなるでしょうか??
another_hoge_instance という別の変数に、Hoge クラスのインスタンスを入れて確認してみます。

 irb(main):006:0> hoge_instance = Hoge.new
=> #<Hoge:0x000000010530c388>
irb(main):007:0> hoge_instance.object_id
=> 58320
irb(main):008:0> another_hoge_instance = Hoge.new
=> #<Hoge:0x00000001053d46d0>
irb(main):009:0> another_hoge_instance.object_id
=> 79140
irb(main):010:0>

hoge_instanceanother_hoge_instance は別の object_id が割り振られています。

これ自体は先程の解説通りです。

Rubyでは、(Garbage Collectされていない)アクティブなオブジェクト間で重複しない整数(object_id)が各オブジェクトにひとつずつ割り当てられています。このメソッドはその値を返します。

オブジェクトは生成されたときに一意となる

ここまでの解説でクラスとインスタンスがオブジェクトということがなんとなく理解できたと思います。
で、ここで解説したかったことのまとめとして オブジェクトは生成されたタイミングで一意となるということです。
つまりクラスであれば、クラス定義が読み込まれたタイミング、インスタンスであれば生成されたタイミングで一意となります。
なので、クラスは実行中に常に同じ object_id を返し、インスタンスは生成されるごとに別の object_id が割り振られます。

# Class.object_id は常に同じ値 == 同じオブジェクト
irb(main):010:0> Hoge.object_id
=> 15500
irb(main):011:0> Hoge.object_id
=> 15500
irb(main):012:0> Hoge.object_id
=> 15500

# Class.new.object_id は実行ごとに異なった値 == 生成されるのは毎回違うオブジェクト
irb(main):013:0> Hoge.new.object_id
=> 120900
irb(main):014:0> Hoge.new.object_id
=> 123620
irb(main):015:0> Hoge.new.object_id
=> 126340
irb(main):016:0>

オブジェクトに対して、何らかのメソッドを実行することができる

Ruby のオブジェクトは何らかのメソッドを実行することができます。
今まで実行してきた object_id もメソッドの一つです。
Hoge.new で使用している new もメソッドです。
なので、 Hoge というオブジェクトに対して new メソッドを実行しているということになります。

このオブジェクトがメソッドを実行するという行為が、 mock を使う際にも大事になります。

まとめ

ひとまず、 Ruby は殆ど全てがオブジェクトであり、そのオブジェクトは生成されるタイミングで一意であること。
そしてオブジェクトに対してメソッドを実行することができるということが何となく分かれば次に進みましょう。

補足

いわゆるオブジェクト指向プログラミングに関しての考え方は、komagata さんという方の記事がとてもわかりやすいです。
もしこれまでの話が全然分からなければ、この記事の図や解説を読んでみてください🙇‍♀
https://docs.komagata.org/5696

RSpec の mock の考え方

だいぶ長い前置きでしたが、これまでのことが少し頭の片隅に入っていれば、これから先はそこまで難しくないと思います。

簡単なサンプルコードを書いたので、このコードを使用してmockについて理解していきましょう。

分かりやすいように、specとclassを同じファイルに定義しています。

class User
  def full_name(first_name:, last_name:)
    "#{last_name} #{first_name}"
  end
end

RSpec.describe User do
  describe '#full_name' do
    let(:user) { User.new }

    it 'full_name が表示されること' do
      expect(user.full_name(first_name: '太郎', last_name: '博士')).to eq '博士 太郎'
    end
  end
end

RSpec の mock って何?

テスト中に利用される、実際のオブジェクトを模倣するためのオブジェクトです。
つまり偽物のオブジェクトです。
あえて偽物を使うということは、その理由があるのですが、主に以下の 3 点の場合に mock を使用することになると思います。

  • 本物を利用すると困ることがある
    • 例えば、外部サービスを利用する API を叩くような処理がある場合など
    • 外部の要因でテスト結果が変わってしまう場合
  • テスト環境では本物を使用することができない
    • 本番でしか用意されていない何らかの処理を実行する必要がある場合など
  • 本物を使用する必要がない
    • クラスの単体テストを実行したいだけなので、 DB への接続が不要な場合など

あえて偽物を使用する必要がある場合、または偽物を利用するメリットが大きい場合には mock を使用することがあります。
また、 mock を多用するかどうか?に関しては派閥があるためこの記事では触れません。

mockはオブジェクト

これまでの話に出てきたように、 Ruby は全てがオブジェクトです。
そのため、当たり前に mock もオブジェクトです。
試しに、サンプルコード内で mock オブジェクトを作成し、 object_id を見てみましょう。

RSpec.describe User do
  describe '#full_name' do
    let(:user) { User.new }

+    before do
+      mock_object = double('MockObject')
+      puts "mock object の object_id: #{mock_object.object_id}"
+    end

    it 'full_name が表示されること' do
      expect(user.full_name(first_name: '太郎', last_name: '博士')).to eq '博士 太郎'
    end
  end
end

テスト実行結果はこちらです。

❯ bundle exec rspec rspec_sample_spec.rb
mock object の object_id: 2160
.

Finished in 0.00154 seconds (files took 0.04507 seconds to load)
1 example, 0 failures

mock object の object_id: 2160 という出力がありますね。
この mock オブジェクトは、double('MockObject')というメソッドによって作成されたオブジェクトであるということが分かりました。

ついでに、何度か mock オブジェクトを生成してみましょう。

RSpec.describe User do
  describe '#full_name' do
    let(:user) { User.new }

    before do
+     mock_object_1 = double('MockObject')
+     puts "mock object 1 の object_id: #{mock_object_1.object_id}"

+     mock_object_2 = double('MockObject')
+     puts "mock object 2 の object_id: #{mock_object_2.object_id}"

+     mock_object_3 = double('MockObject')
+     puts "mock object 3 の object_id: #{mock_object_3.object_id}"
    end

    it 'full_name が表示されること' do
      expect(user.full_name(first_name: '太郎', last_name: '博士')).to eq '博士 太郎'
    end
  end
end

テスト実行結果はこちらです。

❯ bundle exec rspec rspec_sample_spec.rb
mock object 1 の object_id: 2160
mock object 2 の object_id: 2180
mock object 3 の object_id: 2200
.

Finished in 0.00155 seconds (files took 0.04454 seconds to load)
1 example, 0 failures

出力を見ると全て別のオブジェクトであることが分かりました。
つまり、doubleメソッドによって、毎回新しい mock オブジェクトが生成されるということですね。

mock を生成するだけでは何もできない

生成されただけの mock は何もすることができません。

イメージとしてはこんな状態です。

👨「あなたは mock です。」
mock🤖「ハイ」
👨「何ができますか??」
mock🤖「........ワカラナイ」

mock オブジェクトは定義されただけでは何もできないのです。
なので、こちらが mock にどのような振る舞いをするのかを教える必要があります。

mock に期待する振る舞いを設定する

サンプルコードとして別のクラスを作成してみます。

class Piyo
end

例えば、 Piyo クラスのオブジェクトに name メソッドを実行すると、 'piyoです' と文字列を返すようなクラスとメソッドを実装してくださいと言われたら、実装できますよね??

こんな感じで書けるはずです。

class Piyo
  def name
    'piyoです'
  end
end

Piyo.new.name
=> 'piyoです'

mock に振る舞いを設定するときも同じで、 xx を実行すると xx と返してくださいと、こちらが実装することになります。

mock に対して振る舞いを設定する際には、allow(振る舞いを変えたいオブジェクト).to receive(振る舞いを指定したいメソッド名).and_return(振る舞いとして期待する返り値)を使用します。

class Piyo
  def name
    'piyoです'
  end
end

RSpec.describe Piyo do
  it do
    piyo_mock = double("PiyoMock")
    allow(piyo_mock).to receive(:name).and_return('piyoです')
    expect(piyo_mock.name).to eq 'piyoです'
  end
end

先程の何ができるか分からなかった mock とは違い、今回の mock オブジェクトは出来ることがあります。
👨「あなたは mock です。」
mock🤖「ハイ」
👨「name メソッドが呼ばれた場合、 piyoですという文字列を返してください」
mock🤖「ワカリマシタ(nameメソッド呼バレタラ、piyoですヲカエスゾ)」
RSpec👮‍♀「piyo_mock に name メソッドが呼ばれた場合の返り値は。。。'piyoです'であってるな!テスト成功」

このように mock オブジェクトに対して振る舞いをこちら側から設定することで、 mock オブジェクトは初めてなにか出来るようになります。

ただ、今のサンプルコードでは特に意味のある mock の使い方ではありません。
例えば今回のテストはこちらが mock オブジェクトに指定したふるまいを検証しただけなので、全くテストコードとしての意味がありません。

本物のオブジェクトの振る舞いを変える

先程は double を使用して偽物のオブジェクトを作成し、その偽物に振る舞いを与えました

ただ、 mock を利用して、本物のオブジェクトに振る舞いを与える(変える) こともできます。
以下にサンプルコードを書いてみます。

RSpec.describe Piyo do
  describe '#name' do
    let(:piyo) { Piyo.new }

    before do
      allow(piyo).to receive(:mock_name).and_return('ニセモノデス')
    end

    it do
      expect(piyo.name).to eq 'piyoです'
      expect(piyo.mock_name).to eq 'ニセモノデス'
    end
  end
end

テスト実行結果

❯ bundle exec rspec rspec_sample_spec.rb
.

Finished in 0.00486 seconds (files took 0.05599 seconds to load)
1 example, 0 failures

allow(piyo) で指定されている piyo は、let(:piyo) の引数を見て分かる通り本物のオブジェクトです。
今回はこの本物のオブジェクトに対して mock_name メソッドを受け取ったら'ニセモノデス' と返すように命令しました。
先程との比較は以下のとおりです。

  • double で作成した偽物オブジェクトに振る舞いを与えた
  • Piyo.new で作成した本物オブジェクトに振る舞いを与えた

つまり本物のオブジェクトに対しても偽の振る舞いを与えることができます。

mock を利用して既存のメソッドの振る舞いを変える

先程の説明で

allow(振る舞いを変えたいオブジェクト).to receive(振る舞いを指定したいメソッド名).and_return(振る舞いとして期待する返り値)

と説明したように、allow には振る舞いを変えたいオブジェクトを与えることができます。

つまりクラスもオブジェクトなので、クラスの振る舞いを変えることも出来るということです。
今回は、元々クラスに定義されている new メソッドの振る舞いを変えてみたいと思います。

少しサンプルコードを見てみましょう。

RSpec.describe Piyo do
  describe '#name' do
    let(:piyo) { Piyo.new }

    before do
      piyo_mock = double('PiyoMock')
      allow(Piyo).to receive(:new).and_return(piyo_mock)
    end

    it do
      expect(piyo.name).to eq 'piyoです'
    end
  end
end

before do ~ end の中で Piyo クラスの振る舞いが変えられています。
Piyo オブエジェクトが new メソッドを受け取った場合、 double で作成した偽物オブジェクトを返すようになっています。

このテストは成功するでしょうか??

結論、失敗します

❯ bundle exec rspec rspec_sample_spec.rb
F

Failures:

  1) Piyo#name
     Failure/Error: expect(piyo.name).to eq 'piyoです'
       #<Double "PiyoMock"> received unexpected message :name with (no args)
     # ./rspec_sample_spec.rb:35:in `block (3 levels) in <top (required)>'

Finished in 0.00411 seconds (files took 0.04473 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./rspec_sample_spec.rb:34 # Piyo#name

エラーメッセージの

#<Double "PiyoMock"> received unexpected message :name with (no args)

にも書かれているように、偽物オブジェクトには name メソッドを受け取ったときの振る舞いが書かれていません。
なのでエラーとなります。
つまり、このテストを成功させるためには、Piyo.new で返される偽物オブジェクトに対して name メソッドを受け取ったときの振る舞いを追加すればよいのです。

RSpec.describe Piyo do
  describe '#name' do
    let(:piyo) { Piyo.new }

    before do
      piyo_mock = double('PiyoMock')
      allow(Piyo).to receive(:new).and_return(piyo_mock)
+     allow(piyo_mock).to_receive(:name).and_return('piyoです')
    end

    it do
      expect(piyo.name).to eq 'piyoです'
    end
  end
end

テスト実行結果

❯ bundle exec rspec rspec_sample_spec.rb
.

Finished in 0.00436 seconds (files took 0.04523 seconds to load)
1 example, 0 failures

成功しましたね!
ただ、今回も偽物に対してこちらが指定したふるまいを検証しているだけなので意味のないテストになっています。
次から実際に必要な場面での使い方を見てみましょう。

mockによって、振る舞いを変える必要があるテスト

先程作成したPiyoクラスを少し修正しました。

class Piyo
  def name
+    if sleep?
+      'zzz....'
+    else
+      'piyoです'
+    end
  end

+  def sleep?
+    # Piyo は 23:00 ~ 6:00 まで寝ている
+    current_time = Time.now
+    current_hour = current_time.hour
+
+    current_hour >= 23 || current_hour < 6
+  end
end

Piyoは 23:00 ~ 6:00まで寝ているので、その間 name メソッドを呼んでも反応してくれないような機能を追加してみました。

この機能が追加されたことによって、 name メソッドに必要なテストは以下の 2 種類になりました。

  • piyo が寝ている場合 (sleep? == true) の返り値のテスト
  • piyo が寝ていない場合 (sleep? == false) の返り値のテスト

この sleep メソッドの返り値はテストの実行時間に左右されてしまいます。
これは、先程説明した 外部の要因でテスト結果が変わってしまう場合 の状態である気がしますね。

まずは mock を使用せず、普通にテストを書いてみましょう

RSpec.describe Piyo do
  describe '#name' do
    let(:piyo) { Piyo.new }

    context 'piyo が寝ている場合' do
      it '寝ているため、zzz.... が返ること' do
        expect(piyo.name).to eq 'zzz....'
      end
    end

    context 'piyo が起きている場合' do
      it '起きているため、piyoです が返ること' do
        expect(piyo.name).to eq 'piyoです'
      end
    end
  end
end

寝ている場合、起きている場合のテストを書きたいのですが、今はどうすることもできないのでひとまず同じテストを 2 種類書いてみました。

これを実行してみましょう。

❯ bundle exec rspec rspec_sample_spec.rb
F.

Failures:

  1) Piyo#name piyo が寝ている場合 寝ているため、zzz.... が返ること
     Failure/Error: expect(piyo.name).to eq 'zzz....'

       expected: "zzz...."
            got: "piyoです"

       (compared using ==)
     # ./rspec_sample_spec.rb:31:in `block (4 levels) in <top (required)>'

Finished in 0.01139 seconds (files took 0.04467 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./rspec_sample_spec.rb:30 # Piyo#name piyo が寝ている場合 寝ているため、zzz.... が返ること

この記事を書きながらテストを実行したのですが『寝ている場合』のテストが落ちました。
現在の時刻が 22:30で、 piyo は起きているため、寝ている場合のテストは失敗しました。

一度、このテストコードを実行した際にどの様な流れで実行されるのかを考えてみましょう。

  1. expect の中で piyo が呼ばれる
  2. let(:piyo) の中で定義されている Piyo.new が実行される
    • 返り値は Piyo クラスのオブジェクト
  3. expect(piyo.name)piyo オブジェクトに対して name メソッドが実行される
  4. name メソッドの中で sleep? メソッドが呼ばれる
  5. sleep? メソッドの中で時刻の比較が実行される
    • 実行時刻が 22:30 なので false が返る
  6. name メソッド内の if sleep?false のため、 else のほうが実行される
  7. zzz....が返る

なので、 mock を使用してみましょう。

RSpec.describe Piyo do
  describe '#name' do
    let(:piyo) { Piyo.new }

    context 'piyo が寝ている場合' do
+      before do
+        allow(piyo).to receive(:sleep?).and_return(true)
+      end

      it '寝ているため、zzz.... が返ること' do
        expect(piyo.name).to eq 'zzz....'
      end
    end

    context 'piyo が起きている場合' do
+      before do
+        allow(piyo).to receive(:sleep?).and_return(false)
+      end

      it '起きているため、piyoです が返ること' do
        expect(piyo.name).to eq 'piyoです'
      end
    end
  end
end

このテストの流れを再度考えてみましょう。
context によって変わりますが、大まかな流れはこうです。

  1. before の中の allow(piyo)piyoが呼ばれる
  2. let(:piyo) の中で定義されている Piyo.new が実行される
    • 返り値は Piyo クラスのオブジェクト
  3. 2の返り値であるpiyoオブジェクトがに対して sleep? メソッドが実行されたときに trueが返るように偽の振る舞いが与えられる(もうひとつの context では false を返すように振る舞いが与えられている)
  4. expect(piyo.name)piyo オブジェクトに対して name メソッドが実行される
  5. name メソッドの中で sleep? メソッドが呼ばれる
  6. 3 で与えられた偽の振る舞いによって、こちらが指定した返り値が返る( true or false )
  7. name メソッド内の if sleep? が、こちらが指定して返り値の分岐の方を処理する

今まで現在時刻によって変わっていた sleep? メソッドの返り値が、 mock によってこちらの指定した振る舞いに変わったおかげで、無事テストを通過することができました。

今回のまとめ

これまでの話のまとめです。

  • Ruby は全てオブジェクト
  • double を使用することで偽物のオブジェクトを生成できる
  • 偽物オブジェクトは、こちらが振る舞いを指定しないとなにもできない
  • RSpec のモックではオブジェクトに対して振る舞いをしていすることができる

次回

実際に開発現場で使用される mock のパターンは何パターン化に分けられるので、次回はそれについて書こうと思います!

Discussion