単体テストの興味・関心ごと

会社内で

「メソッドが呼ばれたか、N回呼び出したかどうかのテストは外から見た振る舞いのテストに含まれるのか?」

という疑問を目にしたのでそれに対しての自分の答えをつらつらと書いていく。

つまり以下のようなテストが外部からの振る舞いをテストしているかどうか (≒ 良いテストであるかどうか) について自分の意見を整理していきたい。

@Test
fun doAction_doesSomething(){ 
  /* Given */
  val mock = mock<MyClass> {
    on { getText() } doReturn "text"
  }
  val classUnderTest = ClassUnderTest(mock)
  
  /* When */
  classUnderTest.doAction()
  
  /* Then */
  verify(mock).doSomething(any()) // これって良いテストなの???
}

※上記は、 https://github.com/mockito/mockito-kotlin のサンプルコードより拝借

TL;DR

任意のコンポーネントは、 入力 -> F(何かしらの処理) -> (出力 or 例外) + 副作用 という構成をしており、「外部から見た振る舞い」には (出力 or 例外) + 副作用 全てが含まれると考えているので、 副作用 をテストする目的で利用される 「メソッドが呼ばれたか、N回呼び出したかどうかのテストは外から見た振る舞いのテストに含まれるのか?」 は、 外部から見た振る舞いをテストしている と考えている。

思考整理

契約プログラミング (a.k.a Design by Contract) の図にもある通り、ある特定のコンポーネントの構成要素は、主に以下の4種類だと自分は捉えている

もっと端的にいうと 入力 -> F(何かしらの処理) -> (出力 or 例外) + 副作用 という関係にあると考えており、 (出力 or 例外) + 副作用 全てを外部から見た時のそのコンポーネントの振る舞いだと捉えている。
つまり単体テストでも、これら3つの振る舞いをテストとして記述したいと考えることができる。

出力 と 例外

出力は一番単純なパターンで、以下のようにテストを書くことができる。

@Test
fun test(){ 
  // Arrange
  val classUnderTest = ClassUnderTest()
  val expected = ExpectedObj()
  
  // Act
  val actual = classUnderTest.doAction()
  
  // Assert
  Assertion.assertEquals(expected, actual)
}

例外の場合にも、多くのテストライブラリには例外を捕捉する機能がついてるので難しくはない。

@Test
fun test(){ 
  // Arrange
  val classUnderTest = ClassUnderTest()
  val expected = ExpectedException()
  
  // Act
  val e = assertThrows<ExpectedException> {
    classUnderTest.doAction()
  }
  
  // Assert
  Assertion.assertEquals(expected, e)
}

これらが、入力に対しての出力をテストしているのは明らかでこういったテストが「外部からの振る舞い」をテストしていることに疑問を持つ人はいないだろう。

副作用がある場合

副作用が一番曖昧で判断が難しいポイントだと考えている。

いわゆる情報工学の分野における「副作用」という言葉は、単に状態の変更だけを指すわけではなくある特定のコンポーネントを力学における系のように捉えたときに系に与えるあらゆる影響、効果を指すことが多い。
例えば、メールを送信するとかログを残すとかは状態を変更しているわけではないが系に対しての影響、効果を及ぼしているのでこれらを副作用と呼ぶのは自然だと考える。

その前提の上で 単体テストの考え方/使い方 という本では、

モックはテスト対象システムからその依存に向かって行われる 外部に向かう コミュニケーション (出力) を模倣し、そして、検証するのに使われる。このときモックが模倣するコミュニケーションはテスト対象システムが依存の状態を変えるために行うその依存への呼び出しのことになる。

と書かれていて、「外部に向かう コミュニケーション (出力) を模倣し、そして、検証するのに使われる」はまさに「副作用を検証する」目的で利用されると解釈することができる。

このように考えると、副作用という 出力例外 のように返値などで直接的に観測できないもののテストとしてモックが利用されている場合には、それは 副作用 という振る舞いの一部を検証対象に入れていると考えることができるため冒頭に挙げた以下のようなテストも 外部から見た振る舞いをテストしている と言えると考えている。

@Test
fun doAction_doesSomething(){ 
  /* Given */
  val mock = mock<MyClass> {
    on { getText() } doReturn "text"
  }
  val classUnderTest = ClassUnderTest(mock)
  
  /* When */
  classUnderTest.doAction()
  
  /* Then */
  verify(mock).doSomething(any()) // doSomething が classUnderTest の副作用の場合には外部から見た振る舞いをテストしている
}

まとめ (再掲)

任意のコンポーネントは、 入力 -> F(何かしらの処理) -> (出力 or 例外) + 副作用 という構成をしており、「外部から見た振る舞い」には (出力 or 例外) + 副作用 全てが含まれると考えているので、 副作用 をテストする目的で利用される 「メソッドが呼ばれたか、N回呼び出したかどうかのテストは外から見た振る舞いのテストに含まれるのか?」 は、 外部から見た振る舞いをテストしている と考えている。

参考