KMMのテストはなぜ必要か

久保出雅俊氏(以下、久保出):今日の発表、connpass上は「expect/actual」の話だったのですが、テストのtipsの話をします。よろしくお願いします。

久保出といいます。ウォンテッドリーのモバイルエンジニアやっています。wantedly.comのIDは、kubodeが私のプロフィールです。

今日のアジェンダですが、KMMのテストについてと、Kotlin/JVMとのテストの違いについてと、あとはその違いを埋めるtipsの紹介をしたいと思います。

KMMのテストについてですが、KMMのテストがなぜ必要かという話です。

KMMは基本的にはUIを持ちません。

では、KMMのテストはどうしたらいいのか。UI上でマニュアルでテストをしようとなると、UIの実装が必要になってきて、しかもAndroidとiOS両方でのテストが必要になってきます。

そうなると、開発のリードタイムが非常に長くなってしまって、生産性がすごく落ちてしまいます。開発生産性高めるためにも、KMM側でテストを書くのがほぼ必須になっています。

KMMのテストのディレクトリの構成ですが、このようなかたちになっています。

KMMの新しいプロジェクトを作ると、commonTest、iosTest、androidTestの3つのディレクトリがあって、commonの中にiOS、Android両方で実行されるテストコードを置きます。

もし、expect/actualなどを使っていて、プラットフォーム固有のコードがあるのであれば、iosTestや、androidTestにプラットフォーム専用のテストを書くというかたちです。

KMMは、コードの共通化をしていくものなので、基本的にはcommonTestにテストを書いていくことになります。

KMMとKotlin/JVMのテストの違い

次に、KMMとKotlin/JVMとのテストの違いですが、commonTestに配置するテストコードでは、Kotlin/CommonのAPIしか利用できません。

どういうことかというと、JVM固有の各種APIや、Kotlin/Nativeに対応してないAPIやライブラリは使えないです。

具体的には、JVMのTestRuleや、ほかのアノテーションベースのJVMの機能や、あとはcoroutine-testみたいな、JVMでしか使えないライブラリなどは利用できません。

また、MockK、Kotlinのモックライブラリですが、これはKotlin/Nativeに今は対応していないので、使えないです。

KMMとKotlin/JVMのテストの差を埋める方法

この差を埋めるためのtipsを、ちょっと紹介していきます。

まずは、JUnitであったTestRuleをKMMで実装するという話です。

そもそも、なぜTestRuleを自作しようとしたかというと、KMMでデータベースを使った機能があって、それをテストしたいからです。

通常、データベースを使うテストの場合は、テストを走らせるたびに、データベースの状態をクリーンな状態にしたいのですが、下にあるような、MyTest1みたいな感じで、TestクラスごとにBeforeTest、AfterTestを毎回毎回書くのをやると、何回も何回も書かなくてはいけなくなってしまいます。

これを省略して、TestRuleみたいなかたちにして、楽にしたいというのがきっかけです。

という感じで、このようなTestRuleを実装しました。実装は、こんな感じです。Testのルール自体のインターフェイスを定義して、TestRuleを定義してます。

カスタムのルールを作るには、このTestRuleを実装していきます。

このHasTestRulesというのは、Testクラスに実装します。HasTestRulesは、@BeforeTest、@AfterTestのアノテーションのついたデフォルトの実装を持ってます。

これを実装することで、このTestクラス側に、@BeforeTest、@AfterTestを書かなくても、デフォルト実装がテストの前後で呼ばれるようになります。

実際に自作したデータベースのTestRuleがこんな感じです。

DbTestRuleは、TestRuleを実装していて、BeforeとAfterの記述をしています。

この場合、testSqlDriver()というのが、毎回DBを作り直しているんですよね。テスト前に毎回データベースが削除されて、クリーンな状態になるというかたちになっています。

実際のTestクラスには、このHasTestRulesを実装します。このTestRulesに、使いたいTestRule、今の場合はdbTestRuleを指定します。

こうすると、@BeforeTest、@AfterTestをこのMyTestの上で書かなくても、TestRuleのBefore、Afterがきちんと呼ばれるようになります。

実際に、JUnitライクなTestRuleというのが、これで実現できます。

テストをクリーンにする実装が、ルールを使うことでまとめられて、テストを書くのがすごく簡単になりました。

いい感じのMockライブラリはない

次に、Mockについてです。KMMにおいて、現状MockKのようなDSLでいい感じにMockを書くライブラリは、私が知る限りはないです。MockKにはFeatureリクエストが飛んでいるのですが、進んでいない感じです。

なので、Mockを使ってテストしたいとなった場合は、自分で書くしかありません。

ということで、どうやってMockを書いたらいいかを話します。

まずは、Mockを書く前にテスタブルな設計にしないと、そもそもMock化することができません。基本的な話ですが、抽象に依存して、具象に依存しないようにしましょう。

抽象というと、Kotlinでいうinterface、具象というと、class、object、global functionみたいなやつです。

テスタブルな設計にする

具体的な例を出して進めていきます。

APIから取得したUserを返す、UserRepositoryというのがあって、これをテストしたい場合です。

今書かれてる実装は、このUserRepository create()の中で、ApiClientという具象クラスに依存している状況です。

これをテストしたいなとなった場合、テストは書けません。

これだと、実際にAPI通信とかをしてしまうので、バックエンド含めたテストになりますし、通信にもし失敗したらどうなるかみたいなテストも書きづらいです。

もし、MockKが使えるのであれば、ここでMockを書いて、UserRepositoryのテストはできるかもしれませんが、KMMではMockKが使えないです。

ということで、まずはテストを書ける設計にしていきます。

前述のApiClientを、まずはinterfaceにして、実際のクラスは、Implクラスにして、ApiClientのinterfaceを実装するかたちに変更します。こうすると、抽象と具象を分けることができます。

UserRepositoryは、interface、ApiClientに依存するかたちにして、これを注入します。

こうすると、具象クラスであるImplクラスには依存しなくなります。いわゆる、依存性の注入をやっている状況です。

次に、テストコード用にApiClientInterfaceを実装したMockクラスを作ります。こんな感じで、overrideしたcreateUser()がmock()を呼ぶかたちにします。

このmockプロパティにラムダを入れることで、createUserが呼ばれた時に、ラムダの結果で操作できるようになります。

ここまでやって、ようやくテストが書けるようになります。

このように、Mockクラスのmockプロパティに、モックしたい結果を指定します。apiClient.mock = { User(“test”) }みたいに書きます。

こうして、UserRepositoryにはMockを注入して、create()を呼ぶようにします。

これでcreate()を呼ぶと、内部的にはmockのラムダが呼ばれて、User(“test”)が返ってきます。それから、Mockから期待される結果をアサーションすれば、テストができるようになります。

KMMのMockは、このようなかたちで実現可能です。

Mockを使えば、もしAPIが失敗した時にどういう挙動になるか、みたいなテストも書くことができるようになります。もし例外を投げたらどうなるかというテストがこういうふうに書けるわけです。

KMMテストは抽象と具体を分ける設計が重要

最後、まとめです。

KMMのテストは、まだまだライブラリやツールが少ない状況です。なので、なかったら自分で作る精神でいく必要があります。

抽象と具象を分けるというような、テスタブルな設計も重要になってきます。

先ほどのコードの例にあったように、かなりボイラープレートが増えてしまうのですが、ちょっと今はしょうがないかなという感じです。

compiler-pluginとかが、もしいい感じに実装できれば、もっとボイラープラートは減らせると思います。作れたらいいのですが、まだ作れないです(笑)。

発表は以上です。ご清聴ありがとうございました。