KyashのKMMにおけるテストのこれまでとこれから
はじめに
これは Kyash Advent Calendar 2022 の12日目の記事です、こんにちは あるいは こんばんは。
10月にAndroidエンジニアとしてKyashに入社し2ヶ月が立ちました、@_rmakiyamaです。楽しく開発しています!
Kyashのモバイルアプリ開発ではKMM (Kotlin Multiplatform Mobile) を採用しています。Kyashの場合、ロジックの共通化と各OSがUIを最適化しUXを最大化することを方針とし、UI以外はすべてKMMで共通化する選択をとっています。
本記事では、KMMにおけるテストについて、「実装当時と前提である課題が変わったことで、方針を変えていこうとしている」という話をします。
これまで
まずはこれまでのKMMにおけるテストの方針について簡単に紹介します。
アーキテクチャ外観
- 各OSのUI層はSwiftUI/Jetpack Composeを採用
- UIからはStateHolderとしてKMMで実装するMVIベースのReactorを利用
より詳しい背景などは Kyash Tech Talk #3 で発表されたMobileアプリのアーキテクチャ設計法をご覧ください。
テストの方針
おおきく下記の観点からReactorからApiClient層までの統合テストを行う選択をしていました。
- 各OSでロジックが異なったことによるインシデントを避けたい
- multi-thread問題を含むKotlin/Native起因のクラッシュを避けたい
統合テストでは、HttpClientにおけるレスポンスのみをMockする方法を取っています。KyashではHttpClientにKtorを採用しているため、Ktor ClientのMockEngine
を活用しています。
これにより、expect/actualでの各プラットフォームにおけるコードの差異がある場合もワンソースでテストを行うことができ、Kotlin/Native起因のクラッシュにも気づきやすい状態を実現しています。
くわしい説明は割愛しますが、Kyashでは統合テストを実装しやすくするためのヘルパーメソッドを用意し、面倒なセットアップとレスポンスのMockを容易に書ける仕組みを整え、下記のように手軽に統合テストを記述することができます。
class HogeReactorTest { @Test fun executeAction_hoge_then_piyp() = reactorSuspendTest( reactorFactory = ::HogeReactorFactory, // 必要なAPIレスポンスのMockを指定 responses = { every { "/testing/api/path" } returns SUCCESS_RESPONSE_JSON } ) { reactor -> // implement your testing } }
課題の変化
これまでの課題のひとつであるmulti-thread問題はNew memory managementに移行することで解決しました。さらに、expect/actualでの各プラットフォームの実装差異の課題について見たときに、expect/actualで実装された処理は少なく、課題感としては小さくなっていると判断できそうでした。
また、これまでの方針はうまく機能していましたが、KMM化が進むにつれて下記のような新たな課題も生まれてきました。
- data層でのキャッシュの仕組みを導入したことでテストが複雑化する
- APIのレスポンスが暗号化されていることがありレスポンスのMockコストが高い
これから
課題が変化すれば、解決策も変化していきます。
Kyashでも改めてテストの方針をチームで考え直しました。これまでの課題と新たな課題をもとに、下記の方針とすることにしました。
- Reactorのユニットテストを必須とする
- UseCase層からMockする
- expect/actualでプラットフォーム間に差異のある実装は別途テストをする
- 必要に応じてこれまでの統合テストも行っていく
- 既存の仕組みを活かせる
実装としては、愚直に依存するクラスを抽象化し、具象に依存しない作りにすることでMockに置き換えられるようにしています。
Reactorの場合はUseCaseに依存するため、UseCaseをインタフェースで実装し、テストのときはMockに差し替えるようにしています。(※ 擬似コード)
interface GetPiyoUseCase { suspend operator fun invoke(): Piyo } class GetPiyo(...) : GetPiyoUseCase { override suspend operator fun invoke(): Piyo { return ... } } class GetPiyoMock : GetPiyoUseCase { lateinit var mock: () -> Piyo override suspend fun invoke(): Piyo = mock() }
これを使い、Reactorのテストは下記のようにシンプルなユニットテストとして実装します。HasTestRules
についてはKMMのテストのtips / KMM testing tipsを参考にさせていただいています。
class HogeReactorTest : HasTestRules { // BeforeTest/AfterTestを隠蔽 override val testRules: TestRules = TestRules(MainDispatcherRule()) // 依存するUseCase群 private lateinit var getPiyo: GetPiyoUseCase private fun createReactor(): HogeReactor { return HogeReactor(getPiyo = getPiyo) } @BeforeTest fun setup() { // default mocks getPiyo = GetPiyoMock().apply { mock = { Piyo.mock() } } } @Test fun executeAction_hoge_then_piyp() = runTest { // override mock instance getPiyo = GetPiyoMock().apply { mock = { Piyo.mock2() } } val reactor = createReactor() // implement yout testing } }
この方針にすることで、下記の効果が得られることを期待しています。
- Reactor単体のテストに集中できる
- data層のキャッシュの実装に引っ張られること無くテスト可能
- Reactorが抽象に依存するようになりテスタブルな実装になる
- 一般に言われるテストピラミッドに沿ってテストが増える
もちろん統合テストが悪いということではありません。テストの範囲を意識してどのようなテストをするかについて、チームで改めて認識を合わせる良い機会になりました。
さらにこれから
あくまでも新たな方針は、現時点での方針に過ぎません。すでにKyashでは下記のようなトピックも上がっています。
- モックのクラスの実装/管理が大変になる未来が見えるのでライブラリも検討できそう
- Kotlin/Nativeの問題が少ないなか
iosTest
を常に回す必要はないのではないか
もっとよくできる部分は引き続きブラッシュアップしていくぞ!という気持ちで引き続きチームで取り組んでいく予定です。
おわりに
アーキテクチャは一度決めて終わりではなく、イテレーティブに改善していく必要のあるものです。今回のように「"なぜ"その決定を下したかが明らかである」ことは、その"なぜ"が変わったタイミングで次の改善を推進しやすくすることにつながります。
まだKMMもベータになったばかりです。KMMやそれを取り巻くエコシステムもどんどん進化していくことでしょう。我々のプロダクトも、KyashのValueである"動いて風を知る"にもあるように、イテレーティブにどんどん進化させていきます!
そんなKyashではAndroidチームをはじめ、様々な職種で仲間を募集しております。一緒にKyashを育てていきましょう!
Kyashをもっと知りたい、すでに応募したい!という方々はこちらから!