[補足] org.mockito.exceptions.misusing.UnnecessaryStubbingExceptionについて


背景

以下記事は [Web/まとめ] JUnit5 で Mockitoを利用する方法で理解に若干時間のかかった org.mockito.exceptions.misusing.UnnecessaryStubbingException について、実際のサンプルコードを踏まえて、理解の足しにした追加メモである。

一体どのような時に起こるのか?

(復習&まとめ)まずMockitoで何ができるのか?

  • Mockitoを利用することで、テスト対象コードが利用する依存サービス (3rd party libraryだったり、実際の実装がわからない interface classだったり)への依存なしでテストを行うことができる。
  • 例えば、実際の実装を使わずとも、Mockオブジェクトを定義し、そのMock オブジェクトがどのような結果を返すかなどを、開発者は各テストケースに応じて挙動を書き換える (=定義する)ことができる
    • 利用している3rd library interfaceがExceptionをthrowした場合は、自分のテストクラスはどのような挙動をするべきか?
    • 3rd party library interface / method の実装や中身は興味がない、とにかくintegrationが正常に行われていると仮定して、3rd party library interface の挙動をフェイク実装したい

(復習&まとめ) TestにおけるStubとMockとは?

自分は、Mockitoにおいて、ここやや混同したのでまとめておく。

MockとStubの違い


Mockitoの場合は StubもMockも作り方は基本的に一緒 (<=ここが自分にとってかなりややこしかった )。Mockの場合は verifyをコールして動作の検証を行う一方、Stubの場合はそれを行わない(実装が壊れないようにフェイクデータを返すことが責務だから)。

参考LINK
まとめ
  • Mock

    • テスト対象のクラスに対するテストの一環として、(実装は持たない架空の)interface/componentの 予測される定義を記述し、実際に期待通りコールされたかをチェックする
    • 従って、Mockitoの場合は verify を呼ぶことが必須!!!
    • ただ、それ以外の作成方法は一緒! (例: @Mockを利用)
  • Stub

    • テストコードがテストケースの意図と異なる箇所で関連のないエラーが発生しないよう、問題ないFakeデータを返すように定義するためだけに、挙動を定義する
    • 従って、Mockitoの場合は verify はむしろ実行してはいけない! (Mockito java docではコードが冗長になるのでやめろと言っている
    • (参考) https://javadoc.io/static/org.mockito/mockito-core/3.11.2/org/mockito/Mockito.html#stubbing
    • ただ、それ以外の作成方法は一緒! (例: @Mockを利用)

(話題の核心) UnnecessaryStubbingExceptionはどのような問題で発生するのか?

ここまで前提知識が詰まったところでようやく核心に入れる。。

このExceptionは、Mockについてではなく Stubについて述べている。
上記で述べたとおり、テストコードを無事テスト意図通りに動かすためだけのFake Dataを返せば良いというタイプのオブジェクトであるが、注意しなければならないのが、、

やみくもに Stub Codeをテスト対象コード、テスト意図をきちんと理解しないまま、テストを動かすだけに適当につけていってしまうと、テストコードが読みにくくなり、テストが汚くなってしまうということなのだ!

この問題に対してアラートを検知してくれる仕組みがこのExceprtionなのだ!

実際には、Mocktito JavaDocでわかりやすい例を表示してくれていた (https://javadoc.io/static/org.mockito/mockito-core/3.11.2/org/mockito/Mockito.html)

例:

以下テストでは before() メソッドにより、毎テストケース実行時に when(foo.foo()).thenReturn("ok"); が定義されている。

これは、test1 & test2 では問題なく動作するが、 test3では foo に対する interactionが発生していないため、when(foo.foo()).thenReturn("ok");というスタブ定義が冗長になってしまい、 UnnecessaryStubbingExceptionが発生してしまう。

 public class SomeTest {

     @Rule public MockitoRule mockito = MockitoJUnit.rule().strictness(STRICT_STUBS);

     @Mock Foo foo;
     @Mock Bar bar;

     @Before public void before() {
         when(foo.foo()).thenReturn("ok");

         // it is better to configure the stubbing to be lenient:
         // lenient().when(foo.foo()).thenReturn("ok");

         // or the entire mock to be lenient:
         // foo = mock(Foo.class, withSettings().lenient());
     }

     @Test public void test1() {
         foo.foo();
     }

     @Test public void test2() {
         foo.foo();
     }

     @Test public void test3() {
         bar.bar();
     }
 }

lenient 利用のススメ

このtest errorは mock objectに対して lenient モードを利用することによって消去することができる。
上記 公式Mockito documentationでは、別にそうして lenient モードを利用することは必ずしも悪いことではないと言っている (エラーはデフォルトで出しておきながら、、 )

理由としては、、

  • 上記のように、@Beforeメソッド時に、繰り返し的にStubの共通挙動を定義することで、コードの冗長化が減る

    • なので、上記例 (test3) のように、一部やむを得ない例外事項が発生してしまったとしても、重複コードの冗長化 (=>@Beforeメソッドの代わりに、各テストメソッドに同じ処理を重複して書き込む)よりは、 lenient モードを使い、(stubの冗長化をトレードオフし)@Beforeメソッドで初期化処理をまとめた方が綺麗である
  • ただ、いずれにしてもケースバイケースなので、開発者自身の best judgement に委ねる

私の会社のプロジェクトの場合は、、、

まさに lenient 利用のススメ と同じようなトレードオフ現象に遭遇したわけだが、私のテストの場合は、とりあえず@Beforeメソッドから共通処理を外し、各テストメソッドごとに特有の初期化処理を実装するようにした。

どうしてそのような判断をしたかというと、、、

  • テストケースごとに、このオブジェクトはStub初期化処理して、これはしない、というケースが複雑だったから

    • 場合によっては、テストケース時に、Before メソッドで定義したStubを resetする必要があった
    • ちなみに、resetを使うこともmockitoのベストプラクティスとしてはお勧めされていない
    • このままの状態で letientモードを使い共通化を優先してしまうと、後ほど別の開発者(いや私ですら)がテストコードを修正したり、デバッグしたり、となった時、非常に煩雑で読みにくい実装になるだろうと懸念されるから
  • また、各Stub初期化処理をメソッドにまとめる (例: setUpXXXServiceStub みたいにdescriptiveなメソッド名で)と、別に各テストコード内に記載しても、それなりに読みやすくキープできる

    • 同時に、各テストケース/テスト意図ごとに、オブジェクトがどのようにスタブされるべきであるかが、クリアにわかり良い判断だとは思う