よしたろうブログ

設計・人文知・歴史・哲学・漫画とかの話が好きです。

モック入門『考え方と使い分けについて』

はじめに

モックについての本記事は、以下の方々を対象にしております。

  1. 開発歴一年程度
  2. 自動単体テストについて多少の理解がある方
  3. 自動単体テストを実務で触れた事はあるが、良く解らないという方
  4. モックやスタブなどを扱った事はあるが、違いや使い分けがいまいちな方
  5. モックでどこまで置き換えるべきなのか基準が曖昧な方

本記事ではモックについて以下の様に定義し、基本的に狭義のモックの意味でモックという単語を用います。

  1. 狭義のモック:本来のモックとはテストダブルの一種を指す
  2. 広義のモック:テスト対象が依存する外部システムを簡易なオブジェクトに置き換える
    1. 文脈によっては「モックする」などの動詞、たんに名詞としてのモックなど意味は様々

以下のページにモックの概要についてまとめたスライドがあるので、モック初心者の方にはこちらを見てから読んでもらうと解り易いかもしれません。

1. モックなどのテストダブルとユニットテストについて

ユニットテストでは、モックなどの「テストダブル」を使用すると、テスト対象の依存先のふるまいを模倣することが出来ます。テスト目的で本物のシステムの代わりに使う、見せかけの物体の総称です。この名前は、映画のスタントの代役(double)からきています。

ユニットテストの中では、外部のサービス・ファイル・データベース、あるいはその他の面倒な依存先(DOC)との間で相互的なやりとりを行う必要は基本的に無いと考えられています。それらの振る舞いによる副作用を避けるために、テスト対象(SUT)への影響を制御する必要がある場合も存在します。依存先をテストダブルに置き換えることで、外部依存性をユニットテストから取り除くことができます。

ただし、これにはいろいろな考え方があります(「ロンドン学派」と「デトロイト学派」など後述)。テストダブルを使用し、SUT と DOC を分離することでのメリット・デメリットがあります。後述しますが、適用すべき場合とそうで無い場合を理解する必要があります。

他システムとのインタラクティブなやりとりの中で発生し得るエラーを自身のローカル環境下に実施する。これはやろうと思えばできることです。例えばDBとのやり取りを例にしましょう。DB接続のタイムアウト時間を極端に小さくする、該当のテーブルを削除するなど、様々な方法が考えられます。しかしそれは自身のローカル環境が特殊なだけです。全チームメンバーのデフォルトな環境において、JUnit はいつでも繰り返し素早く成功しなければ意味がないでしょう。その様な場合は、テストダブルオブジェクトを用いて誰の環境でも結果が同じになる様にすることが出来ます。

指定した DOC のテストダブルとして振る舞うオブジェクトの一種がモックです。SUT の DOC の振る舞いを真似するものがテストダブルで、その振る舞いの模倣の仕方などによって、スタブ・スパイ・モック・フェイク・ ダミーといった種類を使い分けます。

引用元:xUnit Test PatternsのTest Doubleパターン(Mock、Stub、Fake、Dummy等の定義)

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

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

2. 「関節的な出力」と「関節的な入力」とは

「関節的な出力」 とは、SUT の動作によって外部システムなどに影響を及ぼす出力のことを指します。

  • SUT が外部の何かしらを呼び出す際に引数で渡した値
  • 外部メソッド呼び出しの有無
  • データベースへのインサート
  • 送信メッセージ
  • ファイルへの書き込みなどの記録

SUT の間接出力動作の検証には、SUT の「裏側」に適切な「観測点」が必要です。観測を行うためにはモックオブジェクトを用いる事が出来ます。SUT の間接出力を受け取り記録し、その値を期待値と比較し検証します。また、モックは SUT と DOC のインタラクションなやりとりも検証できます。

反対に 「関節的な入力」 とは、テスト対象が依存する外部の何かしらからの戻り値で、テスト対象に影響を及ぼすものを指します。

  • 関数の実際の戻り値
  • 手続きやサブルーチンの更新パラメータ
  • 依存するコンポーネント(DOC)が発生させるエラーや例外

間接入力による SUT の動作のテストには、SUTの「裏側」にある適切な「制御点」が必要です。間接入力を SUT に注入するために、多くの場合、テストスタブを使用します。例えばいつも true を返すようなメソッドを持つテストダブルはスタブです。

||    間接入力    ||
|| SUT   ⇆   DOC ||
||    間接出力    ||

3. 各テストダブルの違い・使い所

スタブ・モックの違いや使い分けについて知る前にテストダブルの種類を紹介します。

  • 図の用語解説
    • Fixture
      • テストの前提条件となる、すべてのオブジェクト(およびその状態)をフィクスチャと呼びます。
    • Setup
      • テスト前に必要な前提条件を準備すること。オブジェクトやデータ(Fixture)など。@Before などで実施。フィクスチャのセットアップを行うなどと表現します。
    • Exercise
      • テスト対象となるソフトウェアを使用して、テストケースを実行することを指します。@Test がついたテストメソッドの実行などが該当します。
    • Verify
      • テストケースの実行結果が期待したものと一致しているかをテストすることを指します。@assertion で実施。戻り値の他、実行回数なども検証できます。
    • Teardown
      • テスト後に必要な環境をクリーンアップすることを指します。@After で実施
    • SUT
      • System under Testの略で、テスト対象となるソフトウェアを指します。UTであればメソッドやクラスなどの粒度が対象です。
    • DOC
      • depended-on component の略で SUT が依存する他システム・他コンポーネントのことです。DB・APIからクラスなどまで幅広い粒度を指します。SUT の間接的出力の送り先となるため、SUT との相互作用を調べ、制御する必要がある。カバレッジを上げるためにはこれらの把握は必須。
    • Creation
      • テストのためのデータを作成することを指します。単純にテストメソッド内で行うものから、@DatabaseSetup と @ExpectedDatabase なども該当します。
    • Installation
      • テストのためのソフトウェアをインストールすることを指します。
    • indirect output / indirect input
      • 間接出力(SUT → DOC) / 間接入力(SUT ← DOC)

image.png

3-1. スタブ

SUT の間接入力を制御できないためにコードがテスト出来ない場合に用います。SUT への間接入力を制御点をスタブに置き換え、期待される間接入力で SUT の動作を確認します。

  • テスト対象に「間接的な入力」を提供
  • テスト中に行われる呼び出しに対しあらかじめ用意された回答を提供、通常はテスト用にプログラムされた内容以外に反応しない

||    間接入力    ||
|| SUT   ⇆   DOC ||
||    間接出力    ||

image.png

3-2. スパイ(スタブ + 記録)

SUT が DOC への間接的な出力がある場合、その出力と出力に至るロジックを検証したい際、SUT によって DOC に対して行われた間接的な出力呼び出しを記録し、後でテストで検証できるようにします。

  • スタブのより機能的なテストダブルで、スタブ機能も兼任する
  • テスト対象からの「間接的な出力」を記録する
    • 例えば、メールサービスで、何通送信されたかを記録する
    • その情報を後で検証に用いることもできる

||    間接入力    ||
|| SUT   ⇆   DOC ||
||    間接出力    ||

3-3. モック(スタブ機能 + 動作検証 + エクスペクテーション)

SUT の間接的な出力に対する動作検証に用います。SUT が依存するオブジェクトを、SUT によって正しく使用されていることを、検証するテスト固有のオブジェクト(モックオブジェクト)に置き換えます。エクスペクテーションとは、あるオブジェクトが隣接するオブジェクトとどのような連携をするのかを定義したものです。それに従って、メソッドの実行などの順番などが検証(動作検証)されます。単純な間接出力の検証だけではありません。それはスパイで行う事が十分に行えます。

  • テスト対象が期待する「間接的な出力」を保持
    • 保持した間接出力用いてモック内で、間接出力の期待結果を比較検証できます(スパイと同じ)。
  • エクスペクテーションは事前にプログラムされたメソッドの動作順序やインタラクション
    • これにより、スタブ + アサーション というスパイとは異なった働きを期待します
    • エクスペクテーションに定義されない(期待されない)呼び出しが行なわれたかどうかの動作検証をモックそのもので検証し、テストの成功失敗を確定させる
    • テスト実行後に、期待された呼び出しがすべてきちんと行われたかどうかを検証(assertion + 動作検証)
    • 動作順序に関しては、厳密な順序による呼び出し守らせる場合と順不同の呼び出しを許容する場合があります。

||    間接入力    ||
|| SUT   ⇆   DOC ||
||    間接出力    ||

image.png

3-4. フェイク

SUTの間接的な入力・出力の検証以外の理由で、テストで実際のDOCの機能を置き換えるテストダブルで、実行に問題のあるテスト(スロウ・副作用が有害)を高速にかつ副作用を除去し、より軽量な実装に置き換えます。

  • 実際のオブジェクトに近い働きのより単純な実装にしテストに不要か有害な副作用の発生を除去する
  • SUTに代用可能な依存関係をインストールする必要がある
    • スタブもその点は同様ですが、スタブと異なるのは SUT への間接入力の提供していません。DOC への間接出力のみを提供します。
  • 間接入力を操作し、SUT にとって本当の DOC と変わらないインタラクションができてる様に見せます。
  • 実際に動作する実装を持つが、通常は簡略化されているため実運用には適していない
    • 問い合わせるDBやサーバ処理などを単純な実装に置き換える
    • プロダクション環境では本物のDBだけど、単体テスト環境ではインメモリ(データベースの偽物)など

||    間接入力    ||
|| SUT   ⇆   DOC ||
||    間接出力    ||

3-5. ダミー

メソッドのシグネチャに必要なオブジェクトの代替として必要になります。 SUTに対し、

  • 引数などに渡されるだけで、実際には使われることのないオブジェクト
  • 通常、テスト対象の生成な際にパラメータリストを埋めるために使われるだけで呼び出されない
  • 有名なユニットテストの書籍『xUnit Test Patterns: Refactoring Test Code』では「ダミーオブジェクトはテストダブルではない」と説明されている
    • この意味で、ダミー オブジェクトは実際にはTest Doubleではなく 、値パターンのリテラル値(ページ X)、派生値(ページ X)、および生成値の代替です。この意味で、ダミー オブジェクトは実際にはTest Doubleではなく 、値パターンのリテラル値(ページ X)、派生値(ページ X)、および生成値の代替です。

4. モックの使用例

以下の例は、UserDao が期待通りに UserRepository から使われているかを確認し、UserRepository クラスの delete メソッドの異常系テストを行うために使用されています。モックを使用することで、実際のデータベースにアクセスせずに、異常系テストを実行できるようになり、かつ外部システムへのアクセスによるオーバーヘッドなどが発生しないのでテストを高速実行できるようになります。

実行内容

まず、フィクスチャのセットアップを行い(1~3、場合によっては4)。モックでエクスペクテーションの確認(5~6)と検証作業(6の assertion)。SUT の外部依存システム(DB)への間接出力は、UserDao のデリートメソッドと発行される削除対象ユーザのID情報を持ったクエリの発行である様に制御。SUT への間接入力は例外であることを観測します。

  1. @InjectMocks を付与した変数に、UserRepository クラス(テスト対象:SUT)のモックをインジェクション
  2. @Mock を付与した変数に、UserDao クラス(データベースレイヤとのインタラクションを行う)のモックをインジェクション
    1. テスト対象:SUT が使用(依存)するインスタンス(DOC)
  3. @BeforeEach のある before メソッドでは、UserInfo クラスのインスタンス
    1. このクラスのメソッド実行前に毎回インスタンスを作成
  4. @Test を付けた DeleteExecutionErrorTest メソッドでは、entity に削除対象のユーザコードを設定
  5. mockDaodelete メソッドを呼ぶときに DomaException を発生させるよう、when メソッドで設定
    1. any() は、任意の引数を受け取る。delete メソッドが任意の引数を受け取ることを表す
      1. 任意の値を受け取ることで、関数が特定の引数を受け取らなくても正常に動作することを保証する
      2. メソッドが受け取る引数の型を決める必要がなくなり、メソッドの実装が容易になる
  6. mockServicedelete メソッド を呼び出し、期待した例外が発生することを検証
    1. 例外が発生しなかった場合は try ブロック内で fail メソッドを呼び出し、テストを失敗させる
public class ServiceTest {

    @InjectMocks
    private UserRepository mockUserRepository;

    @Mock
    private UserDao mockUserDao;

    @BeforeEach
    public void before() {
        UserInfo entity = new UserInfo();
    }

    /**
   * ユーザ情報削除時の異常系テスト 
   * DomaException を発生させる
   */
    @Test
    public void DeleteExecutionErrorTest() {

        entity.setUserCd("userCd78901234567893");

        // UserDao の delete メソッドが呼び出されたら、DomaException を返すように設定する
        when(mockUserDao.delete(any()).thenThrow(new DomaException(Message.DOHA0001,""));

        try {
            // UserRepository の delete メソッド内に UserDao の delete メソッドがあり、呼び出されている。
            mockUserRepository.delete(entity);
            fail();
        } catch (Exception expected){
            assertTrue(expected instanceof DomaException);
        }
}

mockUserRepository.delete(entity);UserRepository が依存している UserDaodelete() を呼び出します。その中身は以下のような単純なメソッドです。もし、mockUserRepository.update(entity); という記述の場合は、fail() が実行されテストが失敗します 。

public class UserRepository {

    @Auotwired
    UserDao userDao;

................略

    public int update(final UserInfo entity) {
        return userDao.update(entity);
    }

    public int delete(final UserInfo entity) {
        return userDao.delete(entity);
    }

................略

}

ここでは、UserRepository.delete()を実行すれば、UserDao.update()が実行されるというエクスペクテーション(SUT と DOC のインタラクション。仕様通りに正しくテストコードで実装されている上で、SUT がその通りに実装されているか)が検証されています。

さらに間接出力が観測され、検証されています。

一つは目は間接出力の例外を観測し、期待通りの結果になっているかをテストコード上で検証(assertion)されている事がわかります。これは、スパイオブジェクトでも同じ事が出来ます。モックでは以下のように、テストコード上ではなくモックによって動作検証作業も行なっているのがスパイと大きく異なる点です。

二つ目はエクスペクテーションです。指定のメソッドが、指定の動作で呼び出されることが検証されています。エクスペクテーションが正しくなければ、その時点でテスト失敗が確定します。もし、mockUserRepository.update(entity);が呼び出されていたら。つまりmockUserRepository.update(entity);という間接出力を受け取ると同時に検証結果が確定されます。mockUserRepository.update(entity);では例外が間接入力として設定されません。fail()が行われ、テストが失敗します。これはテストコードで検証を実施しているわけではありません。

4-1. データアクセスレイヤをモックすると自作自演になりがち

現実を模したMockを作り実装を合わせるべきで、実装に合わせたmockを作るべきでないのです。自身の環境で再現できるものは再現してテストすべきです。 DBのクラッド処理は確かに外部の依存システムではありますが、全ての開発者の環境で再現できるものです。SUT の DOC の全てをテストダブルに置き換えるというのは、結局何の検証もできていない状態を引き起こしてしまいます。

また、ユニットテストでカバーできなかったものは IT などで検証が必要になりますが最初で説明した様に、コストが跳ね上がることになります。

ただし、上記のモックの実用例の様に、障害による内部エラーや実際には起こしにくいケースはモック/スタブで行います。

参考

https://blog.p.info/ja/2021/10/12/mock/

5. テストダブルオブジェクト使用の注意点

実運用のコードを直接テストしているわけではないという点に注意が必要です。

5-1. 実運用のコード・本物のシステムのふるまを本当に再現しているか?実際のコードが入り混じっていないか?

これらを考慮する必要があることです。場合によっては前者はテストして確認する必要があるかもしれません。後者は架空のデータを使用することで実運用コードではエラーが起きる様にすることで対策できる様です。

5-2. テストダブルオブジェクトを導入することでテストのカバー範囲に漏れが生じる

テストダブルは記述者の頭の中にある正しいと思われる振る舞いを再現しているに過ぎません。どこまで行っても本物ではないので100%信頼できません。ユニットテスト対象周辺の前提が間違ったスタブ・モックを作ってしまえば意味のないテストになってしまいます。テストが通っているのは、コードが正しいのか、コードがテストダブルと揃っているからなのか解りづらいからです。

また、テストダブルを多用すると「自作自演テスト」になりがちです。あくまでも、開発者の想定している範囲内での模倣でしかないのです。テストダブルを使えばDBなどの外部システムを意識しなくて良くなります。ですが、テスト対象の依存先の振る舞いも全て把握し模倣するには非常に面倒な場合もあります。テスト対象の依存先にさらにまた別の依存先があるのか?あった場合、テスト時に関係するのか?どの様な型でどの様な値を返す必要があるのか?

正しく理解し、テストダブルの振る舞いを実装するのは、規模が大きくなればなるほど多くの場合は難しいはずです。また、本来であれば起きるであろうエラーも不完全な模倣によるテストダブルでは発生しないことも考えられます。

これらの不確実性は、より上位階層でのテスト(統合テストなど)実施時、実際のクラスを使ったE2Eのシナリオで検証する必要があります。テストダブルオブジェクトによって、ユニットテストのカバー範囲は減少しますが、統合テストを通じてこの穴を埋める必要も生じます。

5-3. モックの多用がリファクタリングを妨げる要因になる

上記にも通じますが、モックを使用することでテスト速度の上昇・分離性は高まりますが、その代わりに信頼性は失なわれます。テストダブルが模倣する動作は開発者の想像の中の振る舞いでしかありません。また、それらもコードで記述されるため、メンテナンス対象になります。モックと実装が密結合してしまうことで、後々コードを変更するのに余計な労力が必要になります。リファクタリングや再設計を支えるためのテストにおいて、そのような事態は本末転倒でしかありません。

6. テスト駆動開発における「ロンドン学派」と「デトロイト学派」

https://twitter.com/t_wada/status/1448864195357777928?s=20

テスト駆動開発にはざっくりいうとモックを積極的に使う派(ロンドン学派)とあまり使わない派(デトロイト学派、古典派)がありまして、私は後者なのでほとんど使わず、このエントリに深く同意するところです / “モックは必要悪で、しないにこしたことはない - …”

古典的なTDDのスタイルは、可能であれば本物を使い、本物を使うのが厄介であればダブルを使うというものです。ですから、古典的なTDDerは、本物の倉庫を使い、メールサービスにはdoubleを使うでしょう。doubleの種類はそれほど重要ではありません。

しかし、モック派のTDD実践者は、興味深い振る舞いをするオブジェクトには常にモックを使います。この場合、倉庫とメール・サービスの両方にモックを使います

実際にアジャイルやTDDの実践者の話を思い返しても、彼らがDHH氏が言うところの厳密な意味でのユニットテスト(依存関係は全てMockにして、対象となるユニットだけをテスト対象にする)にこだわっている印象はない。むしろ、DHH氏と同様、過剰なMockの導入は無駄が多いし、本来テストすべき箇所をテスト出来ないという問題意識を持っている人が多いように思う。Mockにこだわるのは厳密な Outside-in デザインにこだわる Behavior Driven Development (BDD) の一派ではないだろうか。実際にオリジナルTDD派とBDD派にはそのような対立も散見される。

私個人の考えと、実務上の経験では「古典派」です。後々発覚するバグ発生がテストダブル由来なことが多い印象です。本物を使用していれば早期に判明していた設計・コーディングのミスがあったのになーが沢山ありました.....。