言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

@JvmOverloadsなfunctionをmockできないケースがある

@JvmOverloadsを付与したKotlinのfunctionを、Javaから呼び出すコードを書いた。ここのテストを書こうと思ってmockito-kotlinでmockしようとしてできなかったときのメモ。ちなみにmockしたいクラスはDagger2でDIしている前提。

やりたかったこと

例えばこんなKotlinで書かれたfunctionがあったとして、

// Hoge.kt
class Hoge {
    @JvmOverloads
    fun equal(a: String, b: String = "0"): Boolean = a == b
}

それをJavaから呼び出しをするコードに対して

// Fuga.java
public class Fuga {

    @Inject
    Hoge hoge = new Hoge();

    @VisibleForTesting
    public Fuga(Hoge hoge) {
        this.hoge = hoge;
    }

    boolean callHogeEquals() {
        return hoge.equal("1");
    }
}

mockするようなテストコードを書こうと思ったらfailする

class FugaTest {

    @Mock
    lateinit var hoge: Hoge

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }
}
junit.framework.AssertionFailedError: 
Expected :true
Actual   :false

mockできずに本体コード通っちゃったのかな?と思ったが、こういうコードに対して同じようなテストを書いたところ

    @JvmOverloads
    fun returnConcatString(a: String, b: String = "0") = a + b
    @Test
    fun testCallHogeConcatString() {
        val fuga = Fuga(hoge)
        whenever(hoge.returnConcatString("1")).thenReturn("hoge")

        val result = fuga.callHogeCancatString()

        Assert.assertEquals("hoge", result)
    }
junit.framework.ComparisonFailure: 
Expected :hoge
Actual   :null

という結果になったので、どうやら本体コードを通っているのではなく、その型のdefault値が返って来ているように見える。

どうやって回避する?

1. 全てKotlinで書く

今回のようにTest対象のクラスだけがJavaで、mock対象とTestコードがKotlinで書かれた場合にこの問題が発生する。Test対象のクラスもKotlinで書いた場合には再現しないので、Test対象のクラスをKotlinで書き直すと回避できる。

2. 全てのParameterを指定する

別の方法として、Javaで書かれたTest対象のクラスからmock対象のコードを呼び出す時に、全てのParameterを指定するように変更するとこの問題は回避できる。

先ほどの例でいうと、Test対象のJavaをこんな感じに変更して、

    boolean callHogeEquals() {
        return hoge.equal("1", "0"); // default parameterと同じものを2nd parameterに指定
    }

Testコードも2nd paramterをつけてやると

    @Test
    fun testCallHogeEquals() {
        val fuga = Fuga(hoge)
        whenever(hoge.equal("1", "0")).thenReturn(true)

        val result = fuga.callHogeEquals()

        Assert.assertEquals(true, result)
    }

無事にtestがpassする。ちなみにmock対象のコードは@JvmOverloads をつけたままで問題ない。

なぜmockできないのか?

@JvmOverloads がついたfunctionをJavaのbyte codeで見てみると、それぞれのシグネチャのパターン分のメソッドだけでなく、ブリッジメソッドも作られていた。

  // access flags 0x1049
  public static synthetic bridge equal$default(Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
  @Lkotlin/jvm/JvmOverloads;() // invisible
    ILOAD 3
    ICONST_2
    IAND
    IFEQ L0
   L1
    LINENUMBER 8 L1
    LDC "0"
    ASTORE 2
   L0
    ALOAD 0
    ALOAD 1
    ALOAD 2
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    IRETURN
    MAXSTACK = 3
    MAXLOCALS = 5

で、mockする箇所で引数一つで呼び出すとそのブリッジメソッドをINVOKESTATICしている。

   L3
    LDC "1"
    ACONST_NULL
    ICONST_2
    ACONST_NULL
    INVOKESTATIC com/example/muumuu/playgroundapp/Hoge.equal$default (Lcom/example/muumuu/playgroundapp/Hoge;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP

自分の理解ではmockitoは@Mockを付与したインスタンスはdummy objectを生成して、when等で定めた振る舞いをstubとして生成している。そのため、今回のようにブリッジメソッドをstubとして生成しても実際にJavaのコードからコールされるのはこのブリッジメソッドではないのでstubが存在しないため戻り値のクラスのデフォルト値が返ってしまったようだ。

それでは上記回避策をとった場合は、どのようなbyte codeが生成されていて、なぜ問題が起きないのか見ていこう。

まず、全てKotlinで書かれた場合、mockする時だけでなく本体側も@JvmOverloadsなfunctionを同じシグネチャでコールするとブリッジメソッドが呼ばれるので、stubする対象が一致するので正常にmockできる。

次にstub生成時に引数をフルで指定する場合は、ブリッジメソッドではなく引数がフルに揃ったシグネチャのメソッドのスタブが作成されるため正常にmockできる。

   L3
    LDC "1"
    LDC "1"
    INVOKEVIRTUAL com/example/muumuu/playgroundapp/Hoge.equal (Ljava/lang/String;Ljava/lang/String;)Z
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKESTATIC com/nhaarman/mockito_kotlin/MockitoKt.whenever (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    ICONST_1
    INVOKESTATIC java/lang/Boolean.valueOf (Z)Ljava/lang/Boolean;
    INVOKEINTERFACE org/mockito/stubbing/OngoingStubbing.thenReturn (Ljava/lang/Object;)Lorg/mockito/stubbing/OngoingStubbing;
    POP




はー、フルKotlinな世界はよ〜〜〜〜〜