RSpec の mock が チョットワカル ようになる記事
はじめまして。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)が各オブジェクトにひとつずつ割り当てられています。このメソッドはその値を返します。
解説は難しいので簡単に説明すると、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_instance
と another_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 さんという方の記事がとてもわかりやすいです。
もしこれまでの話が全然分からなければ、この記事の図や解説を読んでみてください🙇♀
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 は起きているため、寝ている場合のテストは失敗しました。
一度、このテストコードを実行した際にどの様な流れで実行されるのかを考えてみましょう。
-
expect
の中でpiyo
が呼ばれる -
let(:piyo)
の中で定義されているPiyo.new
が実行される- 返り値は Piyo クラスのオブジェクト
-
expect(piyo.name)
でpiyo
オブジェクトに対してname
メソッドが実行される -
name
メソッドの中でsleep?
メソッドが呼ばれる -
sleep?
メソッドの中で時刻の比較が実行される- 実行時刻が 22:30 なので
false
が返る
- 実行時刻が 22:30 なので
-
name
メソッド内のif sleep?
がfalse
のため、else
のほうが実行される -
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
によって変わりますが、大まかな流れはこうです。
-
before
の中のallow(piyo)
でpiyo
が呼ばれる -
let(:piyo)
の中で定義されているPiyo.new
が実行される- 返り値は Piyo クラスのオブジェクト
- 2の返り値である
piyo
オブジェクトがに対してsleep?
メソッドが実行されたときにtrue
が返るように偽の振る舞いが与えられる(もうひとつの context では false を返すように振る舞いが与えられている) -
expect(piyo.name)
でpiyo
オブジェクトに対してname
メソッドが実行される -
name
メソッドの中でsleep?
メソッドが呼ばれる - 3 で与えられた偽の振る舞いによって、こちらが指定した返り値が返る( true or false )
-
name
メソッド内のif sleep?
が、こちらが指定して返り値の分岐の方を処理する
今まで現在時刻によって変わっていた sleep?
メソッドの返り値が、 mock によってこちらの指定した振る舞いに変わったおかげで、無事テストを通過することができました。
今回のまとめ
これまでの話のまとめです。
- Ruby は全てオブジェクト
-
double
を使用することで偽物のオブジェクトを生成できる - 偽物オブジェクトは、こちらが振る舞いを指定しないとなにもできない
- RSpec のモックではオブジェクトに対して振る舞いをしていすることができる
次回
実際に開発現場で使用される mock のパターンは何パターン化に分けられるので、次回はそれについて書こうと思います!
Discussion