はじめに
この記事は、RubyやRailsプロジェクトでMinitestを使ったことがあるけれど、スタブやモックについて理解がちょっと曖昧かも...という方のためにスタブやモックの基本的な考え方、使い方を解説するものです。
スタブ、モックってそもそも何?
まずは、言葉の意味や基本的な使い方を見ていきます。
スタブとその使い方
スタブというのは、「代用品」みたいな意味合いで使われます。Minitestでは主に、メソッドスタブというものを用意します。つまり、「代用メソッド」ですね。以下のように、自分の好きなようにメソッドの戻り値をセットできます。
require 'test_helper'
require 'minitest/autorun'
# 自己紹介ができる「パーソン」というクラスを例に取る
class Person
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def introduce_myself
"My name is #{name}. I'm #{age} years old."
end
end
class SampleTest < ActiveSupport::TestCase
test "インスタンスメソッドをスタブするサンプル" do
# 普通に自己紹介
person = Person.new('Jonny', 20)
puts person.introduce_myself
# => "My name is Jonny. I'm 20 years old."
# 自己紹介メソッドを置き換える
person.stub(:introduce_myself, '恥の多い人生を送ってきました。') do
puts person.introduce_myself
# => "恥の多い人生を送ってきました。"
end
# スタブはブロックの中だけ有効
puts person.introduce_myself
# => "My name is Jonny. I'm 20 years old."
end
end
1つめの引数に置き換えたいメソッド名のシンボル、2つめの引数に置き換えたい戻り値を指定します(厳密に言うと2つの引数に渡せるのは戻り値だけではないのですが、それは後述します。)
上記の例ではインスタンスメソッドを置き換えましたが、クラスメソッドも置き換えることができます。
class Person
attr_reader :name, :age
def initialize(name, age)
@name = name
@age = age
end
def self.all
# 普通ならデータベースなどから値を取得するが、説明のために固定値にしている
[new('Jonny', 20), new('Beth', 19)]
end
end
class SampleTest < ActiveSupport::TestCase
test 'クラスメソッドをスタブするサンプル' do
# 普通にallメソッドを実行する
pp Person.all
# => [#<Person:111 @age=20, @name="Jonny">, #<Person:222 @age=19, @name="Beth">]
# do~endの中で、クラスメソッドの戻り値を置き換えることができる
Person.stub(:all, [Person.new('太宰', 38)]) do
pp Person.all
# => [#<Person:333 @age=38, @name="太宰">]
end
end
end
上記のように、スタブは特定のメソッドの処理結果を自分の好きなように置き換えることができるものと覚えておきましょう。
モックとその使い方
モックとは、モックアップまたはモックアップオブジェクトの略で、「模造品」みたいな意味合いです。スタブはメソッドを置き換えましたが、モックはまさに「模造品」という名の通り、指定した振る舞いをしてくれるオブジェクトです。
class SampleTest < ActiveSupport::TestCase
test 'モックのサンプル' do
person_mock = Minitest::Mock.new
person_mock.expect(:say_hello, 'Hello!')
pp person_mock.say_hello
# => "Hello!"
# 引数を取るパターン
person_mock.expect(:greet, 'Hi, Jonny', ['Jonny'])
pp person_mock.greet('Jonny')
# => "Hi, Jonny"
# 引数を複数取るパターン
person_mock.expect(:greet, 'Hi, Jonny. Good morning!', ['Jonny', 'morning'])
pp person_mock.greet('Jonny', 'morning')
# => "Hi, Jonny. Good morning!"
end
end
モックは、expect
というメソッドを使ってまず期待する振る舞いを指定します。expectしたあとにシンボルで指定したメソッドを呼び出すと、expect
の2つめの引数で指定した値を返却してくれていますね。
引数を取るメソッドの場合は、expect
の3つめの引数に渡される引数を配列で指定します。なぜ配列なのかというと、配列の中身がそれぞれ何番目の引数なのかに対応しているからです(上記の引数を複数取るパターンを参照。)
モックで重要なのは、期待する(=expect
で指定する)引数が一致していないとエラーになるということです。
test '引数が実際と違うとエラーになるサンプル' do
person_mock = Minitest::Mock.new
person_mock.expect(:greet, 'Hi, Jonny', ['Jonny'])
puts person_mock.greet('Beth')
# => Minitest::UnexpectedError: MockExpectationError: mocked method :greet called with unexpected arguments ["Beth"]
end
上記、expect
で期待している引数は'Jonny'
なのに、実際にgreet
を呼び出している箇所では'Beth'
を渡してしまっているので、「期待してない引数で呼び出されてるよ!」とエラーになります。
いつ使うの?
スタブもモックも、要は「何かを置き換えたい」という目的のために使うのですが、これはどんなときに発生するのでしょうか?
答えは、自分たちが触っているシステムの外部になにかの処理をさせている場合です。
例えば、「今日の天気をslackに通知する」といった簡単なアプリケーションを想定してみましょう。このアプリでは、架空のWeatherApiClient
というgemを利用しているとします。
このAPIは非常にシンプルで、get_current_weather
というメソッドを実行すれば、'sunny'
や'rainny'
、'cloudy'
といった文字を返すことができます。
アプリのコードはこんなイメージです。
class WeatherApp
def initialize(weather_api_client, slack_client)
@weather_api_client = weather_api_client
@slack_client = slack_client
end
def notify_current_weather
weather = @weather_api_client.get_current_weather
weather_text = case weather
when 'sunny'
'晴れ'
when 'cloudy'
'曇り'
when 'rainy'
'雨'
end
@slack_client.chat_postMessage(channel: 'お天気通知チャンネル', text: "今日の天気は#{weather_text}です。")
end
end
# 接続情報は省略
app = WeatherApp.new(WeatherApiClient.new, Slack::Web::Client.new)
app.notify_current_weather
スタブは単純な置き換えに使う
このアプリのテストコードを書くにあたって、WeatherApiClientを実際に呼び出したくありません...。というのも、
- 何らかの事情でAPIがダウンしてしまっていた場合に、自分のコードのせいではないのにテストが失敗してしまう
- APIの利用制限があると、自動テストで何回も実行すると利用制限に引っかかってしまう
- たまたま通信環境が良くなくてテストが失敗してしまう
などなど、壊れやすいテストになってしまうからです。そこで、WeatherApiClientで現在の天気を取得する処理をテスト内で置き換えることで、何度実行しても安定するテストにしたいがためにstubを利用します。
class WeatherApp
def initialize(weather_api_client, slack_client)
@weather_api_client = weather_api_client
@slack_client = slack_client
end
def notify_current_weather
weather = @weather_api_client.get_current_weather
weather_text = case weather
when 'sunny'
'晴れ'
when 'cloudy'
'曇り'
when 'rainy'
'雨'
end
# 一旦標準出力に天気だけ表示させる
pp weather_text
# @slack_client.chat_postMessage(channel: 'お天気通知チャンネル', text: "今日の天気は#{weather_text}です。")
end
end
class SampleTest < ActiveSupport::TestCase
test 'まずは天気取得を置き換える' do
weather_api_client = WeatherApiClient.new
app = WeatherApp.new(weather_api_client, nil) # 今はslack_clientを使わないのでnilとしている
weather_api_client.stub(:get_current_weather, 'sunny') do
app.notify_current_weather
end
end
end
# => "晴れ"
こんなふうに、単純にメソッドの実行結果を置き換えたい場合はメソッドスタブを行います。
モックは適切に実行されたかの検証につかう
次に、slack通知をちゃんとできているかテストコードを書くことを考えていきましょう。テストコードでは、「実際にslackにメッセージが送信されている」ということまでは残念ながらテストできません...。slackはあくまで外部サービスなので、slackの挙動まで完全にテストすることはできないんです。
なので、現実的な落とし所として、「SlackのAPIクライアントの適切なメソッドを呼び出し、適切な引数を渡している」という部分だけはせめてテストするという形になります。そして、そのテストでモックオブジェクトを利用するわけですね。
class WeatherApp
def initialize(weather_api_client, slack_client)
@weather_api_client = weather_api_client
@slack_client = slack_client
end
def notify_current_weather
weather = @weather_api_client.get_current_weather
weather_text = case weather
when 'sunny'
'晴れ'
when 'cloudy'
'曇り'
when 'rainy'
'雨'
end
@slack_client.chat_postMessage(channel: 'お天気通知チャンネル', text: "今日の天気は#{weather_text}です。")
end
end
class SampleTest < ActiveSupport::TestCase
test 'slackに今日の天気を通知する' do
weather_api_client = WeatherApiClient.new
slack_client_mock = Minitest::Mock.new
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '今日の天気は晴れです。')
app = WeatherApp.new(weather_api_client, slack_client_mock)
weather_api_client.stub(:get_current_weather, 'sunny') do
app.notify_current_weather
end
slack_client_mock.verify
end
end
テストコードを1行ずつ確認していきましょう。
weather_api_client = WeatherApiClient.new
↑ここでは、普通にAPIのインスタンスを作成しています。普通ですね。
slack_client_mock = Minitest::Mock.new
↑slack APIクライアントについては意図した引数で#chat_postMessage()
が呼び出されていることを検証したいので、モックオブジェクトを作成します。
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '今日の天気は晴れです。')
↑引数が多いので混乱してしまいますが...
まず、1つめの引数は呼び出されるメソッド名をシンボルにしたものです。今回は、WeatherApp
の#notify_current_weather()
というメソッドを実行すると、内部でslackの#chat_postMessage()
が呼び出されるはずですよね。
2つめの引数は、戻り値の指定です。今回特に戻り値は気にしていない(=なんでもいい)ので、nilとしています。このように、テストに関係なかったり大事じゃなかったりする値はnilを指定することが多いです。
3つめの引数は、#chat_postMessage()
に実際どんな引数が渡されるかをセットするんでしたね。しかし、ここでは空っぽの配列を指定しています。
というのも、#chat_postMessage()
をよく見ると、ただの引数でなくキーワード引数を渡しているからです。そして、キーワード引数は#expect()
の中でもキーワード引数として指定してあげることで検証を行うことができます。
app = WeatherApp.new(weather_api_client, slack_client_mock)
weather_api_client.stub(:get_current_weather, 'sunny') do
app.notify_current_weather
end
↑これまででスタブやモックの設定ができたので、天気取得APIの戻り値を'sunny'
に指定してアプリを実行します。すると、お天気アプリの中で
-
@weather_api.get_current_weather
の返却値が'sunny'
に置き換わる - slack_client_mockの
#chat_postMessage()
を呼び出す
ということが行われます。
slack_client_mock.verify
↑最後に、モックオブジェクトの#verify()
を実行することで、テスト内でちゃんと#expect()
で指定した通りに呼び出されたかを検証しています。
バグがある場合
例えば、「本来は天気が晴れの場合に"晴れ"と書きたいのに"ハレ"と日本語を打ち間違えてしまっていた...」みたいなケアレスミスがあったときを想定してみましょう。そのときにもう一度このテストを実行すると、
Minitest::UnexpectedError: MockExpectationError:
mocked method :chat_postMessage called with unexpected keyword arguments
{:channel=>"お天気通知チャンネル", :text=>"今日の天気は晴れです。"}
vs
{:channel=>"お天気通知チャンネル", :text=>"今日の天気はハレです。"}
こんなエラーが発生して、テストが失敗します。何がだめだったのか一目瞭然ですね。
また、「slack APIクライアントを呼び出すべきなのにコメントアウトしてしまっていた...」みたいなケースだと、
Minitest::UnexpectedError: MockExpectationError:
expected chat_postMessage(:channel=>"お天気通知チャンネル", :text=>"今日の天気は晴れです。") => nil
こんなふうにエラーになります。これは、slack_client_mock.verify
によってちゃんと呼び出されたかを検証している箇所でエラーとなっています。
まとめ
以上がスタブとモックの基本的な使い方でした。
- スタブは検証の必要もないような簡単な値の置き換えをしたいときに使う
- モックは呼び出しや引数が思った通りのものになっているか検証したいときに使う
これが理解できていれば、基本はマスターです。
応用1:期待する回数
上記のサンプルでは、今日の天気を通知するだけのアプリでしたが、同時に明日の天気も通知するようなアプリに変更するとしましょう。コードは以下のようになります。
class WeatherApp
def initialize(weather_api_client, slack_client)
@weather_api_client = weather_api_client
@slack_client = slack_client
end
def notify_weather
today_weather = localize_weather_text(@weather_api_client.get_current_weather)
tomorrow_weather = localize_weather_text(@weather_api_client.get_tomorrow_weather)
@slack_client.chat_postMessage(channel: 'お天気通知チャンネル', text: "今日の天気は#{today_weather}です。")
@slack_client.chat_postMessage(channel: 'お天気通知チャンネル', text: "明日の天気は#{tomorrow_weather}です。")
end
private
def localize_weather_text(str)
case str
when 'sunny'
'晴れ'
when 'cloudy'
'曇り'
when 'rainy'
'雨'
end
end
end
ポイントは、slackに2回通知を行っているという点です。それでは、テストコードを書いていきましょう。
class SampleTest < ActiveSupport::TestCase
test 'slackに今日と明日の天気を通知する' do
weather_api_client = WeatherApiClient.new
slack_client_mock = Minitest::Mock.new
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '今日の天気は晴れです。')
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '明日の天気は雨です。')
app = WeatherApp.new(weather_api_client, slack_client_mock)
weather_api_client.stub(:get_current_weather, 'sunny') do
weather_api_client.stub(:get_tomorrow_weather, 'rainy') do
app.notify_weather
end
end
slack_client_mock.verify
end
end
今回の実装で、slack APIクライアントは、
- まず最初に今日の天気を通知するために
#chat_postMessage()
が呼び出される - 次に、明日の天気を通知するために
#chat_postMessage()
が呼び出される
と2回呼び出されます。そして、これをテストするにあたっては、この順番で2回expect
を記述します。
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '今日の天気は晴れです。')
slack_client_mock.expect(:chat_postMessage, nil, [],
channel: 'お天気通知チャンネル',
text: '明日の天気は雨です。')
#expect()
は呼び出される回数分だけ書くとおぼえておきましょう。
応用2:#stub()
の第二引数には手続きオブジェクトも指定できる
weather_api_client.stub(:get_current_weather, 'sunny') do
app.notify_current_weather
end
これまでは、#stub()
の第二引数に特定の値を渡す例を説明してきましたが、実はここに手続きオブジェクトを渡すことで、その手続きをその場で実行させることが可能です。
(rubydocより)
Method: Object#stub
#stub(name, val_or_callable, &block) ⇒ Object
Add a temporary stubbed method replacing name for the duration of the block. If val_or_callable responds to #call, then it returns the result of calling it, otherwise returns the value as-is. Cleans up the stub at the end of the block.
太字の部分を訳すと、「もし第二引数であるval_or_callble
が#call()
というメソッドに応答するなら、呼び出した結果を返却し、そうでなければ値そのものを返却する」となります。
これは、意図的に例外を発生させたいときなんかに使ったりします。
raise_proc = proc { raise StandardError }
assert_raise do
weather_api_client.stub(:get_current_weather, raise_proc) do
app.notify_current_weather
# 以下で例外発生時に行う処理のテストを記述する
end
end
rubydocにある通り、テストの中でweather_api_client
の#get_current_weather()
が実行されると、raise_proc.call
が実行されます。procオブジェクトは#call()
することで中身の処理が実行されるので、その時点で例外が発生するというカラクリです。
補足
WeatherApiClientは架空なので、実際にテストコードを動かしてみたい方は
class WeatherApiClient
def get_current_weather; end
def get_tomorrow_weather; end
end
こんな形でメソッド定義だけのクラスを用意してあげてください。