give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

【Ruby版】xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義)

はじめに

テストダブル(Test Double)について、わかりやすく解説した技術記事はないかな〜と探していたところ、こちらのブログ記事を見つけました。

goyoki.hatenablog.com

とても詳しく解説されていたので、まさに打ってつけだったのですが、ふだん僕はRubyを使っているのでサンプルコードをRubyにしてみたいな〜と思いました。

そこで今回のエントリでは、原著者の id:goyoki さんの許諾をいただいた上で、上記のブログ記事の説明文を維持したまま、サンプルコードだけをRubyに書き直してみました。(goyokiさん、どうもありがとうございます!)
ただし、Ruby版のコードにあわせて説明文を改変した箇所もいくつかあります。

それでは以下がRuby版の「xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義)」です。

(もくじ)

【Ruby版】xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義)

 最近、昔作ったTest Doubleの解説資料を参照・引用してくれる方がちらほら出ていて恐縮しているのですが、見直してみると結構わかりにくい資料なので今回文章としてまとめたいと思います。内容は世間一般的に言われているMock、Stub、Fake、Dummyといった言葉の定義になります。

Test Doubleとは

 Test Doubleとは、テスト実行時に、テスト対象が依存しているコンポーネントと置き換わるものです。「テスト代役」と訳されることもあります。世の中でMock、Stub、Fake、Dummyなどと呼ばれているものの総称に位置づけられます。
 簡単な例を以下に示します。このコードでは、テストメソッド「テストコード」がメソッド「テスト対象」をテストしています。また「テスト対象」は、中でメソッド「外部メソッド」を実行しています。なお「外部メソッド」はテスト対象でないとします。

def test_テストコード
  assert_equal 期待値, テスト対象
end

def テスト対象
  # ...
  外部メソッド
  # ...
end

 上記の「外部メソッド」のようなテスト対象の依存要素は、例えば以下の理由でテスト中に実行したくないことがあります。

  • 実行上の制約を持つ。例えば「テスト環境にないハードウェアを制御する」「勝手に変更してはいけない顧客のDBを操作する」「実行にコストがかかる」「OSや外部ライブラリのAPIであり挙動をコントロールできない」といった制約を持つ。
  • テストのために自由に操作したい。例えば『「外部メソッド」から例外を投げて正しく動くかチェックする』『「外部メソッド」が呼び出されたかチェックする』のように操作や検証に活用したい

 こうした状況に対応するためには、「外部メソッド」をテスト実行時に都合の良い代替と置き換えないといけません。Test Doubleとは、その「外部メソッド」と置き換わるものが該当します。
 図にすると以下のような感じです。本番環境での依存コンポーネントを、テストの際はTest Doubleに置き換えていきます。

f:id:JunichiIto:20200806074809p:plain

※今回は入門用のテキストということで、コードをかなり簡略化して書いていきます。実際はTest Doubleは一般的にオブジェクトであり、Dependency InjectionやDependency Lookupといった仕組みで本物と置き換えます。詳細は「Test Double Patterns at XUnitPatterns.com」を参照ください。

xUnit Test PatternsのTest Doubleパターン

 このTest Doubleの定義や分類例には、有力なものにユニットテストの実装パターン集であるxUnit Test Patterns(index at XUnitPatterns.comおよび同名の書籍)があります。そこではTest Doubleを用途に応じて以下のように分類しています。

  • Test Double Pattern
    • Test Stub
    • Test Spy
    • Mock Object
    • Fake Object
    • (Dummy Object)

 具体的には、Test Doubleに含まれる「Test Stub」「Test Spy」「Mock Oject」「Fake Object」の4つ+Test Doubleに類似した「Dummy Object」の1つの計5つに分類しています。
 なおTest Doubleの定義はいろいろな流儀があり、このxUnit Test Patternsの定義がデファクトスタンダードというわけではありません。ただ分類が明快なほか、Martin Fowlerや id:t_wada さんなどユニットテストの世界で有力な技術者が理解を示しているので、個人的に推奨できる分類と判断しています。それぞれの詳細は後述していきます。

前提:間接入力と間接出力

 なお上記の5つは、おもに間接入力と間接出力の扱いに応じて分類されています。具体的な説明に入る前に、その間接入力と間接出力について説明します。

間接入力

 まず間接入力はテストコードから見えないテスト対象への入力です。
 コードでの例を示します。ここではメソッド「テスト対象」が中で「外部メソッド」を呼び、その戻り値を使用しています。

def テスト対象
  # ...
  answer = 外部メソッド
  # ...
end

def test_テストコード
  assert_equal 期待値, テスト対象
end

 ここでの「外部メソッド」の戻り値のように、テストコードから直接見えないがテスト対象に影響を与える入力が、間接入力となります。なお間接入力には、例えばテスト対象が依存するオブジェクトからの例外発生も含まれます。

間接出力

 一方間接出力はテストコードから見えないテスト対象の出力です。
 コードの例を示します。テスト対象が中で「外部メソッド」を呼んでいますが、そこで「外部メソッド」に引数を出力しています。

def テスト対象
  # ...
  外部メソッド(x)
  # ...
end

def test_テストコード
  assert_equal 期待値, テスト対象
end

 ここでの「外部メソッド」への引数xのように、テストコードから直接見えないが、テスト対象が外に出力しているものが、間接出力となります。なお間接出力には「外部メソッドが実行されたか」「複数のメソッドが順番通り呼ばれたか」のようなメソッド呼出しの有無も含まれます。
 間接出力・間接入力の関係図は以下のようになります。

f:id:JunichiIto:20200806075214p:plain

Test Double詳細

 では間接入力・間接出力の定義を踏まえて、各Test Doubleの詳細を説明します。

(注:以下のコード例は実行可能なRubyコードとして元記事のサンプルコードを一部改変しています。また、サンプルコードは以下のリポジトリに置いています)

Dummy Object

 Dummy Objectはテストに影響を与えない代替オブジェクトです。
 かなり極端ですが、コードの例を以下に示します(変なコードですがとりあえずTDDでの仮実装中とでも考えてください)。

require 'minitest/autorun'

class DummyObjectTest < Minitest::Test
  class Foo
  end

  def テスト対象(foo)
    10
  end

  def test_テストコード
    foo = Foo.new # Dummy Object
    期待値 = 10
    assert_equal 期待値, テスト対象(foo)
  end
end

 上記の例では、「テスト対象」は引数を持ちますが、出力である戻り値「10」と引数は全く無関係です。そこに指定される「foo」はテストに影響を与えないという点で、まさにDummy Objectに該当します。

Test Stub

 テスト対象への間接入力を操作するTest Doubleは、Test Stubと分類されます。テストでは、任意の間接入力がテスト対象に出力されるように、間接入力をTest Stubに事前設定して使用します。例えば以下のような感じです。

require 'minitest/autorun'

class StubTest < Minitest::Test
  # Test Stubとして動作する代替関数
  def 外部メソッド
    @間接入力値
  end

  def テスト対象
    # ...
    answer = 外部メソッド
    # ...
    answer * 2
  end

  def test_テストコード
    @間接入力値 = 100 # 外部メソッドがテストにとって望ましい値をテスト対象に返すようセット
    期待値 = 200
    assert_equal 期待値, テスト対象
  end
end

 上記のコードでは、「外部メソッド」による間接入力「間接入力値」を、テストコード上で事前設定してからテストを実行しています。そのように望ましいように間接入力を操作できるオブジェクトが、Test Stubです。

f:id:JunichiIto:20200806075447p:plain

フレームワークを利用するコード例

以下はMinitestのstub機能を利用した場合のコード例です。

(注:この項、およびそのあとに登場する「フレームワークを利用するコード例」はRuby版独自のサンプルコードです)

require 'minitest/autorun'

class StubTest < Minitest::Test
  # このメソッドはstubに置き換えられるため、実際には呼ばれない
  def 外部メソッド
    raise "Don't call me!"
  end

  def テスト対象
    # ...
    answer = 外部メソッド
    # ...
    answer * 2
  end

  def test_テストコード
    間接入力値 = 100
    stub(:外部メソッド, 間接入力値) do
      期待値 = 200
      assert_equal 期待値, テスト対象
    end
  end
end
Test Spy

 テスト対象の間接出力を記録し、それをテストコードから参照可能にするTest Doubleは、Test Spyと分類されます。テストでは、テスト対象の間接出力を記録させ、その後テストコード上でその間接出力を検証します。なお間接入力を操作することもあります。
 例えば以下のような感じです。

require 'minitest/autorun'

class SpyTest < Minitest::Test
  # Test Spyとして動作する代替関数
  def 外部メソッド(input)
    @間接出力値 = input
  end

  def テスト対象
    # ...
    x = 100
    外部メソッド(x)
    # ...
  end

  def test_テストコード
    テスト対象
    期待値 = 100
    assert_equal 期待値, @間接出力値 # Test Spyに記録させておいた間接出力値を比較検証する
  end
end

 上記のコードでは、「外部メソッド」への間接出力をいったん「間接出力値」に保持します。テストコードはテスト実行後それを参照することで、間接出力が適切だったかチェックします。こうした間接出力を保持するものがTest Spyとなります。

f:id:JunichiIto:20200806075548p:plain

フレームワークを利用するコード例

以下はspy gemのspy機能を利用した場合のコード例です。

require 'minitest/autorun'
require 'spy/integration'

class SpyTest < Minitest::Test
  # このメソッドはspyに置き換えられるため、実際には呼ばれない
  def 外部メソッド(input)
    raise "Don't call me!"
  end

  def テスト対象
    # ...
    x = 100
    外部メソッド(x)
    # ...
  end

  def test_テストコード
    Spy.on(self, :外部メソッド)
    テスト対象
    期待値 = 100
    assert_received_with self, :外部メソッド, 期待値
  end
end
Mock Object

 以下の用途をあわせもつTest Doubleは、Mock Objectと分類されます。

  • テスト対象の間接出力の期待結果を持っています。
  • テスト対象を実行している間、テスト対象の間接出力を取得します。
  • 間接出力を確保できたら、Mock Objectの中でその期待結果と比較検証し、成功か失敗か判定します。
  • テストコードは、テスト対象を実行後、Mock Objectから検証の成功・失敗の情報を受け取ります。
  • Test Stubの機能を包含していることもあります。

 非常に簡略化した例ですが、例えば以下のようなものです。

require 'minitest/autorun'

class MockTest < Minitest::Test
  # Mock Objectとして動作する代替関数
  def 外部メソッド(input)
    if @期待する間接出力値 == input
      @テスト成功フラグ = true
      return
    end
    @テスト成功フラグ = false
  end

  def テスト対象
    # ...
    x = 999
    外部メソッド(x)
    # ...
  end

  def test_テストコード
    @期待する間接出力値 = 999 # Mock内で比較に用いる期待値
    テスト対象
    assert @テスト成功フラグ # Mock内で検証した結果をチェック
  end
end

 ここでは、「外部メソッド」が、テスト対象から間接出力を受け取るとともに、同時にその検証も行っています。そしてテストコードでは、その検証結果を見てテスト結果を決めています。このように間接出力の検証能力を持つのがMock Objectです。
 なおMock ObjectとTest Spyは両方とも間接出力を検証するためのTest Doubleです。ただ「Mock ObjectはMock Object内で間接出力結果を評価する」のに対し、「Test Spyは間接出力を保持するだけで、間接出力結果の評価は後からテストコード上で行う」という違いがあります。

f:id:JunichiIto:20200806075705p:plain

フレームワークを利用するコード例

以下はMinitestのmock機能を利用した場合のコード例です。
そのままでは「外部メソッド」をモックに置き換えることができないため、Fooクラスを導入し、「テスト対象」を呼び出す際にFooクラスのMock Objectを引数として渡すようにしました。

require 'minitest/autorun'

class MockTest < Minitest::Test
  class Foo
    # このメソッドはmockに置き換えられるため、実際には呼ばれない
    def 外部メソッド(input)
      raise "Don't call me!"
    end
  end

  def テスト対象(foo)
    # ...
    x = 999
    foo.外部メソッド(x)
    # ...
  end

  def test_テストコード
    mock_foo = MiniTest::Mock.new
    モックの戻り値 = nil
    期待する間接出力値 = [999]
    mock_foo.expect(:外部メソッド, モックの戻り値, 期待する間接出力値)
    テスト対象(mock_foo)
    mock_foo.verify
  end
end
Fake Object

 テスト実行中、代替元の本物と同じように動けるTest DoubleはFake Objectと分類されます。Fake Objectは間接出力を受け取り、間接入力を操作しますが、あくまでそれも本物と同じように処理したり出力したりします。

補足

 説明は以上ですが、おまけの補足解説をいくつか行いたいと思います。

分類方法

 Test Doubleの分類方法は以下のような感じです。

  • テストの範囲内で本物と同じように動作するTest DoubleはFake Object。
  • 内部のパラメータや状態がなんでもあってもテストに影響を及ぼさない代替オブジェクトなら、Dummy Object
  • 上記以外で、テスト対象の間接出力を受け取り、かつ自身でその検証を行うTest DoubleはMock Object
  • 上記以外で、テスト対象の間接出力を受け取りそれをあとから参照可能にするTest DoubleはTest Spy
  • 上記以外で、テスト対象の間接入力を操作できるTest DoubleはTest Stub
Test Doubleの用途

 Test Doubleはしばしば以下の用途で用いられます。

  • コスト的・時間的・環境的にテストで実行困難なコンポーネントを置き換える
  • テストを高速化する。例えばDBをメモリ上のFake Objectに置き換えたりする。
  • まだ実装していないコードの代替となる。
  • 複雑で使いにくいコードを簡略化する。例えば多数のクラスや設定に依存しているコンポーネントを、単純なTest Doubleに置き換えてしまう。
  • テスト対象の間接出力を取得し検証する。
  • テスト対象の間接入力を操作する。

(Ruby版の記事はここまで)

参考情報

記事のタイトルに含まれている「xUnit Test Patterns」は書籍(洋書)としても出版されています。