Edited at

Specs2とMockitoと面白かった罠

More than 3 years have passed since last update.

近頃PSO2にハマりまくっているエンジニアのsuikwashaです.

ドワンゴでは主にScalaで開発をしています.

今回は, Akka/AkkaCluster, Specs2とMockito, Scalastyleの話, どちらをやろうか迷ったのですがAkkaの話とかは他の詳しい方が書いてくれそうなところなので, 今回はSpecs2について, 特にSpecs2とMockitoを組み合わせて利用した時の注意点について書いてみます.


Specs2とMockitoについて

Specs2というScala用のテストライブラリがあります.

PlayFrameworkでもはじめから利用可能であり, Playのドキュメントにも利用方法がまとめられていることから結構な開発者が利用してるものと思っています.

さて, ドキュメントにも紹介されているのですが, Specs2ではデフォルトでMockitoというモックライブラリを利用することが出来ます. Mockitoは元々Javaで作られたモックライブラリです, Specs2はそれをラップする形で利用可能にしています.

このSpecs2とMockitoを利用するとテストが書きやすくなり便利です.

私のチームでも使っているのですが, 使っているうちにいくつかの罠を踏みました. そのままだと, 通らないはずのテストが通ってしまったり, 通るはずのテストが通らずに原因究明に時間がかかってしまいます.

その中でも面白かった罠と原因を紹介していきます.


モックにしたクラスのメソッドにあるデフォルト引数がnullになる

以下の様なコードを書いてみます.


import org.specs2.mock.Mockito
import org.specs2.mutable.Specification

class FooClient {

def doSomething(param: String = "foo"): Unit = println(param)
}

class FooInvoker (foo: FooClient) {
def invoke = foo.doSomething()
}

class FooInvokerSpec extends Specification with Mockito {

"FooInvoker" should {
"doSomethingを呼び出せる" in {
val mockFoo = mock[FooClient]
val invoker = new FooInvoker(mockFoo)

invoker.invoke

there was one(mockFoo).doSomething(===("foo"))
}
}
}

実行してみます.

[info] FooInvokerSpec

[info]
[info] FooInvoker should
[info] x doSomethingを呼び出せる
[error] The mock was not called as expected:
[error] Argument(s) are different! Wanted:
[error] fooClient.doSomething(
[error] 'null' is not equal to 'foo'
[error] );
[error] -> at com.suikwasha.FooInvokerSpec$$anonfun$1$$anonfun$apply$2$$anonfun$apply$1.apply$mcV$sp(FooInvokerSpec.scala:24)
[error] Actual invocation has different arguments:
[error] fooClient.doSomething(
[error] null
[error] );
[error] -> at com.suikwasha.FooInvoker.invoke(FooInvokerSpec.scala:12) (FooInvokerSpec.scala:24)

デフォルト引数に"foo"を指定しているのに, nullで呼び出されているようです. コードを見ても, デフォルト引数を利用して呼び出しているのでこのテストが通らないのはおかしいです.

原因を探るために, デコンパイラ(例:JavaDecompiler)を使ってどのようなコードが生成されているのか確かめてみます.

  public void invoke()

{
this.foo.doSomething(this.foo.doSomething$default$1());
}

デフォルト引数は, インスタンスの"doSomething\$default\$1()"を使って呼び出してるようです.

Specs2のMockitoでは, 以下のようにモックの挙動を定義することができます.


mockFooClient.doSomething(===("foo")) returns ...

未定義のモックの戻り値はnullになります. つまり, テストコードではモックのインスタンスの"doSomething\$default\$1()"を呼び出すわけですが, これがnullになってしまいます.

なので, デフォルト引数を利用した時のモックの挙動を書きたいときは


mockFooClient.doSomething(===(null)) returns ...

としなければいけなく, とてもつらい感じになります.

また, AnyValを使うとnullではなく, 0になってしまうので注意が必要です.

以下FooClientにIntのデフォルト引数を利用したメソッドを追加したときとそのテストを記述した時です.


def doSomething2(param: Int = 1): Int = param

[info] x doSomething2を呼び出せる

[error] The mock was not called as expected:
[error] Argument(s) are different! Wanted:
[error] fooClient.doSomething2(
[error] '0' is not equal to '1'
[error] );
[error] -> at com.suikwasha.FooInvokerSpec$$anonfun$1$$anonfun$apply$4$$anonfun$apply$2.apply$mcI$sp(FooInvokerSpec.scala:38)
[error] Actual invocation has different arguments:
[error] fooClient.doSomething2(
[error] 0
[error] );
[error] -> at com.suikwasha.FooInvoker.invoke2(FooInvokerSpec.scala:16) (FooInvokerSpec.scala:38)


何故か3回呼ばれてしまうモック

まず以下のようなテストを書いてみます.

import org.specs2.mock.Mockito

import org.specs2.mutable.Specification

class BarClient {
def openSession(f: () => Unit): Unit = ???
}

class FooOperation {
def doWithBarClient(barClient: BarClient): Unit = ???
}

class DoSomethingWithBar (barClient: BarClient, fooOp: FooOperation) {

def doSomethingWithBarAndFoo(): Unit = {
barClient.openSession { () =>
fooOp.doWithBarClient(barClient)
}
}
}

class DoSomethingWithBarClientSpec extends Specification with Mockito {

"DoSomethingWithBarClient" should {

"BarClientのセッションを開いて, FooOperationを実行する" in {
val mockBarClient = mock[BarClient]
mockBarClient.openSession(any) answers { arg => arg.asInstanceOf[() => Any].apply() }

val mockFooOperation = mock[FooOperation]

val target = new DoSomethingWithBar(mockBarClient, mockFooOperation)
target.doSomethingWithBarAndFoo()

there was one(mockBarClient).openSession(any)
there was one(mockFooOperation).doWithBarClient(===(mockBarClient))
}
}
}

上記のコードは, 何かのクライアントのセッションを開いて, セッションを開いた状態で何かするというクラスのテストのつもりです.

割とちょくちょくあるんじゃないかと思います.

実行してみます.

[info] DoSomethingWithBarClientSpec

[info]
[info] DoSomethingWithBarClient should
[info] x BarClientのセッションを開いて, FooOperationを実行する
[error] The mock was not called as expected:
[error] fooOperation.doWithBarClient();
[error] Wanted 1 time:
[error] -> at com.suikwasha.DoSomethingWithBarClientSpec$$anonfun$1$$anonfun$apply$4$$anonfun$apply$3.apply$mcV$sp(DoSomethingWithBarClientSpec.scala:37)
[error] But was 3 times. Undesired invocation:
[error] -> at com.suikwasha.DoSomethingWithBar.doSomethingWithBarAndFoo(DoSomethingWithBarClientSpec.scala:17) (DoSomethingWithBarClientSpec.scala:37)

なぜかfooOperation.doWithBarClient3回呼ばれてますね, コードを読んでも1回しか呼ばれてないはずなのに...

これは, Specs2のMockitoのラッパーがモックの引数をチェックするときにFunction0の場合は特別扱いしてapplyしてしまう仕様のために起きます.

なぜapplyするのかというと, 名前渡しの引数に対応するためです.

scalaのコンソールで名前渡しがどのようなクラスになるか見てみます.

scala> class A { def func(a: => Int): Unit = ??? }

defined class A

scala> classOf[A].getDeclaredMethods
res2: Array[java.lang.reflect.Method] = Array(public void A.func(scala.Function0))

A.funcの引数がscala.Function0になってますね, コンパイルされ, 引数aはFunctioin0に包まれ実行時はFunction0の型を持ちますが, コード上はIntです.

Mockitoでは, 実行時にA.funcの引数がIntだと思い, Function0をIntに変換しようとします. しかし, これはもちろん失敗してしまうので, Specs2が前もってapplyしてIntを取り出しているようです. このコミットで対応したみたいですが, 実装をMockito自体のクラスを上書いていたりしてとても楽しい感じになっています.

 else if (arg.isInstanceOf[Function0[_]]) {

// evaluate the byname parameter to collect the argument matchers
// if an exception is thrown we keep the value to compare it with the actual one (see "with Any" in the MockitoSpec and issue 82)
val value = try { arg.asInstanceOf[Function0[_]].apply() } catch { case e: Throwable => e }

ですが, この対応の副作用として名前渡しではない引数までapplyされるようになってしまいました. 結果として, モックでFunction0を受け取り, それを実行してあげるようなコードを書くときに, 複数回実行されてしまっていたわけです...

これは, 対策がこれといって思いつかないので誰か教えてください.


終わりに

他にもAnyValを継承したクラスをモックできなかったり...とかいろいろありますが疲れてしまったのでこれぐらいにします.

参考になれば幸いです.