それがいいことの序章です

楽しいことならいっぱい夢見ることならめいっぱい

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をもっと知りたい、すでに応募したい!という方々はこちらから!