この記事について
仕事で、railsのテストフレームワークにminitestを使っています。そして、railsプロジェクトの中では、モックやスタブをするときに、RR、WebMockといったgemや、minitest標準のモックであるMiniTest::Mockが使われています。
テストを書くときに、モックやスタブの書き方に戸惑うことが多くありました。
この記事では、テストダブルとはどんなものか、モックとスタブの違いはなにか、RR、WebMock、MiniTest::Mockそれぞれの使い方について記述します。
目次
- テストダブル(モック、スタブ)とは
-
RR
- Rubyのテストダブルのフレームワークのgem
-
WebMock
- RubyでHTTPリクエストのスタブやモックを設定するためのgem
-
MiniTest::Mock
- minitestに含まれるモックオブジェクトのフレームワーク
- 最後に
- 参考情報
テストダブル(モック、スタブ)とは
まず、テストダブルとはどんなものでしょう。また、モック、スタブの違いはなんでしょう。
- テストダブルとは、ソフトウェアテストにおいて、テスト対象が依存しているコンポーネントを置き換える代用品(ダブルは代役、影武者を意味する)
- モックもスタブも、テストダブルの一種
テストダブルの5つのバリエーション
テストダブルは、xUnit Test Patternの書籍によると、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_received
やexpect(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.proxy、stub.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_return
、to_raise
、to_timeout
をthen
でつないで複数のレスポンスを返したり、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::Unit、RSpecでの期待値の設定方法の記述はありましたが、minitestについて記述がありませんでした。
minitestは、Test::Unitと同様の書き方ができそうです(参考)。
Test::Unit/minitest
assert_requested
やassert_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
expect
とhave_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_request
とhave_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の情報量は少なめだったので、irb
やrails c
でmock
やstub
の動きを確認してみると、想像がつきやすくなると思いました。(実行時に、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