11
3

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 1 year has passed since last update.

テストが実装に依存する...

詳細な手続きを一箇所に羅列せず、オブジェクトのコミュニケーションで表現されたコードは見ていて美しいものです。例えばこんなサービスを考えてみましょう。

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

テストでよく見るassertassert_equalなどが見当たりませんが、これがスタブやモックを使ったテストの特徴です。モックオブジェクトを注入して例外を吐いてない=想定通りの動きをしているということになるため、アサートが必要ないんです。

これは体感ですが、責任の委譲がうまく行われているクラスでは、こういったスタブやモックが頻出します。サービスクラスなど、自身にビジネスルールを持たせてはならないクラスなどでは特にですね。

終わりに

この記事では主にスタブ・モックの活用方法についてお伝えしてきましたが、うまく責任範囲を狭めることでテストのメンテナンスコストを大幅に減らすことができます。

もちろん、これらの使い方を誤ると「バグっているのにテストは通る」というアンチパターンにハマってしまいますが、まずはとにかくユニットテストの責任範囲をできる限り限定すべくスタブ・モックを使い倒して使い倒してみてください。

11
3
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
11
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?