Android アプリをマルチモジュールにしたときのCI環境を整える

この記事は ドワンゴ Advent Calendar 2022 の 21 日目の記事です。

N予備校 Android アプリでは、コードの依存関係を明確にして、ビルドの速度を向上させるためにマルチモジュール構成を採用しています。マルチモジュールを導入した経緯やモジュール構成については以下の記事にまとめていますので、そちらをご覧ください。

blog.nnn.dev

マルチモジュールに移行するにあたって、CI 環境の整備に非常に苦労しました。インターネット上には、マルチモジュールのアプリを CI でテストする方法についての情報が少なかったのも大変でした。

この記事では、アプリをマルチモジュールに移行するにあたって CI 環境を変えた経緯をまとめつつ、各 CI 環境でマルチモジュールのテストをする設定ファイルなどを記載します。これからアプリをマルチモジュールに変えていく方々のお役に立てれば幸いです。

CI で行なっているテストについて

まず前提として、N予備校 Android アプリが CI で行なっているテストについて確認します。テストはモジュールごとに行なっており、モジュール構成とテストの内容は以下のようになります。

モジュール構成図

  • ui レイヤーの各モジュールに対して、Jetpack Compose のテストを実施
  • domain レイヤーの各モジュールに対して、ロジックの動作を確認するユニットテストの実施
  • repository モジュールに対して、データ取得の動作を確認するユニットテストの実施
  • datasource モジュールに対して、データの整合性および処理の動作を確認するユニットテストの実施

ちなみに CI ではアプリのテスト以外にも、アプリのビルド、Android Lint の実行、確認用アプリの配信などを行なっていますが、それらはマルチモジュールになっても特に影響がなかったため、この記事では説明を省略します。

この中でマルチモジュール化によって問題となったのは Android テストで実行している Jetpack Compose のテストでした。Jetpack Compose のテストは以下のガイドに基づいて作成しました。

developer.android.com

しかし、「マルチモジュール」x「Jetpack Compose のテスト」を CI で実行するための情報はほとんどなかったため、いくつかの方法で試行錯誤しました。その過程をまとめます。

マルチモジュールアプリの CI 環境を模索する

Bitrise で試してみる

もともと N予備校 Android アプリの CI は Bitrise で実行していました。しかし、Bitrise は 2022 年 12 月時点ではマルチモジュールの Android テストに対応していませんでした。

理由は、Bitrise が Android テストを実行するワークフロー の対象として apk, aab ファイルを想定しており、ライブラリモジュールがビルドする aar ファイルがテスト対象にならないためです。詳しくは以下の記事を参考にさせていただきました。

nashcft.hatenablog.com

gradle コマンド ./gradlew connectedAndroidTest を使用して全てのモジュールで Android テストを実行する方法も試しましたが Bitrise では動作しませんでした。

上記の理由から、マルチモジュールにした N予備校アプリの Android テストを Bitrise で実行することができませんでした。そこで、別の CI 環境として GitHub Actions を試してみました。

GitHub Actions で試してみる

GitHub Actions では Android テストを実行するために用意されている Android Emulator Runner を使用しました。ただし、この Action は実行環境として macOS を推奨しており、macOS は Linux と比べて 10 倍のコスト がかかります。

この Action を使用することでマルチモジュールの Android テストが実行できることは確認できましたが、1 回の実行に 20 分ほどかかることがわかりました。私たちは GitHub Team プランを使用しているので 3,000 分はプランに含まれています。つまり大まかな計算で 1 ヶ月に 3000 / (20 x 10) = 15 回まで Android テストが実行できます。

GitHub の各プラン(N予備校アプリでは Team プランを使用)

しかし、1 ヶ月に 15 回の実行ではテストの役割を十分に果たすことはできません。また GitHub Actions では他のワークフローも実行しており、iOS アプリとも共有しているので、余裕を見ると 10 回が限界でした。もちろん追加でお金をかければ解決できますが、予算の兼ね合いもあるのでいったん見送りました。

他にも GitHub Actions で Android テストを実行する方法を検討しましたが、上記の Action より適した方法が見つからず、手動で頑張って実装することでメンテナンスコストが高くなることは避けたかったので、GitHub Actions による Android テストの実行は諦めました。

参考までに、GitHub Actions で Android テストを実行するための設定ファイルを掲載します。具体的には以下のことを行いました。

  • 平日の 15 時にワークフローを定期実行する
  • Android Emulator を起動して Android テスト(以下のファイルでは UI テストと呼んでいます)の実施
  • テストが落ちた場合、GitHub Actions が作成した Pull Request がなければ、テストのエラー内容を記載した UITestError.txt を作成してコミット
  • コミットした内容をもとに Pull Request を作成する
name: UI Testing

on:
  workflow_dispatch:
  # 平日 15 時に定期実行
  schedule:
   - cron: '0 6 * * 1-5'
jobs:
  ui_testing:
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ [トークン名] }}
    steps:
      - name: checkout
        uses: actions/checkout@v3

      - name: set Java 11
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Gradle cache
        uses: gradle/gradle-build-action@v2

      - name: AVD cache
        uses: actions/cache@v3
        id: avd-cache
        with:
          path: |
            ~/.android/avd/*
            ~/.android/adb*
          key: avd-29

      # Android Emulator Runner
      # https://github.com/marketplace/actions/android-emulator-runner
      - name: create AVD and generate snapshot for caching
        if: steps.avd-cache.outputs.cache-hit != 'true'
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          force-avd-creation: false
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
          disable-animations: false
          script: echo "Generated AVD snapshot for caching."

      # テストの実行
      - name: run tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 29
          force-avd-creation: false
          emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
          disable-animations: true
          script: |
            ./gradlew connected[Flavor]DebugAndroidTest

      # テストがエラーだった場合、テストを修正するための Pull Request がすでにあるか確認する
      - name: check pull request existing
        id: check_pull_request_existing
        if: failure()
        run: |
          ui_testing_error_pull_requests=$(gh pr list --label "ui-testing-error" --json number | jq '. | length')
          echo "::set-output name=ui_testing_error_pull_requests::$ui_testing_error_pull_requests"

      # Pull Request がなければ、Pull Request を作成するためにエラー内容をコミットする
      - name: Make changes to pull request
        if: failure() && steps.check_pull_request_existing.outputs.ui_testing_error_pull_requests == 0
        run: |
          echo UI テストが落ちました。GitHub Actions のログを見て修正をお願いします。 https://github.com/[team名]/[リポジトリ名]/actions/runs/${{ github.run_id }} > UITestError.txt

      # テストを修正するための Pull Request を作成する
      - name: create ui testing error pull request
        if: failure() && steps.check_pull_request_existing.outputs.ui_testing_error_pull_requests == 0
        uses: peter-evans/create-pull-request@v4
        with:
          token: ${{ [トークン名] }}
          commit-message: UI Testing Error
          committer: GitHub <noreply@github.com>
          author: [author名] <[author名]@users.noreply.github.com>
          branch: ui-testing-error
          delete-branch: true
          title: 'UI テストが落ちています'
          body: |
            UI テストが落ちました。
            [GitHub Actions](https://github.com/[team名]/[リポジトリ名]/actions/runs/${{ github.run_id }}) のログを見て修正をお願いします。
            修正の際には UITestError.txt の削除もお願いします。
          labels: ui-testing-error

Circle CI で試してみる

GitHub Actions が現実的に難しかったため、次に Circle CI を試してみました。Circle CI では Android エミュレータを立ち上げて UI テストを実行するための Android Orb(circleci/android@2.1.2)を使用して、マルチモジュールでも問題なく Android テストを実行できました。テスト結果を Slack に流すための Slack Orb(circleci/slack@4.12.0)も設定がわかりやすく、すぐに動かすことができました。

Circle CI で Android テストを実行したときの所要時間はおよそ 20 分でした。Circle CI の無料プランには 30,000 クレジットが含まれており、Android テストの実行環境は 1 分あたり 20 クレジットを消費するものを選択していたため、大まかな計算で 1 ヶ月に 30000 /(20 x 20) = 75 回まで実行できることがわかりました。GitHub Actions よりも利用可能枠が大きいのはありがたいです。

Circle CI の各プラン(N予備校アプリでは Free プランを使用中)

ただ、やはり Android テストは時間がかかるので、Pull Request に対して毎回実行していると開発速度が低下してしまいます。そのため、Android テストは 1 日 1 回の定期実行とし、テストに失敗した場合は Slack に通知するようにしました。

参考までに Circle CI の設定ファイルも掲載します。Slack の表示は以下のようにカスタマイズしています。

成功時 失敗時
テスト成功時の Slack 通知
テスト失敗時の Slack 通知
version: 2.1

orbs:
  android: circleci/android@2.1.2
  slack: circleci/slack@4.10.1

jobs:
  android-test:
    executor:
      name: android/android-machine
      tag: 2021.10.1
    steps:
      - checkout
      - android/start-emulator-and-run-tests:
          test-command: ./gradlew connected[Flavor]DebugAndroidTest
          system-image: system-images;android-30;google_apis;x86
      - slack/notify:
          event: pass
          custom: |
            {
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":tada: *UI テストが成功しました!* :tada:"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Job:* ${CIRCLE_JOB}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Branch:* ${CIRCLE_BRANCH}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Docs:* <[ドキュメントへのリンク]|Circle CI について>"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": {
                        "type": "plain_text",
                        "text": "ログを確認"
                      },
                      "url": "${CIRCLE_BUILD_URL}"
                    }
                  ]
                }
              ]
            }
      - slack/notify:
          event: fail
          custom: |
            {
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": ":red_circle: *UI テストに失敗しました。詳しくは下のボタンから確認してください。* :red_circle:"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Job:* ${CIRCLE_JOB}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Branch:* ${CIRCLE_BRANCH}"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Docs:* <[ドキュメントへのリンク]|Circle CI について>"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": {
                        "type": "plain_text",
                        "text": "ログを確認"
                      },
                      "url": "${CIRCLE_BUILD_URL}"
                    }
                  ]
                }
              ]
            }

workflows:
  ui-testing:
    when:
      # 定期実行かどうか確認
      equal: [ scheduled_pipeline, << pipeline.trigger_source >> ]
    jobs:
      - android-test:
          context: [定数名]

Jetpack Compose のテストを Unit テストで実行する

上記のような試行錯誤を経て Circle CI で無事にマルチモジュールの Android テストが実行できるようになりました。しかしできることなら、Jetpack Compose のテストは Android テストではなく Unit テストで実行したいところです。Unit テストで実行することで、テスト実行にエミュレータを起動する必要がなくなり、開発もスムーズになるためです。

Unit テストで Robolectric を使って Jetpack Compose のテストを実行しようとしてうまくいかなかったのですが、以下の issue を見つけて参考にすることでようやく解決しました。

github.com

つまり、instrumentedPackages=androidx.loader.content の設定を入れることで、Espresso と Robolectric の依存関係が解消され、Unit テストで Jetpack Compose のテストを実行できるようになります。

そのため、Jetpack Compose のテストは今まで通り Pull Request ごとに実行される Bitrise の Unit テストで確認できるようになりました。頑張って設定した Circle CI は、残された一部の Android テストを確認するだけとなっています。なかなか遠回りしましたが、テストを実行できる環境が整ってよかったです。

おわりに

N予備校 Android アプリでは、マルチモジュールへの移行や CI の整備など、アプリの品質を高めるための活動にも積極的に取り組んでいます。アプリで提供する機能は今後も増えていく予定ですので、その開発に対応していくために開発環境を改善する取り組みは重要になっています。

Android アプリ開発チームでは引き続き採用活動も行っておりますので、開発環境を改善する取り組みなどに興味を持っていただけましたら、ぜひ以下の資料もご覧ください。

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com