Konsist を使って KMP ライブラリモジュールの作業抜けを単体テストで検出する

# 経緯

ある日 [AndroiDagashi](https://androiddagashi.github.io/) を見ていたら [#290 2023-08-27](https://androiddagashi.github.io/issue/290-2023-08-27/) に

> アーキテクチャをテストできる新ツール、 Konsist

というものが紹介されていて、その [GitHub のトップページ](https://github.com/LemonAppDev/konsist)を眺めてみたら、所属先の [Kotlin Multiplatform (KMP)](https://kotlinlang.org/lp/multiplatform/) を用いたアプリ開発でよくある作業抜けを CI で検出できそうだと思ったので、まずは自分の学習用サンプルコードで試してみました。

# Konsist の紹介として、実装クラスに対する internal の設定抜けを単体テストで検出する

[Konsist の GitHub トップページ](https://github.com/LemonAppDev/konsist)には複数の使用例があり、それを読むだけで大まかな使い方を理解できると思いますが、ここでは `internal` の設定抜けを単体テストで検出するための Konsist の書き方を紹介します。

あるアプリではマルチモジュールを導入していて、データの処理をモジュールにまとめています。

**参考** [一般的なモジュール化パターン - データモジュール](https://developer.android.com/topic/modularization/patterns?hl=ja#data-modules)

データの処理を代表するクラスを `○○RepositoryImpl` 、そのインターフェースを `○○Repository` として、インスタンスはインターフェースを指定して Hilt などの DIコンテナから取得する形を取りました。
そうなると、実装クラスである `○○RepositoryImpl` には `internal` が付いていることが望ましいです。すべての `○○RepositoryImpl` クラスに `internal` が付いているかを Konsist を使い単体テストで確認します。

```ArchitectureTest.kt
class ArchitectureTest {
    @Test
    fun checkAllRepositoryImplsAreInternal() {
        Konsist.scopeFromProject() // このプロジェクトの
            .classes() // すべてのクラスリストを取得。型は List<KoClassDeclaration>
            .withNameEndingWith("RepositoryImpl") // 名前が RepositoryImpl で終わる要素に限定する
            .assertTrue { it.hasInternalModifier } // すべての要素に対して internal が付いているか確認する
    }
}

```

# KMP モジュールのテストから Konsist を使えるようにする

Konsist は JVM ターゲットからしか使えないので、それを追加して `jvmTest` の依存ライブラリとして追加します。

```libs.versions.toml
konsist = 'com.lemonappdev:konsist:0.13.0'
```

```build.gradle.kts
kotlin {
    sourceSets {
        // 略
        jvm()
        jvmTest.dependencies {
            implementation(kotlin("test"))
            implementation(libs.konsist)
        }
    }
}
```

`commonTest` 方ではテストクラスから Konsist が使えません。[Konsist は内部で kotlin-stdlib-jdk8 を使っていて](https://github.com/LemonAppDev/konsist/blob/93b825ce9f6d79f959df727c928bd3881c67075b/lib/build.gradle.kts#L7)、それは JVM に依存するからです。

# Koin の iOS 向け DI 設定の書き忘れを単体テストで検出する

私の学習用サンプルコードでは DI コンテナに [Koin](https://insert-koin.io/) を使っています。Koin で作成されたインスタンスを iOS から使うためには、次のリンク先にあるようにヘルパークラスの実装が必要です。

[Kotlin Multiplatform Dependency Injection - Injected Classes](https://insert-koin.io/docs/reference/koin-mp/kmp/#injected-classes)

ここで iOS/Android 両方から使う[状態ホルダー](https://developer.android.com/topic/architecture/ui-layer/stateholders)のクラスがあったとして、それに対応するヘルパクラスの実装を忘れたとします。ひとつのリポジトリを私1人で作る分には iOS 側の UI を作るついでに作れば良いです。しかし KMP と iOS の GitHub リポジトリが別で開発担当者も別の場合、iOS の開発担当者がすぐにその状態ホルターを使い UI を作ることができません。

そこで状態ホルダーのクラスが作られた時点で、対応するヘルパクラスが無ければ単体テストを失敗にして CI を失敗にするようにします。

私の学習用サンプルコードは Redux アーキテクチャを採用していて、状態ホルダーは ActionCreator と Reducer で構成されています。その2つのインスタンスは次のようなコードで、iOS から使えるようにしています。

```HomeViewModelHelper.kt
class HomeViewModelHelper : IosViewModelHelperBase<HomeEvent, HomeAction, HomeState, HomeEffect>() {
    // 親の abstract class に Redux に従い iOS 側の UI 状態を更新するための処理が入っている

    override val actionCreator: HomeActionCreator by inject()

    override val reducer: HomeReducer by inject()
}
```

Konsist を使い `○○ActionCreator/○○Reducer` と対になる `○○ViewModelHelper` が作られているかをチェックする単体テストを書きます。設置場所は `commonTest` ではなく `jvmTest` になります。

```ArchitectureTest.kt
class ArchitectureTest {
    @Test
    fun checkAllActionCreatorsAndReducersAreRegisteredInViewModelHelper() {
        // 名前が ViewModelHelper で終わるクラスのプロパティの型の名前 Set を作成する。
        val registeredClassNames = Konsist.scopeFromProject()
            .classes()
            .withNameEndingWith("ViewModelHelper")
            .flatMap {
                it.properties()
            }.map {
                it.type?.name ?: ""
            }.toSet()
        // 名前が ActionCreator で終わるクラスと Reducer で終わるクラスの名前 Set を作成する。
        val actionCreators = Konsist.scopeFromProject()
            .classes()
            .withNameEndingWith("ActionCreator")
        val reducers = Konsist.scopeFromProject()
            .classes()
            .withNameEndingWith("Reducer")
        val checkClassNames = (actionCreators + reducers).map {
            it.name
        }.toSet()
        // 両者が同じかを確認する
        Assert.assertEquals(checkClassNames, registeredClassNames)
    }
}
```

# まとめ

単体テストで Konsist を使うことで、Kotlin コードのクラス名や修飾子、プロパティなどが、ガイドラインに沿っているかをチェックすることができます。また KMP においては JVM ターゲットのテストとして作成します。

チーム開発において完璧と思って作成したプルリクが Approved になったのでマージしたところ、あとで作業抜けに気がついて追加でプルリクを作成することがありがちな場合は、Konsist を使った単体テストによるチェック項目にすることで、開発効率を向上できると思いました。