8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

minitestでモック、スタブする(RR、WebMock、MiniTest::Mockを使う)

Last updated at Posted at 2020-06-21

この記事について

仕事で、railsのテストフレームワークにminitestを使っています。そして、railsプロジェクトの中では、モックやスタブをするときに、RR、WebMockといったgemや、minitest標準のモックであるMiniTest::Mockが使われています。
テストを書くときに、モックやスタブの書き方に戸惑うことが多くありました。
この記事では、テストダブルとはどんなものか、モックとスタブの違いはなにか、RR、WebMock、MiniTest::Mockそれぞれの使い方について記述します。

目次

テストダブル(モック、スタブ)とは

まず、テストダブルとはどんなものでしょう。また、モック、スタブの違いはなんでしょう。

  • テストダブルとは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品(ダブルは代役、影武者を意味する)
  • モックもスタブも、テストダブルの一種

テストダブルの5つのバリエーション

テストダブルは、xUnit Test Patternの書籍によると、5つのバリエーションがあります。
私は、スタブとモックをよく混同していましたが、スタブは依存するコンポーネントを置き換えるものであり、モックはテスト対象コードからの出力が期待通りであるか検証するものである、ということを理解しました。

  1. テストスタブ

    • テスト対象コードが依存する実際のコンポーネントを置き換えるために使用する
    • テスト時の呼び出しときに、あらかじめ決められた値を返すように設定する
  2. テストスパイ

    • テスト対象コードが実行された時の間接的な出力をキャプチャし、後のテストによる検証のために保存する
    • 呼び出しに基づく情報を記録するスタブ
  3. モックオブジェクト

    • テスト対象コードが実行される際に、テスト対象コードからの間接的な出力を検証するために使用するオブジェクト
    • 間接出力の検証に重きが置かれる
    • 期待した呼び出しが行われたかを検証できる(どんな引数で呼ばれたか、など)
  4. フェイクオブジェクト

    • テスト対象コードの依存コンポーネントを置き換えるオブジェクト
    • 依存コンポーネントと同じ機能を実装しているが、よりシンプルな方法で実装されている
    • フェイクを利用する一般的な理由は、実際の依存コンポーネントがまだ利用できない、遅すぎる、または副作用があるためにテスト環境で使用できない、などがある
  5. ダミーオブジェクト

    • テスト対象コードのメソッドシグネチャの中に、パラメータとしてオブジェクトを必要とする場合に、ダミーオブジェクトを使う(テストもテスト対象コードでもこのオブジェクトを気に掛けていない場合)

参考
Test Double / xUnit Patterns.com
wiki テストダブル
自動テストのスタブ・スパイ・モックの違い

RR

RRは、Rubyのテストダブルのフレームワークのgemです。
読み方は、'Double Ruby'と読むそうです。
RRにはアダプタが用意されているので、RSpec、Test::Unit、MiniTest/MiniSpecなどのテストフレームワークと統合することができるようです。

GitHub:https://github.com/rr/rr
公式:http://rr.github.io/rr/

RR is a test double framework that features a rich selection of double techniques and a terse syntax.
(訳:RRは、豊富なダブルテクニックと簡潔な構文を特徴とするテストダブルフレームワークです。)

RRの使い方

RRには、モック、スタブ、プロキシ、スパイが実装されています。
RRのGitHubページのサンプル通りですが、こんな感じで書けます。

スタブ

stubで、スタブする(実際の呼び出しを置き換える)ことができます。

# 何も返さないメソッドをスタブする
stub(object).foo
stub(MyClass).foo

# 常に値を返すスタブメソッド
stub(object).foo { 'bar' }
stub(MyClass).foo { 'bar' }

# 特定の引数で呼び出されたときに値を返すスタブメソッド
stub(object).foo(1, 2) { 'bar' }
stub(MyClass).foo(1, 2) { 'bar' }

詳細はstubのページ参照。

モック

mockで、期待した呼び出しが行われるか検証するモックを作成できます。

# メソッドが呼ばれることを期待する
# objectのfooメソッドが呼ばれることを期待する
mock(object).foo
mock(MyClass).foo

# メソッドに期待値を作成し、常に指定した値を返すようにスタブする
# objectのfooメソッドが'bar'を返すことを期待する
mock(object).foo { 'bar' }
mock(MyClass).foo { 'bar' }

# 特定の引数を持つメソッドに期待値を作成し、それを返すためにスタブを作成する
# objectのfooメソッドが引数1, 2で呼ばれ、'bar'を返すことを期待する
mock(object).foo(1, 2) { 'bar' }
mock(MyClass).foo(1, 2) { 'bar' }

詳細はmockページ参照。

スパイ

stubと、assert_receivedexpect(xxx).to have_receivedの記述を組み合わせて、スパイ(呼び出された情報を記録するスタブ)が書けるようです。
(公式GitHubには、Test::UnitとRspecでの書き方はありましたが、minitestでの書き方は載っていませんでした。)

# RSpec
stub(object).foo
expect(object).to have_received.foo

# Test::Unit
stub(object).foo
assert_received(object) {|o| o.foo }

プロキシ

proxyを使うと、メソッドを完全にオーバーライドせずにインターセプトして新しい戻り値を設定したスタブやモックが作れるようです。

# 既存のメソッドを完全にオーバーライドせずにインターセプトして
# 既存の値から新しい戻り値を取得する
stub.proxy(object).foo {|str| str.upcase }
stub.proxy(MyClass).foo {|str| str.upcase }

# 上記の例でやってることに加えて、さらに期待値のモックを作成する
mock.proxy(object).foo {|str| str.upcase }
mock.proxy(MyClass).foo {|str| str.upcase }

# クラスの新しいメソッドをインターセプトし、戻り値にダブルを定義する
stub.proxy(MyClass).new {|obj| stub(obj).foo; obj }

# 上記の例でやってることに加えて、.newに期待値のモックを作成する
mock.proxy(MyClass).new {|obj| stub(obj).foo; obj }

詳細はmock.proxystub.proxyページ参照。

クラスのインスタンス

any_instance_ofで、インスタンス作成時にメソッドをスタブしたりモックできます。また、stub.proxyを使うと、インスタンスそのものにアクセスできるようになります。

# MyClass のインスタンスの作成時にメソッドをスタブする
any_instance_of(MyClass) do |klass|
  stub(klass).foo { 'bar' }
end

# インスタンス自体にアクセスできるようにする別の方法
# MyClass.newされたインスタンスobjをスタブしている
stub.proxy(MyClass).new do |obj|
  stub(obj).foo { 'bar' }
end

詳細は#any_instance_ofページ参照。

Pureなモックオブジェクト

モックのためだけにオブジェクトを使用したい場合は、空のオブジェクトを作成することで可能です。

mock(my_mock_object = Object.new).hello

ショートカットとしてmock!を使うこともできます。

# 空の #hello メソッドを持つ新しいモックオブジェクトを作成し、そのモックを取得する
# モックオブジェクトを #subject メソッドで取得できる
my_mock_object = mock!.hello.subject

#dont_allow

#dont_allow#mockの逆で、ダブルには絶対にコールされないという期待を設定します。ダブルが実際に呼び出された場合、TimesCalledErrorが発生します。

dont_allow(User).find('42')
User.find('42') # raises a TimesCalledError

その他

RRは#method_missingを使ってメソッドの期待値を設定しているそうです。これにより、#should_receive#expectsメソッドを使う必要がありません。
また、引数の期待値を設定するために#withメソッドを使う必要がないそうです。(使いたければ使えます)

mock(my_object).hello('bob', 'jane')
mock(my_object).hello.with('bob', 'jane')  # withがついているが上と同じ

RRは、ブロックを使って戻り値を設定することをサポートしています。(お好みで、#returnsを使うことができます)

mock(my_object).hello('bob', 'jane') { 'Hello Bob and Jane' }
mock(my_object).hello('bob', 'jane').returns('Hello Bob and Jane')  # returnsがついているが上と同じ

#times#at_least#at_most#any_timesメソッドでモックの期待する呼び出し回数を調整できます。#with_any_argsでどんな引数での呼び出しも許容したり、#with_no_argsで引数なしの呼び出しを期待したり、#neverでメソッドが呼ばれないことを期待したりできます。
もっと詳しい情報は、API overviewを参照ください。

WebMock

WebMockは、RubyでHTTPリクエストのスタブやモックを設定するためのgemです。
RRとの違いは、HTTPリクエストに特化している部分でしょうか。

GitHub:https://github.com/bblimke/webmock

Library for stubbing and setting expectations on HTTP requests in Ruby.

機能として、以下を提供しています。

  • HTTP リクエストを低レベルの http クライアントの lib レベルでスタブ化 (HTTP ライブラリを変更する際にテストを変更する必要はない)
  • HTTP リクエストに対する期待値の設定と検証
  • メソッド、URI、ヘッダ、ボディに基づいたリクエストのマッチング
  • 異なる表現における同じ URI のスマートマッチング (エンコードされた形式と非エンコードされた形式)
  • 異なる表現での同じヘッダのスマートなマッチング
  • Test::Unit、RSpec、minitest のサポート

WebMockの使い方

WebMockのGitHubページのサンプルから、使い方を抜粋します。

スタブ

stub_requestでリクエストをスタブすることができます。

uri のみに基づくスタブ付きリクエストとデフォルトのレスポンス

stub_request(:any, "www.example.com")    # スタブ(anyを使う)
Net::HTTP.get("www.example.com", "/")    # ===> Success

メソッド、URI、ボディ、ヘッダに基づいたスタブリクエスト

# スタブ
stub_request(:post, "www.example.com").
  with(body: "abc", headers: { 'Content-Length' => 3 })

uri = URI.parse("http://www.example.com/")
req = Net::HTTP::Post.new(uri.path)
req['Content-Length'] = 3
res = Net::HTTP.start(uri.host, uri.port) do |http|
  http.request(req, "abc")
end    # ===> Success

リクエストボディをハッシュと照合

ボディが、URL-Encode、JSON、XML のいずれかのとき、リクエストボディをハッシュと照合できます。

# スタブ
stub_request(:post, "www.example.com").
  with(body: {data: {a: '1', b: 'five'}})

RestClient.post('www.example.com', "data[a]=1&data[b]=five",
  content_type: 'application/x-www-form-urlencoded')    # ===> Success
RestClient.post('www.example.com', '{"data":{"a":"1","b":"five"}}',
  content_type: 'application/json')    # ===> Success
RestClient.post('www.example.com', '<data a="1" b="five" />',
  content_type: 'application/xml')    # ===> Success

hash_includingを使うと、部分的なハッシュとリクエストボディを照合できます。

# bodyをhash_includingで部分的なハッシュで照合
# bodyが全て一致していなくても照合できる
stub_request(:post, "www.example.com").
  with(body: hash_including({data: {a: '1', b: 'five'}}))

RestClient.post('www.example.com', "data[a]=1&data[b]=five&x=1",
:content_type => 'application/x-www-form-urlencoded')    # ===> Success

クエリパラメータの照合

ハッシュでクエリパラメータを照合できます。

# スタブ
stub_request(:get, "www.example.com").with(query: {"a" => ["b", "c"]})

RestClient.get("http://www.example.com/?a[]=b&a[]=c")    # ===> Success

ボディと同様、hash_includingで部分ハッシュとクエリパラメータと照合できます。

stub_request(:get, "www.example.com").
  with(query: hash_including({"a" => ["b", "c"]}))

RestClient.get("http://www.example.com/?a[]=b&a[]=c&x=1")    # ===> Success

hash_excludingを使うと、クエリパラメータ に含まれていない状態に照合できます。

stub_request(:get, "www.example.com").
  with(query: hash_excluding({"a" => "b"}))

RestClient.get("http://www.example.com/?a=b")    # ===> Failure
RestClient.get("http://www.example.com/?a=c")    # ===> Success

カスタムレスポンスを返すスタブ

to_returnでカスタムレスポンスを返すスタブを設定できます。

# スタブ
stub_request(:any, "www.example.com").
  to_return(body: "abc", status: 200,
    headers: { 'Content-Length' => 3 })

Net::HTTP.get("www.example.com", '/')    # ===> "abc"

エラーをraiseする

# クラスで宣言された例外のraise
stub_request(:any, 'www.example.net').to_raise(StandardError)
RestClient.post('www.example.net', 'abc')    # ===> StandardError

# 例外インスタンスのraise
stub_request(:any, 'www.example.net').to_raise(StandardError.new("some error"))

# 例外メッセージで例外をraise
stub_request(:any, 'www.example.net').to_raise("some error")

to_timeoutで、タイムアウト例外のraiseもできます。

stub_request(:any, 'www.example.net').to_timeout

RestClient.post('www.example.net', 'abc')    # ===> RestClient::RequestTimeout

繰り返すリクエストに複数の異なるレスポンス

リクエストが繰り返された時、複数の異なるレスポンスを返すことができます。
また、to_returnto_raiseto_timeoutthenでつないで複数のレスポンスを返したりtimesを使ってレスポンスを返す回数と指定することもできます。

stub_request(:get, "www.example.com").
  to_return({body: "abc"}, {body: "def"})
Net::HTTP.get('www.example.com', '/')    # ===> "abc\n"
Net::HTTP.get('www.example.com', '/')    # ===> "def\n"

# すべてのレスポンスが使用された後、最後のレスポンスが無限に返される
Net::HTTP.get('www.example.com', '/')    # ===> "def\n"

ネットワークへのリアルリクエストを許可または無効化

WebMock.allow_net_connect!で、実際のネットワークへのリクエストを許可できます。WebMock.disable_net_connect!で無効化することもできます。
特定のリクエストを許可しながら、外部リクエストを無効にすることもできます。

# 実際のネットワークへのリクエストを許可
WebMock.allow_net_connect!
stub_request(:any, "www.example.com").to_return(body: "abc")

Net::HTTP.get('www.example.com', '/')    # ===> "abc"
Net::HTTP.get('www.something.com', '/')    # ===> /.+Something.+/

# 実際のネットワークへのリクエストを無効化
WebMock.disable_net_connect!

Net::HTTP.get('www.something.com', '/')    # ===> Failure

他にも、様々な方法で、スタブすることができます。他の使い方のサンプルコードはStubbingページを参照ください。

期待値の設定(モック)

WebMockのGitHubページには、Test::UnitRSpecでの期待値の設定方法の記述はありましたが、minitestについて記述がありませんでした。
minitestは、Test::Unitと同様の書き方ができそうです(参考)。

Test::Unit/minitest

assert_requestedassert_not_requestedを使います。

require 'webmock/test_unit'

stub_request(:any, "www.example.com")

uri = URI.parse('http://www.example.com/')
req = Net::HTTP::Post.new(uri.path)
req['Content-Length'] = 3
res = Net::HTTP.start(uri.host, uri.port) do |http|
  http.request(req, 'abc')
end

assert_requested :post, "http://www.example.com",
  headers: {'Content-Length' => 3}, body: "abc",
  times: 1    # ===> Success

assert_not_requested :get, "http://www.something.com"    # ===> Success

assert_requested(:post, "http://www.example.com",
  times: 1) { |req| req.body == "abc" }

スタブを使って期待値を設定するためには、以下のように書きます。

stub_get = stub_request(:get, "www.example.com")
stub_post = stub_request(:post, "www.example.com")

Net::HTTP.get('www.example.com', '/')

assert_requested(stub_get)
assert_not_requested(stub_post)

Rspec

expecthave_requestedを組み合わせて書きます。

require 'webmock/rspec'

expect(WebMock).to have_requested(:get, "www.example.com").
  with(body: "abc", headers: {'Content-Length' => 3}).twice

expect(WebMock).not_to have_requested(:get, "www.something.com")

expect(WebMock).to have_requested(:post, "www.example.com").
  with { |req| req.body == "abc" }
# Note that the block with `do ... end` instead of curly brackets won't work!
# Why? See this comment https://github.com/bblimke/webmock/issues/174#issuecomment-34908908

expect(WebMock).to have_requested(:get, "www.example.com").
  with(query: {"a" => ["b", "c"]})

expect(WebMock).to have_requested(:get, "www.example.com").
  with(query: hash_including({"a" => ["b", "c"]}))

expect(WebMock).to have_requested(:get, "www.example.com").
  with(body: {"a" => ["b", "c"]},
    headers: {'Content-Type' => 'application/json'})

a_requesthave_been_madeを組み合わせて以下のようにも書けます。

expect(a_request(:post, "www.example.com").
  with(body: "abc", headers: {'Content-Length' => 3})).
  to have_been_made.once

expect(a_request(:post, "www.something.com")).to have_been_made.times(3)

expect(a_request(:post, "www.something.com")).to have_been_made.at_least_once

expect(a_request(:post, "www.something.com")).
  to have_been_made.at_least_times(3)

expect(a_request(:post, "www.something.com")).to have_been_made.at_most_twice

expect(a_request(:post, "www.something.com")).to have_been_made.at_most_times(3)

expect(a_request(:any, "www.example.com")).not_to have_been_made

expect(a_request(:post, "www.example.com").with { |req| req.body == "abc" }).
  to have_been_made

expect(a_request(:get, "www.example.com").with(query: {"a" => ["b", "c"]})).
  to have_been_made

expect(a_request(:get, "www.example.com").
  with(query: hash_including({"a" => ["b", "c"]}))).to have_been_made

expect(a_request(:post, "www.example.com").
  with(body: {"a" => ["b", "c"]},
    headers: {'Content-Type' => 'application/json'})).to have_been_made

スタブを使って期待値を設定するためには、以下のように書きます。

stub = stub_request(:get, "www.example.com")
# ... make requests ...
expect(stub).to have_been_requested

詳細は期待値の設定ページを参照ください。

その他

WebMock.reset!で現在のスタブとリクエストの履歴をすべてリセットしたり、WebMock.reset_executed_requests!で実行されたリクエストのカウンタのみをリセットできます。
WebMock.disable!WebMock.enable!で、WebMock を無効にしたり有効にしたり、一部の http クライアントアダプタのみを有効にすることができます。
他の機能については、WebMockのGitHubページのサンプルコードを参照ください。

MiniTest::Mock

最後に、MiniTest::Mockは、minitestに含まれているモックオブジェクトのフレームワークです。

公式ドキュメント:http://docs.seattlerb.org/minitest/Minitest/Mock.html

A simple and clean mock object framework.
All mock objects are an instance of Mock.
(シンプルでクリーンなモックオブジェクトフレームワークです。すべてのモックオブジェクトは MiniTest::Mockのインスタンスです。)

MiniTest::Mockの使い方

スタブ

オブジェクトをスタブするstubは、Minitest::Mock のオブジェクト拡張です。
スタブが有効なのはブロック内のみで、ブロックの最後にスタブはクリーンアップされます。また、スタブする前にメソッド名が存在している必要があります。
stub_any_instanceメソッドは、クラスのインスタンス上にメソッドスタブを作成できます。minitest-stub_any_instance_ofのgemを導入すると使うことができます。

  • stub:オブジェクトのメソッドをスタブする
  • stub_any_instance_of:クラスのインスタンスメソッドをスタブする

stubのサンプルコードです。

require 'minitest/autorun'

# スタブする対象のクラス
class Hello
  def say
    'Hello!'
  end
end

hello = Hello.new
# helloオブジェクトのsayメソッドが'Hello, this is from stub!'を返すようにスタブする
hello.stub(:say, 'Hello, this is from stub!') do
  hello.say  #==> "Hello, this is from stub!"
end
# ブロックを抜けるとスタブは無効になる
hello.say  #==> "Hello!"

stub_any_instanceを使うと、インスタンスメソッドのスタブを以下のように書けます。インスタンスメソッドのスタブを書くときはこちらの方が使える場面が多そうです。

require 'minitest/autorun'
require 'minitest/stub_any_instance'  # minitest-stub_any_instance_ofのgemも必要

# スタブする対象のクラス
class Hello
  def say
    'Hello!'
  end
end

# Helloクラスの任意のインスタンスのsayメソッドが'Hello, this is from stub!'を返すようにスタブする
Hello.stub_any_instance(:say, 'Hello, this is from stub!') do
  Hello.new.say  #==> "Hello, this is from stub!"
end
# ブロックを抜けるとスタブは無効になる
Hello.new.say  #==> "Hello!"

モック

expectメソッド

`expect(name, retval, args = [], &blk)
メソッド名(name)が呼ばれ、オプションで引数(args)またはブロック(blk)を指定し、戻り値(retval)を返すことを期待します。

require 'minitest/autorun'

@mock.expect(:meaning_of_life, 42)
@mock.meaning_of_life # => 42

@mock.expect(:do_something_with, true, [some_obj, true])
@mock.do_something_with(some_obj, true) # => true

@mock.expect(:do_something_else, true) do |a1, a2|
  a1 == "buggs" && a2 == :bunny
end

引数は、'==='演算子を使って期待される引数と比較されるので、より具体的な期待値が少なくて済むようになっています。(含まれるか?で比較される)

require 'minitest/autorun'

# users_any_stringメソッドがStringに含まれる場合、trueを返す
@mock.expect(:uses_any_string, true, [String])
@mock.uses_any_string("foo") # => true
@mock.verify  # => true(期待通りにモックが呼ばれたのでtrueになる)

@mock.expect(:uses_one_string, true, ["foo"])
@mock.uses_one_string("bar") # => raises MockExpectationError(期待通りにモックが呼ばれなかったため)

メソッドが複数回呼ばれる場合は、それぞれに新しい期待値を指定します。これらは定義した順番で使用されます。

require 'minitest/autorun'

@mock.expect(:ordinal_increment, 'first')
@mock.expect(:ordinal_increment, 'second')

@mock.ordinal_increment # => 'first'
@mock.ordinal_increment # => 'second'
@mock.ordinal_increment # => raises MockExpectationError "No more expects available for :ordinal_increment"

verifyメソッド

すべてのメソッドが期待通りに呼び出されたことを確認します。期待通りに呼ばれたらtrueを返します。モックオブジェクトが期待通りに呼ばれなかった場合、MockExpectationErrorを発生させます。

詳しくは、MiniTest::Mockページを参照ください。

最後に

RRもWebMockも、公式ドキュメントに十分な使い方のサンプルが掲載されていたので、一読してみると良さそうです。MiniTest::Mockの情報量は少なめだったので、irbrails cmockstubの動きを確認してみると、想像がつきやすくなると思いました。(実行時に、require 'minitest/autorun'が必要です。)

参考情報

RR / GitHub
RRのページ
WebMock / GitHub
MiniTest::Mock
MiniTest stub
minitest-stub_any_instance
Mock、Stub勉強会(ruby)
自動テストのスタブ・スパイ・モックの違い
Test Double / xUnit Patterns.com
minitest で stub, mock を使う
wiki テストダブル

xUnit Test Pattern

テストダブルのバリエーションを調べていると、こちらのxUnit Test Patterns: Refactoring Test Codeの書籍がよく出てきました。
英語版しか出版されていないようですが、Webで内容を確認できました(英語です)。
http://xunitpatterns.com

8
7
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
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?