MockKの「黒魔術」を解明する
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
メソッドで何をしているのか、そしてevery
やverify
ではどのようにしてモックの挙動の設定や呼び出し回数のチェックを行うのかについて解説します。
なお、本記事では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$Subclass0
やInterface$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ファイルをデコンパイルすると、以下のようになります。
// 略
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";
}
}
}
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";
}
}
}
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
クラスだけではなく、スーパークラスであるRoot
やjava.lang.Object
クラスも書き換えが行われていることがわかります。書き換え後のメソッドにはJvmMockKDispatcher
というクラスが出てきます。このクラスを介して、MockKが処理可能な場合(多くはインスタンスがモックであるときです)はその結果を、そうでないときは元の実装の結果を返す、という処理を行っています。dispatcher.handler
の内部処理は実行する場所によって変わるのですが、詳しくは後述します。
また、モック対象がインターフェイス、抽象クラスだった場合のサブクラス生成もByteBuddyで行います。
(実装)
先程と同様にio.mockk.classdump.path
オプションを付けて出力されたクラスファイルをデコンパイルしてみます。以下のようにAbstractClass
にfoo
メソッドを追加し、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
のスーパークラスであるAbstractClass
やjava.lang.Object
も、先述の通り書き換えが行われます。生成されたサブクラスでは先程とは異なるメソッドが呼ばれていますが、実際はinterceptNoSuper
メソッド内で同じ処理をします。
ちなみに、MockKはAndroid上での動作もサポートされていますが、その場合はjava.lang.reflect.Proxy
を用います。Proxy
はRetrofitなどでも用いられているため、Androidデベロッパーにも馴染みがあると思います。
Objenesisによるインスタンス生成
さて、ByteBuddyのクラス書き換え、サブクラス生成を知ることによって、every
やverify
の挙動を理解するためのヒントが見えてきました。しかしその前にもう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
モックインスタンスを監視する前章に出てきたJvmMockKDispatcher
やevery
メソッド内の処理で度々登場するのがこの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.sealed
はSealed$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です。クラスの動的生成のテクニックを使えば、ファイル内で定義したサブクラス以外のクラスも作られるということです。
spyk
とmockkObject
、mockkStatic
spyk
とmockkObject
、mockkStatic
の説明を忘れていました。ですが、mockk
の説明だけでもなんとなくそれらが行っていることは予想が付きそうです。
spyk
の場合、mockk
と異なるのは、生成したモックインスタンスのメソッドを渡されたインスタンスのメソッドにプロキシすること、そして、アンサーや実行履歴を記録するMockKStub
で説明したWeakMapにSpyKStub
というMockKStub
のサブクラスを記録することです。SpyKStub
の場合、アンサーが存在しないときは例外をスローするのではなく、オリジナルメソッドを呼ぶように実装されています。
mockkObject
やmockkStatic
も、クラスの書き換え対象が異なること、WeakMapにオブジェクトやClass<*>
をキーとしてSpyKStub
を保存することくらいの違いがあるだけです。
「ん?SpyKStub
なの?」とお気づきの方は察しが良いですね。はい、そうです、mockkObject
やmockkStatic
は実際にはスパイの挙動のほうが近いです。
最後に
長文にお付き合いいただきありがとうございました。
クラス書き換えをしていると知って、やはり黒魔術感を感じたかもしれません。正直私も感じました。ですが、staticモックなど他の実装にもロジックを流用できるという考えは非常に面白いと個人的には思いました。
仕組みや実装を知っておくと、なにかエラーが起きたときにデバッグするポイントを見極められるので、もしこの記事でご興味を持たれたらGitHubのソースの方も読んでみると新たな発見があるかもしれません。
Discussion