CircleCI から、Android 開発者のみなさんに朗報です。すでにご存知の方もいるかと思いますが、CircleCI ビルドで Android エミュレーターの実行がサポートされるようになりました。この新機能を使うには、最新バージョンの Android マシン イメージが必要です。

この記事では、サンプル プロジェクトを例に、CircleCI プラットフォームで Android アプリケーションをビルドしてテストする方法を説明します。ソース コード全体は GitHub の zmarkan/android-testing-circleci-examples でご覧いただけます。また、実際のパイプラインは、対応する CircleCI プロジェクトで確認できます。

この記事は、以下を身に付けたうえでお読みいただくと、よりいっそう理解が深まります。

  • Android 開発の実務知識
  • Android 向けのツールとテスト フレームワークに関する知識
  • Gradle ビルド システムの使用方法

CircleCI の Android 向け新機能

最大の変更点は、新しい Android マシン イメージです。この新しいイメージには、Android アプリケーションのビルドに必要なツール (SDK、Firebase 向け Google Cloud ツール、Python、Node.JS、Fastlane など) がすべて含まれています。このイメージは、これまで提供されていた Docker イメージの代わりになるものです。また、Android Orb も更新され、Android エミュレーターを使用したテストのセットアップと実行に役立つジョブとコマンドが新しく追加されています。

これら新機能についての公式発表は、CircleCI Discuss フォーラムをご覧ください。

ネストされた仮想化

Discuss の記事でお知らせしたように、新しい Android マシン イメージはネストされた仮想化をサポートしています。これを使用することで、CircleCI ジョブで x86 エミュレーターを実行できます。CircleCI プラットフォームの初期から Android 開発を行っていた方なら、当時のエミュレーターが非常に遅かったことをご存知かと思います。その後、x86 エミュレーターは Intel の Hyper-V ハイパーバイザーをサポートするようになり、エミュレーターでホスト コンピューターのリソースを直接使用できるようになりました。このようにリソースを直接使用すると、エミュレーターを非常にスムーズかつ高速に実行できます。

ただ、これまでのところ、CircleCI ではローカル ハードウェアにしか対応していませんでした。サポート対象が限られていたために、エミュレーターの速度が遅く、UI テストの実行は困難でした。しかし、ネストされた仮想化によってこの問題が解消しました。サポート対象が拡大したことで、大規模に再現可能な CI/CD 環境に高速なエミュレーションが拡大し、エミュレーターをフル活用できるようになったのです。

Android CI デモ プロジェクトの概要

Google 独自の Android Testing Codelab ソースをフォークして、デモ プロジェクトを作成しました。Codelab はシンプルな To-Do リスト管理アプリケーションです。単体テストとインストゥルメンテーション テストの組み合わせが含まれており、テストは UI を使って行うことも、UI を使わずに行うこともできます。このプロジェクトは、CircleCI でも閲覧できます。これを Android Studio で開く場合は、[Project (プロジェクト)] ビューに切り替えてください。[Project (プロジェクト)] ビューでは、これから見ていく .circleci/config.yml ファイルを直接確認できます。

このプロジェクトには、1 つの app モジュールと、デフォルトの debug バリアントと release バリアントがあります。test ディレクトリには単体テストが、androidTest ディレクトリにはインストゥルメンテーション テストが含まれています。この記事で参照する完全な CircleCI の設定ファイルも含まれています。この設定ファイルは .circleci/config.yml にあります。

サンプル Android アプリでの CI のビルドとテスト

CI プロセスでは、次の 3 つの作業を行う必要があります。

  1. 仮想マシン内で単体テストを実行する
  2. エミュレーター上でインストゥルメンテーション テストを実行する
  3. アプリのリリース バージョンをビルドする

単体テストとインストゥルメンテーション テストは並列で実行します。両方のテストに成功したら、リリース ビルドを作成できます。ただし、新しいコードが “main” ブランチにコミットされ、かつ Android バージョン 23 ~ 30 すべてで実行されたエミュレーター テストに成功した場合のみ、リリース ビルドに進むという条件を追加します。ビルドがこの条件を満たしていれば、そのアプリケーションが多種多様な Android デバイスで確実に動作するという確信を得られるからです。

メモ: 前述のように、ここからの説明はすべて、.circleci/config.yml の CircleCI 設定ファイルを参照して行います。

CircleCI Android Orb の使用

CI パイプライン構築の第一歩は、Android Orb を使用することです。Android Orb には、いくつかのステップが既に設定されています。最新バージョンは、Orb レジストリから入手できます。

デモ プロジェクトの設定ファイルでは、Orb の定義の後に以下のジョブを指定しています。

  • unit-test
  • android-test
  • release-build

さらに、test-and-build というワークフローも設定しています。

version: 2.1

orbs:
  android: "1.0.3"

jobs:
  unit-test:
    ...
  android-test:
    ...
  release-build:
    ...
    # この後で説明します

workflows:
  test-and-build:
    ...
  # この後で説明します

単体テストの実行

unit-test ジョブでは、Android Orb の Executor である android/android-machine を使用します。ビルド時間を短縮するために、resource-class に xlarge を指定しています。このテストは large Executor で実行することもできますが、リソースが不足しているとビルドに時間がかかってしまいます。みなさん自身で何度か実験してみて、プロジェクトに適した Executor のサイズを探すことをお勧めします。

steps スタンザに関して、いくつか注目したい点があります。 まず、android/restore-gradle-cacheandroid/restore-build-cache を実行することです。restore-gradle-cache は依存関係を保存するもので、restore-build-cache はビルド時間を少し短縮する効果があります。また、テスト コマンドの実行後には、android/save-gradle-cacheandroid/save-build-cache を実行しています。これは、後続のビルド実行でキャッシュを使用してビルド時間を短縮するためです。これらはすべて、android/ というプレフィックスが示すように、Android Orb に含まれるものです。

メインのビルド ステップは、android/run-tests コマンドで行います。ここでは、test-command パラメーター内で ./gradlew testDebug を呼び出します。このコマンドによって、debug ビルド バリアントに対してすべての単体テストが実行されます。なお、このコマンドでは、テストの不安定さを回避するためにデフォルトの再試行値を設定しています。

jobs:
  unit-test:
    executor:
      name: android/android-machine
      resource-class: xlarge
    steps:
      - checkout
      - android/restore-gradle-cache
      - android/restore-build-cache
      - android/run-tests:
          test-command: ./gradlew testDebug
      - android/save-gradle-cache
      - android/save-build-cache
      ...

エミュレーターでの Android テストの実行

android-test ジョブで、新しい Android エミュレーター機能を使用します。 このジョブでは、build ジョブと同じ Android Machine Executor を使用します。後続のすべてのジョブも同じ Executor を使用します。キャッシュの復元ステップも同様です。

ステップは先ほどよりも短く、必要なのは android/start-emulator-and-run-tests コマンドだけです。このコマンドは名前のとおり、指定されたエミュレーターを起動して、テストを実行します。ここでも、test-command をパラメーターとして渡します (これは android/run-tests コマンドを使用します)。system-image では、完全修飾の Android エミュレーター システム イメージを指定します。 現在、Android マシン イメージにはバージョン 23 以降の SDK がバンドルされています。他の SDK が必要な場合は、sdkmanager ツールを使用してインストールできます。

jobs:
  android-test:
    executor:
      name: android/android-machine
      resource-class: xlarge
    steps:
      - checkout
      - android/start-emulator-and-run-tests:
          test-command: ./gradlew connectedDebugAndroidTest
          system-image: system-images;android-30;google_apis;x86
      ...

エミュレーターのセットアップ ステップはすべて、Android Orb で指定されているコマンド create-avdstart-emulatorwait-for-emulatorrun-tests を使用して、手動でコーディングすることもできます。

テスト結果の保存

テスト作業は、テストを実行して終わりではありません。CircleCI ダッシュボードで、どのテストが失敗したのかを知ることができます。それには、CircleCI プラットフォームの組み込み関数である store_test_results を使用します。この関数は、成功 (または失敗) したビルドを表示します。

保存ステップは、単体テストとインストゥルメンテーション テストで微妙に異なります。それぞれの Gradle タスクで、テスト結果の保存先が異なるからです。単体テストの場合、テスト結果は app/build/test-results に保存されます。インストゥルメンテーション テストの場合は app/build/outputs/androidTest-results です。

今回は推奨方法として、新しく “テスト結果の保存” ステップを作成したうえで、find ユーティリティを使用してテスト結果ファイルすべてを共通の場所 (test-results/junit など) にコピーし、そこから保存します。また、ステップに when: always パラメーターを追加して、上記のテストの成否にかかわらずステップが実行されるようにします。

単体テスト結果の保存

このプロジェクトでは、XML ベースの結果が必要です。store_artifacts を呼び出すと、テスト結果が CircleCI プラットフォームで解析可能になります。 必要に応じて、HTML 出力を保存することもできます。

...
- android/run-tests:
  test-command: ./gradlew testDebug
- run:
    name: テスト結果の保存
    command: |
        mkdir -p ~/test-results/junit/
        find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/test-results/junit/ \;
    when: always
- store_test_results:
    path: ~/test-results
- store_artifacts:
    path: ~/test-results/junit

インストゥルメンテーション テスト結果の保存

テストの種類に応じて、正規表現コマンドを変更します。テスト結果を保存するための残りのステップは、単体テストのときと同一です。

- android/start-emulator-and-run-tests:
        test-command: ./gradlew connectedDebugAndroidTest
        system-image: << parameters.system-image >>
    - run:
        name: テスト結果の保存
        command: |
          mkdir -p ~/test-results/junit/
          find . -type f -regex ".*/build/outputs/androidTest-results/.*xml" -exec cp {} ~/test-results/junit/ \;
        when: always
    - store_test_results:
        path: ~/test-results
    - store_artifacts:
        path: ~/test-results/junit

リリース ビルドの作成とアーティファクトの保存

このプロジェクトでは、ビルド中にアプリケーションを保存する必要があります。プロセスのこの段階を達成するために、release-build ジョブを使用します。このジョブには、注目すべき点が 2 つあります。1 つ目は、run ステップで ./gradlew assembleRelease を呼び出していることです。2 つ目は、store_artifacts で、ビルドされた APK の場所を参照していることです。実際の CI/CD プロジェクトでは、この APK に署名し、ベータ配布サービスにアップロードします。Fastlane を使用して、Play ストアにリリースを作成する場合もあります。ただし、いずれの作業もこの記事では取り扱いません。

jobs:
  ...
  release-build:
    executor:
      name: android/android-machine
      resource-class: xlarge
    steps:
      - checkout
      - android/restore-gradle-cache
      - android/restore-build-cache
      - run:
          name: リリース ビルドの実行
          command: |
            ./gradlew assembleRelease
      - store_artifacts:
          path: app/build/outputs/apk/release

複数の Android バージョンでのテストの同時実行

Android プラットフォームは常に進化しています。どのバージョンにも、固有のバグや特性があります。開発に携わっている方なら、そのようなバグや特性に少なからず遭遇したことがあるでしょう。多種多様な Android バージョンのすべてで未定義の動作を回避する方法は、できる限りたくさんのバージョンでテストを実施することです。CircleCI のマトリックス ジョブ機能を使用すると、複数のバージョンで簡単にジョブを実行できます。

マトリックス ジョブを使用するには、複数のバージョンに対してテストを行わせるジョブにパラメーターを追加します。android-test ジョブでは、system-image パラメーターを使用してテストを行っています。このパラメーターにはデフォルト値が設定されているため、読者のみなさんは指定しなくてもジョブを実行できます。

指定したパラメーターを使用するには、山かっこ << >> の間に値を配置して、必要な場所に指定します。

 android-test:
    parameters: # これはパラメーターです
      system-image:
        type: string
        default: system-images;android-30;google_apis;x86
    executor:
      name: android/android-machine
      resource-class: xlarge
    steps:
      - checkout
      - android/start-emulator-and-run-tests:
          test-command: ./gradlew connectedDebugAndroidTest
          system-image: << parameters.system-image >> # 使用するパラメーター

ワークフローでパラメーターに値を渡すには、呼び出すジョブで matrix パラメーターを使用します。

workflows:
  jobs:
  ...
  - android-test:
      matrix:
        alias: android-test-all
        parameters:
          system-image:
            - system-images;android-30;google_apis;x86
            - system-images;android-29;google_apis;x86
            - system-images;android-28;google_apis;x86
            - system-images;android-27;google_apis;x86
            - system-images;android-26;google_apis;x86
            - system-images;android-26;google_apis;x86
            - system-images;android-26;google_apis;x86
            - system-images;android-26;google_apis;x86
      name: android-test-<<matrix.system-image>>

実行するジョブとタイミングの選択

開発中には、コミットをたくさん行うものです。開発者のみなさんは、できる限り頻繁にコミットをリモート リポジトリにプッシュするよう推奨されていることでしょう。コミットのたびに、新しい CI/CD ビルドが作成されます。しかし、リリース対象候補のデバイスすべてに対して、コミットのたびにテストを実行する必要はあまりありません。一方で、main ブランチは信頼できる主要なソースです。main ブランチに対しては、できる限り多くのテストを実行して、常に意図どおりに動作することを確認する必要があります。また、リリース デプロイは main ブランチのみで開始し、他のブランチでは行わない方がよいでしょう。

そこで、CircleCI の requires スタンザと filters スタンザを使用して、プロセスに適したワークフローを定義します。今回のワークフローでは、release-build ジョブに requires を設定し、unit-test ジョブを指定しています。つまり、release-build は、unit-test ジョブがすべて成功するまで、キューに入れられることになります。このジョブが成功して初めて、release-build ジョブが実行されます。

filters を使用すると、指定したブランチまたは Git タグで所定のジョブを実行する論理的なルールを設定できます。 たとえば、このサンプルでは、main ブランチのみを対象として、指定したエミュレーター マトリックスに対して android-test を実行するようにフィルターを設定しています。また、release-build について、main ブランチのみを対象とし、2 つの必須ジョブが成功した場合にのみトリガーされるように設定しています。

workflows:
  test-and-build:
    jobs:
      - unit-test
      - android-test: # 任意ブランチに対するコミット - エミュレーター マトリックスをスキップ
          filters:
            branches:
              ignore: main
      - android-test: # main ブランチに対するコミットのみ - エミュレーター マトリックス全体を実行
          matrix:
            alias: android-test-all
            parameters:
              system-image:
                - system-images;android-30;google_apis;x86
                - system-images;android-29;google_apis;x86
                - system-images;android-28;google_apis;x86
                - system-images;android-27;google_apis;x86
                - system-images;android-26;google_apis;x86
                - system-images;android-25;google_apis;x86
                - system-images;android-24;google_apis;x86
                - system-images;android-23;google_apis;x86
          name: android-test-<<matrix.system-image>>
          filters:
            branches:
              only: main 
      - release-build:
          requires:
            - unit-test
            - android-test-all
          filters:
            branches:
              only: main # main ブランチに対するコミット

もっと優れた Android アプリケーションの CI プロセスを実現するために

この記事でここまで説明してきた手順は、始まりにすぎません。ご紹介したフローは、さまざまな方法で改善することができます。たとえば、以下のような工夫が考えられます。

ビルドは 1 回だけにして、テストは何回も実行する

経験を積んだ Android 開発者ならご存じかと思いますが、connectedAndroidTest は、常に最初からビルド プロセス全体を実行します。CircleCI を使用すると、アプリケーション全体のビルドとテストを 1 回実行した後は、アーティファクトを後続のジョブに渡すことで、エミュレーターに対するテストだけを実行できます。このプロセスを導入すると、ジョブの実行のたびにビルド時間を数分節約できる可能性があります。

これを実現するには、各エミュレーター テストの実行ステップに 3 つのコマンドライン ステップ (アプリのインストール、インストゥルメンテーション アプリのインストール、インストゥルメンテーション テストの実行) を追加します。今回のアプリケーションの場合は、次のように入力します。

adb install app/build/outputs/apk/debug/app-debug.apk  
adb install app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk  
adb shell am instrument -w com.example.android.architecture.blueprints.reactive.test/androidx.test.runner.AndroidJUnitRunner 

ただし、このコードではたしかに出力がコマンドラインに送信されますが、Gradle から提供されるテスト出力のため、整形されていません。出力を見やすくしたい場合には、こちらの StackOverflow のトピックが参考になります。

実機でテストを実行する

エミュレーターはただのツールであり、たいていの場合、(残念ながら) 実際のデバイスとは大きく異なっています。そのため、実機でのテストは何ものにも代えられません。そこで、Sauce Labs や Firebase Test Lab など、クラウドで実機を提供しているソリューションを利用すると役立ちます。

ベータ テスターへのデプロイやアプリケーションのリリースを組み込む

このビルドとテスト用のサンプル プロジェクトは、CI/CD で可能なことの一部を示したにすぎません。CI/CD プロセスでは、もっと複雑な処理も行えます。たとえば、CI/CD プロセスの一環として、アプリケーションの新バージョンを直接 Play ストアに自動リリースできます。CircleCI では、役立つガイドをいくつかご用意しています。最初に参照するのは、こちらのチュートリアルがお勧めです。また、Fastlane の公式ドキュメントも役立ちます。

まとめ

この記事では、CircleCI を使用して Android アプリケーションをビルドし、テストする方法をご紹介しました。新しい CircleCI Android Orb とマシン イメージを使ったサンプル プロジェクトを例として、単体テストの実行方法だけでなく、Android プラットフォーム エミュレーターのマトリックスを使う方法を説明しました。さらに、テスト結果を保存して表示する方法、実行するワークフローのオーケストレーション方法、Gradle によるテスト出力が雑然としていること、すべての出力でデバイスすべてをカバーできるわけではないことも説明しました。

他にもブログやライブ ストリームで取り上げてほしい Android の話題がありましたら、ぜひお知らせください。Twitter - @zmarkan でご連絡をお待ちしています。