15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】Minitestでのスタブとモックの使い方 入門

Posted at

はじめに

この記事は、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行ずつ確認していきましょう。

天気取得APIの作成
weather_api_client = WeatherApiClient.new

↑ここでは、普通にAPIのインスタンスを作成しています。普通ですね。

slack 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クライアントは、

  1. まず最初に今日の天気を通知するために#chat_postMessage()が呼び出される
  2. 次に、明日の天気を通知するために#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

こんな形でメソッド定義だけのクラスを用意してあげてください。

15
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?