🐤

MockKの「黒魔術」を解明する

2020/12/23に公開

An English translation of this article was published by @chao2zhang.
이 기사의 한국어 번역은 @sukyology 에 의해 출판되었습니다.

この記事はKotlin Advent Calendar 2020 23日目の記事です。

昨日は優秀な同僚@_a_akiraさんの記事でした。余談ですが彼の名前をZennのMarkdownエディタに入力するとitalic判定がおかしくて少しバグるので、ぜひテストケースに追加してあげてください。

閑話休題、本日はMockKの話です。MockKはテストコードに用いられるKotlin製のモックライブラリです。MockKではJVM向けモックライブラリが従来から持つ機能に加え、Kotlinの言語機能により使い勝手が向上しているため、私はKotlinを使用するプロジェクトで愛用しています。

しかし、MockKに限った話ではありませんが、JVM向けモックライブラリがどのように動いているのか疑問に思ったことはないでしょうか?また、モックライブラリで、他のライブラリと併用すると問題が発生し、原因の特定に苦労したことはないでしょうか?それを理由にMockKや他の似た振る舞いをするモックライブラリを敬遠する方もいらっしゃると思います。かく言う私も最近Androidのユニットテストで用いられるRobolectricとの併用で問題が発生し、詳しい動作を調べる必要がありました。

そこで本記事では、MockKがmockkメソッドで何をしているのか、そしてeveryverifyではどのようにしてモックの挙動の設定や呼び出し回数のチェックを行うのかについて解説します。

なお、本記事ではMockKの使い方は解説しません。また、MockKはAndroidやJSでも動作するように作られていますが、基本的にはJVM (HotSpot) での動作について解説します。


mockk でモックインスタンスを生成する

モック対象は通常のクラスか、それともインターフェイス、抽象クラスか

モック対象が通常のクラスかどうかは、生成するインスタンスのクラスに影響があります。
試しに以下のようなテストコードを書いてみましょう。

open class OpenClass
class FinalClass
abstract class AbstractClass
interface Interface

class ExampleTest {
    @Test
    fun `mock instance class`() {
        val o: OpenClass = mockk()
        val f: FinalClass = mockk()
        val a: AbstractClass = mockk()
        val i: Interface = mockk()
 
        println(o::class)
        println(f::class)
        println(a::class)
        println(i::class)
    }
}

すると、以下のような出力が得られます

class com.example.mockk_example.OpenClass
class com.example.mockk_example.FinalClass
class com.example.mockk_example.AbstractClass$Subclass0
class com.example.mockk_example.Interface$Subclass1

通常のクラスはfinalかそうでないかに関わらず指定したクラスそのもののインスタンスですが、抽象クラスやインターフェイスの場合はサブクラスが作られています。Javaの世界において、インターフェイスや抽象クラスのインスタンスをそのまま生成することはできず、実装ないし継承した具象クラスを定義する必要があるのは周知の事実ですが、モックインスタンスを生成するときもその制約は適用されます。

では、これらのAbstractClass$Subclass0Interface$Subclass1というクラスは一体どこから出てきたのでしょうか?また、通常のクラスはサブクラスを生成せずに、どのようにして戻り値などのカスタマイズをできるようにしているのでしょうか?

ByteBuddyによるクラス書き換え・サブクラス生成

それを知るには、ByteBuddyというライブラリを知る必要があります(仕組みについては詳しい解説をすると記事が膨大になってしまうため省略します)。

ByteBuddyは、クラスを動的に生成したり、クラスを書き換える機能をもつライブラリです。内部的にはJavaバイトコードを生成、書き換えしているのですが、Javaバイトコードの知識がなくても生成できるようなAPIになっています。

MockKでは、まずモックされるクラスとその全てのスーパークラスをByteBuddyを用いて書き換え、各メソッドの呼び出し結果を変更できるようにします。
実装

MockKでは、テスト実行時に以下のようなVMオプションを追加すると、指定したディレクトリ内に生成したサブクラスのclassファイルを保存するようになっています。

-Dio.mockk.classdump.path=/path/to/output/classfiles

この機能を使い、実際に通常のクラスがモックされたときに、メソッドが書き換えられることを確認してみましょう。

open class Root {
    fun root() = "root"
}
class Sub : Root() {
    fun sub() = "sub"
}
class ExampleTest {
    @Test
    fun `class transform`() {
        mockk<Sub>()
    }
}

上のclass transformテストを実行したときに生成されるclassファイルをデコンパイルすると、以下のようになります。

inline/1/com/example/mockk_example/Sub.class
// 略
public final class Sub
extends Root {
    public final String sub() {
        // 中略
	JvmMockKDispatcher dispatcher = JvmMockKDispatcher.get((long)-8685426189273937011L, (Object)this));
	Callable<?> callable;
	if (dispatcher == null) {
            callable = null;
	} else {
            callable = dispatcher.handler((Object)this, Sub.class.getMethod("sub", new Class[0]), new Object[0]);
	}
	
        if (callable != null) {
            return (String)callable.call();
        } else {
            return "sub";
        }
    }
}
inline/2/com/example/mockk_example/Root.class
public class Root {
    public final String root() {
        // 中略
	JvmMockKDispatcher dispatcher = JvmMockKDispatcher.get((long)-8685426189273937011L, (Object)this));
	Callable<?> callable;
	if (dispatcher == null) {
            callable = null;
	} else {
            callable = dispatcher.handler((Object)this, Root.class.getMethod("root", new Class[0]), new Object[0]);
	}
	
        if (callable != null) {
            return (String)callable.call();
        } else {
            return "root";
        }
    }
}
inline/3/java/lang/Object.class
public class Object {
    @HotSpotIntrinsicCandidate
    public Object() {
    }
    
    static {
        Object.registerNatives();
    }
    
    public String toString() {
        // 中略
	JvmMockKDispatcher dispatcher = JvmMockKDispatcher.get((long)-8685426189273937011L, (Object)this));
	Callable<?> callable;
	if (dispatcher == null) {
            callable = null;
	} else {
            callable = dispatcher.handler((Object)this, Object.class.getMethod("toString", new Class[0]), new Object[0]);
	}
	
	if (callable != null) {
            return (String)callable.call();
        } else {
            return this.getClass().getName() + "@" + Integer.toHexString(this.hashCode());
        }
    }
    
    // 以下略
}

実装が若干冗長になっているのは、ByteBuddyの制約によるものです。Subクラスだけではなく、スーパークラスであるRootjava.lang.Objectクラスも書き換えが行われていることがわかります。書き換え後のメソッドにはJvmMockKDispatcherというクラスが出てきます。このクラスを介して、MockKが処理可能な場合(多くはインスタンスがモックであるときです)はその結果を、そうでないときは元の実装の結果を返す、という処理を行っています。dispatcher.handlerの内部処理は実行する場所によって変わるのですが、詳しくは後述します。

また、モック対象がインターフェイス、抽象クラスだった場合のサブクラス生成もByteBuddyで行います。
実装

先程と同様にio.mockk.classdump.pathオプションを付けて出力されたクラスファイルをデコンパイルしてみます。以下のようにAbstractClassfooメソッドを追加し、mockkメソッドでモックを作成してみます。

abstract class AbstractClass {
    abstract foo(): String
}

すると以下のようなクラスが生成されます。

class AbstractClass$Subclass0 extends AbstractClass {
    // 中略
    private static final Method cachedValue$y1XfOC5k$43hl4d0 = AbstractClass.class.getMethod("foo", new Class[0]);
    
    public String foo() {
        return (String)JvmMockKProxyInterceptor.interceptNoSuper(
	    -7232240735635930748L,
	    (Object)this,
	    cachedValue$y1XfOC5k$43hl4d0,
	    new Object[0]
	);
    }
    
    // 以下略
}

デコンパイル結果は割愛しますが、AbstractClass$Subclass0のスーパークラスであるAbstractClassjava.lang.Objectも、先述の通り書き換えが行われます。生成されたサブクラスでは先程とは異なるメソッドが呼ばれていますが、実際はinterceptNoSuperメソッド内で同じ処理をします。

ちなみに、MockKはAndroid上での動作もサポートされていますが、その場合はjava.lang.reflect.Proxyを用います。ProxyはRetrofitなどでも用いられているため、Androidデベロッパーにも馴染みがあると思います。

Objenesisによるインスタンス生成

さて、ByteBuddyのクラス書き換え、サブクラス生成を知ることによって、everyverifyの挙動を理解するためのヒントが見えてきました。しかしその前にもう1点、mockkメソッドではどのようにしてインスタンスを生成しているのか、について解説します。

説明のために極端な例として、以下のようなクラスを定義します。

class UnimplementedClass {
    init {
        throw NotImplementedError()
    }
}
class ExampleTest {
    @Test
    fun `unimplemented class mocking`() {
        val unimplemented = mockk<UnimplementedClass>()
        assertEquals(UnimplementedClass::class, unimplemented::class) // success
    }
}

インスタンスを生成するときに必ずNotImplementedErrorをthrowするようになっているので、初期化に失敗するはずですが、MockKを使うとインスタンスの生成に成功します。

また、non nullなフィールド変数があった場合でも、モックインスタンスではnullになります。

class FieldExists {
    @JvmField
    var field: String = "Hello"
}
class ExampleTest {
    @Test
    fun `field exists class mocking`() {
        val fieldExists = mockk<FieldExists>()
        assertEquals("Hello", fieldExists.field)
        // java.lang.AssertionError:
        // Expected :Hello
        // Actual   :null
    }
}

これらのことからもわかるように、通常のコンストラクタ呼び出しとは別の方法でインスタンスが生成されています。MockKでは、これをObjenesisというライブラリを用いて実現しています。

ObjenesisはEasyMockチームによって開発され、EasyMock以外にもMockitoなど著名なモックライブラリでも用いられています。また、話は逸れてしまいますが、このライブラリは数多くのJVM、環境に対応していることも特徴です。

では、Objenesisではどのようにしてインスタンスを生成するのでしょうか?JVMの種類によってアプローチが異なっていますが、HotSpotであれば、まずsun.reflect.ReflectionFactory#newConstructorForSerializationを呼びます。このメソッドはその名の通りSerialization用のコンストラクタを作成してくれるメソッドです。このコンストラクタはフィールドの初期化などを含め何も行わずにインスタンスを生成するため、上記の例のように、フィールドはnullで、initブロックも実行せずにインスタンスを生成できるのです。最後に、生成されたコンストラクタを呼べばインスタンスを生成できます。

everyでアンサーを設定する

前章ではmockkメソッドによってモックインスタンスを生成するだけでなく、クラスの書き換えが行われることを解説しました。では、everyメソッドではそれらの書き換えがどのように活用されるのでしょうか?

モックインスタンスを監視するCallRecorder

前章に出てきたJvmMockKDispatchereveryメソッド内の処理で度々登場するのがこのCallRecorderクラスです。CallRecorderは、「現在の状態」やモックインスタンスのメソッド呼び出し回数を記録します。

CallRecorderが記録する「状態」は以下の6種類です。

  • Answering ・・・ 通常の状態。アンサーを実行するのはこの状態。
  • Stubbing ・・・ everyブロック内の処理を記録する状態
  • StubbingAwaitingAnswer ・・・ Stubbingの後、returnsメソッドなどでアンサーを設定する前の状態
  • Verifying ・・・ verifyブロック内の処理を記録する状態
  • Exclusion ・・・ excludeRecordsブロック内の処理を記録する状態

この状態が異なると、モックインスタンスのメソッド呼び出し時の振る舞いも変わります。

everyメソッドが呼ばれると、ブロックを呼ぶ前にまずCallRecorderの状態をAnsweringからStubbingに変えてからブロックを実行します。ブロック内でモックインスタンスのメソッドが呼ばれると、JvmMockKDispatcherを経由してCallRecorderにメソッドの呼び出し情報(メソッド自体の情報や引数、戻り値の型など)が記録されます。この呼び出し情報は、後にアンサーを実行する際にパターンマッチをするのに用います。

ブロックが実行された後は、CallRecorderの状態をStubbingからStubbingAwaitingAnswerに変え、アンサーの設定を待ちます。returnsなどのアンサーを設定するメソッドを実行した後は、StubbingAwaitingAnswerからAnsweringに戻します。

時には複数のメソッドがブロック内で呼ばれることがありますが、テストの実装者がアンサーを設定するのは最後に呼ばれたメソッドですので、その他のメソッドについてはMockK側で「モックインスタンスを返す」というアンサーが設定されます。

以下のようなメソッドチェーンを例に説明します。

interface Foo {
    fun bar(): Bar
}
interface Bar {
    val value: String
}
class ExampleTest {
    @Test
    fun `method chain`() {
        val foo: Foo = mockk()
	// 連続して設定
	every { foo.bar().value } returns "mock"
	
	// 分けて実行しても問題ない
	val bar = foo.bar()
	assertEquals("mock", bar.value) // success
    }
}

foo.bar()のアンサーは「Bar$Subclass1のモックインスタンス(仮にmockInstance1とします)を返す」になり、mockInstance1.valueのアンサーは「"mock"を返す」になります。したがって例のように、everyで設定したメソッドは必ずしも連続して呼び出す必要はありません。

アンサーや実行履歴を記録するMockKStub

実はmockkメソッドではモックインスタンスの他に、MockKStubというクラスのインスタンスも生成します。モックインスタンスが生成されたクラスは、先程デコンパイルしたようにメソッドは書き換えられています。しかし、everyにて行われる呼び出し情報とアンサーのペアの設定や、verifyにて後ほど使われる呼び出し履歴は記録されません。その代わりに、モックインスタンスと1対1で対応するMockKStubクラスのインスタンスを生成して、このMockKStubインスタンスにそれらを記録します。

モックインスタンスにMockKStubインスタンスの参照を持たない代わりに、モックインスタンスをキー、MockKStubを値としてシングルトンのWeakMapで管理されます。このWeakMapは、あるインスタンスがモックか普通のインスタンスかの判別にも用いられます。


実際のメソッド呼び出し

ここまでの説明を読めば、お察しの良い方であればメソッド呼び出しでアンサーを実行する方法も想像がつくかもしれません。

テスト対象クラス内でメソッドが呼ばれるときは、CallRecorderの状態はAnsweringになっているはずです。Answeringの状態でモックインスタンスのメソッドが呼ばれると、JvmMockKDispatcherを経由してCallRecorderはメソッドに渡された引数などの情報を取得し、everyにて設定された呼び出しとアンサーのペアのうち、パターンが一致するものがないかチェックします。存在すればそのアンサーを実行、なければ通常であればMockKExceptionをスローします(リラックスモックであればモックを生成して返します)。

また、モックのメソッドが呼ばれると、呼び出し履歴をMockKStubに記録します。


verifyメソッド呼び出し

もうここまで来ればverifyが何をするか説明は不要かもしれませんが一応。verifyが呼ばれると、CallRecorderの状態をVerifyingにします。この状態でブロックを実行して他と同様に呼び出し情報を取得し、MockKStubに記録された呼び出し記録を照らし合わせて本当に呼ばれているかどうか確かめます。


その他の小話

ジェネリクス

ジェネリクスの型情報はJavaの実行時には参照できません。そのため、以下の3つのテストは少々面白い挙動をします。

class Container<S: Sealed>(val sealed: S)
sealed class Sealed
data class SubSealed(val a: String) : Sealed()

class ExampleTest {
    @Test
    fun test1() {
        val container: Container<SubSealed> = mockk(relaxed = true)
        assertEquals(SubSealed::class, container.sealed::class) // failed
    }
    
    @Test
    fun test2() {
        val container: Container<SubSealed> = mockk(relaxed = true)
        val sealed: SubSealed = container.sealed // ClassCastException
        assertEquals(SubSealed::class, sealed::class)
    }
    
    @Test
    fun test3() {
        val container: Container<SubSealed> = mockk(relaxed = true)
        every { container.sealed } returns mockk()
        val sealed: SubSealed = container.sealed

        assertEquals(SubSealed::class, sealed::class) // success
    }
}

test1では、型情報が失われているためassertEqualsに失敗します。Sealed$Subclass0という新たなサブクラスが生成され、container.sealedはそのインスタンスとなるからです。

test2も同様にcontainer.sealedSealed$Subclass0のインスタンスとなるため、メソッドの2行目でClassCastExceptionをスローします。

しかしtest3は通ります。実はeveryではブロック内で発生するClassCastExceptionのメッセージをもとに型情報を補完してくれます。更にreturns mockk()SubSealedクラスのインスタンスを生成することはコンパイル時に決定されます。そのためテストが通るというわけです。

ここで述べたかったことは他にもあります。

まず、当然ですが絶対にeveryブロック内で副作用のあるメソッドを実行しないでください。

例えば先程のテストを少し修正して、

    @Test
    fun test3() {
        val container: Container<SubSealed> = mockk(relaxed = true)
	var i = 0;
        every { i++; container.sealed } returns mockk()
        val sealed: SubSealed = container.sealed

        assertEquals(SubSealed::class, sealed::class) // success
	assertEquals(1, i) // failed
    }

とすると、iが2になります。これはジェネリクスの型情報を補完するために複数回everyのブロックが実行されるからです。

次に、sealed classの制約(サブクラスをファイル外では作れないこと)が壊れることに注意してください。

sealed classはJavaの世界ではただのabstract classです。クラスの動的生成のテクニックを使えば、ファイル内で定義したサブクラス以外のクラスも作られるということです。

spykmockkObjectmockkStatic

spykmockkObjectmockkStaticの説明を忘れていました。ですが、mockkの説明だけでもなんとなくそれらが行っていることは予想が付きそうです。

spykの場合、mockkと異なるのは、生成したモックインスタンスのメソッドを渡されたインスタンスのメソッドにプロキシすること、そして、アンサーや実行履歴を記録するMockKStubで説明したWeakMapにSpyKStubというMockKStubのサブクラスを記録することです。SpyKStubの場合、アンサーが存在しないときは例外をスローするのではなく、オリジナルメソッドを呼ぶように実装されています。

mockkObjectmockkStaticも、クラスの書き換え対象が異なること、WeakMapにオブジェクトやClass<*>をキーとしてSpyKStubを保存することくらいの違いがあるだけです。

「ん?SpyKStubなの?」とお気づきの方は察しが良いですね。はい、そうです、mockkObjectmockkStaticは実際にはスパイの挙動のほうが近いです。


最後に

長文にお付き合いいただきありがとうございました。

クラス書き換えをしていると知って、やはり黒魔術感を感じたかもしれません。正直私も感じました。ですが、staticモックなど他の実装にもロジックを流用できるという考えは非常に面白いと個人的には思いました。

仕組みや実装を知っておくと、なにかエラーが起きたときにデバッグするポイントを見極められるので、もしこの記事でご興味を持たれたらGitHubのソースの方も読んでみると新たな発見があるかもしれません。

Discussion