alpha Lounge

20%の技術記事とオタクネタ

【Android】マルチモジュールプロジェクトにCIを導入する + UseCaseとViewModelにテストを書く【Turbine】

以前に作成したAndroidのCompose + マルチモジュールプロジェクトで、実装時に割愛したテスト導入とCIの整備を行いました。 背景やベースのプロジェクトについては以下をご確認ください。

Jetpack Compose + マルチモジュール (+ Atomic Design)で作るAndroidプロジェクト - alpha Lounge

UseCaseとViewModelにテストを書く

今回はdomainモジュール内のUseCaseと、featureモジュール内のViewModelにテストを導入します。
どちらも共通して、モックライブラリとしてMockKを、assert関数の拡張ライブラリとしてTruthを使用します。

UseCase

UseCaseのテストは、Repositoryをモックして、UseCaseの実行結果を確認するだけです。依存するRepositoryが増えたり別のUseCaseを呼ぶ場合も、モックを追加するだけで柔軟に対応可能です。

JetpackComposeTest/SearchRepositoryUseCaseImplTest.kt at master · alpha2048/JetpackComposeTest · GitHub

    private val query = "test"
    private val page = 1

    private val mockRepository = mockk<SearchRepository> {
        coEvery { search(q = query, page = page) } returns
            SearchRepositoryEntity(
                totalCount = 2,
                incompleteResults = true,
                items = listOf(
                    RepositoryEntity(
                        ...
                    ),
                    ...
            )
    }

    @Test
    fun `正常系のチェック`() = runBlocking {
        val useCase = SearchRepositoryUseCaseImpl(mockRepository)
        val result = useCase.execute(SearchRepositoryUseCaseParam(query, page))

        // なんかテスト書く

        coVerify { mockRepository.search(q = query, page = page) }
    }

ViewModel

ViewModelではUiStateの公開用にStateFlowを使用しているため、検証のためにTurbineというライブラリを使用します。こちらはflow周りのテスト用のライブラリです。

github.com

テストを書く前に、Kotlin公式のテストガイドを参考にメインスレッドを置き換えます。これはviewModelScopeがメインスレッドを使用しているためです。

    @OptIn(DelicateCoroutinesApi::class)
    private val mainThreadSurrogate = newSingleThreadContext("UI thread")

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun setup() {
        Dispatchers.setMain(mainThreadSurrogate)
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @After
    fun tearDown() {
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }

続いてViewModelのテストを書きます。
UseCaseをモックして、ViewModelのpublic関数(ここではsearch関数)をチェックします。この際に、TurbineでUiState用のStateFlowを購読することで、awaitItem()を使用してデータを取り出すことができます。

JetpackComposeTest/HomeScreenViewModelTest.kt at master · alpha2048/JetpackComposeTest · GitHub

    private val searchWord = "test"
    private val param = SearchRepositoryUseCaseParam(
        q = searchWord,
        page = 1
    )

    private val mockUseCase = mockk<SearchRepositoryUseCase> {
        coEvery { execute(param) } returns
            UseCaseResult.Success(
                data = SearchRepositoryEntity(
                    ...,
                )
            )
    }

    @Test
    fun `正常系のチェック`() = runBlocking {
        val viewModel = HomeScreenViewModel(mockUseCase)
        delay(1000)

        viewModel.uiState.test {
            viewModel.search(searchWord)
            delay(1000)

            coVerify { mockUseCase.execute(param) }

            val state1 = awaitItem()
            val state2 = awaitItem()
            val state3 = awaitItem()

            assertThat(state3.state).isInstanceOf(HomeScreenViewModel.UiState.Loaded::class.java)
            // なんかテスト書く

            cancelAndConsumeRemainingEvents()
        }
    }

今回はflowでのテストの紹介でしたが、Stateの公開にLiveDataを使用している場合はcore-testingを使用すると似たようなことができそうです。

star-zero.medium.com

AndroidのマルチモジュールプロジェクトにCIを導入する

プルリクの作成時やプッシュ時に、前項で作成したテストの実行、及びAndroidのlint、ktlintの実行を行い、Dangerでコメントします。GitHub Actionsで組んでいます。

ktlint設定

ktlintの設定はktlint-gradleを使用し、ルートのbuild.gradleから各モジュールに設定を反映します。

JetpackComposeTest/build.gradle at master · alpha2048/JetpackComposeTest · GitHub

subprojects {
    apply plugin: "org.jlleitschuh.gradle.ktlint" // Version should be inherited from parent

    repositories {
        // Required to download KtLint
        mavenCentral()
    }

    // Optionally configure plugin
    ktlint {
        android = true
        debug = true
        reporters {
            reporter "checkstyle"
        }
        ignoreFailures = true
    }
}

GitHub Actions

マルチモジュールプロジェクトではテストやlintの結果がそれぞれのモジュールに出力されるので、下記の記事を参考にそれぞれの結果からDanger実行します。

qiita.com

作成したDangerfileは以下の通り。

JetpackComposeTest/Dangerfile at master · alpha2048/JetpackComposeTest · GitHub

# ktlint
checkstyle_format.base_path = Dir.pwd
Dir["**/build/reports/ktlint/*/*.xml"].each do |file|
  checkstyle_format.report file
end

# android lint
android_lint.skip_gradle_task = true # 事前にビルド実行するのでskip
android_lint.filtering = true
Dir["**/build/reports/lint-results*.xml"].each do |file|
  android_lint.report_file = file
  android_lint.lint(inline_mode: true)
end

# junit
Dir["**/build/test-results/*/*.xml"].each do |file|
  junit.parse file
  junit.show_skipped_tests = true
  junit.report
end

完成したYAMLファイルは以下の通り。あまり最適化はできてないですが、ベースはこれで動くと思います。

JetpackComposeTest/check_pr.yml at master · alpha2048/JetpackComposeTest · GitHub

jobs:
  setup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: set up JDK 11
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'
          cache: gradle
  check:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '2.7'
          bundler-cache: true
      - name: Build with Gradle
        run: ./gradlew assembleDebug
      - name: run UnitTest
        continue-on-error: true
        run: ./gradlew testDebugUnitTest
      - name: run androidLint
        run: ./gradlew lintDebug
      - name: run ktlintCheck
        run: ./gradlew --no-daemon ktlintCheck
      - name: run danger
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gem install bundler
          bundle update
          bundle install
          bundle exec danger

デフォルトの状態だとktlintが自動で直せない部分(max lengthとか)は結構怒られるので、そのあたりのチューニングは必要そうです😢

おわりに

Turbineは初めて使いましたが、簡単にflowのテストが作成できるのでとてもいい感じ👍 今後のアップデートにも期待です。

CIについては、運用中のアプリであればリリースビルドを作ってFirebase Distributionなどで配布するworkflowを作るとかしたいですね。

もしこの記事をご覧になった方、参考になったらぜひブクマやシェアお願いします🙏