テストが実装に依存する...
詳細な手続きを一箇所に羅列せず、オブジェクトのコミュニケーションで表現されたコードは見ていて美しいものです。例えばこんなサービスを考えてみましょう。
class SaveUserAverageAgeService
def self.call
average_age = User.average(:age)
Analysis.create_record(average_age, Date.today)
end
end
Userモデルにユーザーの平均年齢を計算してもらい、分析テーブルに現在時点のデータを保存するサービスを想定してみました。(実際には平均年齢以外に男女比とか平均課金額とか、そういうデータも一緒に集計すると思いますが、ここでは説明のため省略しています。)
このサービスのテストコードを書けと言われて、ぱっと思い浮かびますか?
初心者でも思いつくのは、
- Userテーブルのテストフィクスチャを用意して、実際に平均年齢を計算させる
- Analysisテーブルに保存されたレコードを検索して、実際のレコードの値をアサートする
こんな方法でしょう。でも、ちょっと待ってください。Railsのテストでも操作しやすいリレーショナルデータベースに値を保存しているから「簡単じゃん」と思うかもしれませんが、、、
例えば保存する先がリレーショナルデータベースでなくredisなら...?
例えば保存するのではなくチャットサービスにメッセージを投稿する仕様なら...?
途端にテストしづらくなってしまいませんか?
これは、テストを過度に実装に依存させるために発生する問題です。
テストの責任範囲を考える
上記のコードでは、
- 平均年齢を計算するのはUserクラスの責任
- 平均年齢データをどうやって保存するかはAnalysisクラスの責任
と、クラスの責任範囲を明確にし、サービスにビジネスルールや手続きを持ち込まない設計にしています。では、UserやAnalysisのメソッドを呼び出しているサービスクラスの責任はなんでしょうか?
それは、「オブジェクト間の値の受け渡し」です。Userクラスから平均年齢を受け取り、それを実行日時とともにAnalysisクラスに引き渡す。実はこれがこのサービスクラスの責任であって、平均年齢の計算や値の保存はこのサービスクラスの責任範囲ではないんです。
スタブ×モックで責任を限定する
計算や保存がサービスクラスの責務ではないので、ただ意図通りのメソッドを意図通りの引数を渡して実行されていることを確認するのがこのサービスのテストの責任範囲です。そして、メソッドの呼び出しや引数の確認をするために有用なツールがスタブメソッドやモックオブジェクトです。
まずは、User.average(:age)
のテストを考えてみましょう。サービス側が:age
というシンボルをaverageメソッドの引数に渡していることをテストするのにスタブとモックを活用します。
class User
# サービスのテストをするだけなので空のメソッドだけ用意
def self.average; end
end
class SaveUserAverageAgeServiceTest < Minitest::Test
def test_average_stab
mock = Minitest::Mock.new
User.stub(:average, mock) do
mock.expect(:call, 15, [:age])
puts User.average(:age)
end
end
end
初見だとかなりややこしいですが、上から順番に見ていきましょう。
まず、mock = Minitest::Mock.new
でモックオブジェクトのインスタンスを作成します。ここは「そんなもんか」という程度ですね。次が重要です。
User.stub(:average, mock) do
# 中身
end
stubメソッドの第一引数には、スタブしたい静的メソッドをシンボルで指定します。続く第二引数が重要です。rubyのドキュメントには以下のように説明が書かれてあります。
#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.
日本語にすると、「第二引数で渡されたオブジェクトがcallに応答するならcallを実行し、そうでなければオブジェクトそれ自身を返却する。」とあります。
この「callに応答する」というのがミソです。
User.stub(:average, mock) do
mock.expect(:call, 15, [:age])
puts User.average(:age)
end
do~endのブロックの中で、スタブで挿入したmockオブジェクトがcallメソッドに応答するよう#expectを記述していますね。これによって、
1)User.averageが実行される
2)スタブしたmockオブジェクトのcallメソッドが実行される
3)引数に:age
が渡されたとき、15
という整数値を返却する(:age
以外が渡されるとエラーになる)
というテストの処理が実現します。つまり、Userモデルにどんなレコードが突っ込まれているかはどうでもよく、ただaverage
というメソッドに:age
という引数を渡すと、結果としてテスト用に適当な整数値を返却させるということが実現されるということです。
#averageの詳細なルールについて気にすることなくこのサービスのテストを書けるので、テストフィクスチャを用意する必要がなくなりました。このサービスのテストはUserという具体的な実装(=クラス)から解放されたので、#averageメソッドの変更があってもこのテストは通り続けます。(まあ、averageはActiveRecordが提供するメソッドなので変更される可能性は極端に低いですが...)
あとは同じ要領でスタブとモックを駆使すれば、Analysisに対して適切な引数を渡せているかということだけをテストすることが可能です。
require 'minitest/autorun'
require 'minitest/mock'
require 'date'
class SaveUserAverageAgeService
def self.call
average_age = User.average(:age)
Analysis.create_record(average_age, Date.today)
end
end
class User
def self.average; end
end
class Analysis
def self.create_record; end
end
class SaveUserAverageAgeServiceTest < Minitest::Test
def test_correct_args
user_mock = Minitest::Mock.new
analysis_mock = Minitest::Mock.new
User.stub(:average, user_mock) do
Analysis.stub(:create_record, analysis_mock) do
Date.stub(:today, Date.new(2023, 01, 01)) do
user_mock.expect(:call, 15, [:age])
analysis_mock.expect(:call, [], [15, Date.new(2023, 01, 01)])
SaveUserAverageAgeService.call
user_mock.verify
analysis_mock.verify
end
end
end
end
end
テストでよく見るassert
やassert_equal
などが見当たりませんが、これがスタブやモックを使ったテストの特徴です。モックオブジェクトを注入して例外を吐いてない=想定通りの動きをしているということになるため、アサートが必要ないんです。
これは体感ですが、責任の委譲がうまく行われているクラスでは、こういったスタブやモックが頻出します。サービスクラスなど、自身にビジネスルールを持たせてはならないクラスなどでは特にですね。
終わりに
この記事では主にスタブ・モックの活用方法についてお伝えしてきましたが、うまく責任範囲を狭めることでテストのメンテナンスコストを大幅に減らすことができます。
もちろん、これらの使い方を誤ると「バグっているのにテストは通る」というアンチパターンにハマってしまいますが、まずはとにかくユニットテストの責任範囲をできる限り限定すべくスタブ・モックを使い倒して使い倒してみてください。