DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

Unityプロジェクト向けオートパイロットフレームワークの運用Tips

SWETグループの長谷川( @nowsprinting )です。

開発者自身の手によるUnityプロジェクトの品質向上アプローチのひとつに、ゲームプレイを自動化するオートパイロットによる検証があります。 このアプローチについて、DeNA内で開発・導入を進めているフレームワークAnjinを昨年紹介しました。

swet.dena.com

また、Anjin自体も先日オープンソース化しました。こちらのリポジトリからご利用いただけます。

github.com

本記事では、Anjinの、主にオートパイロットを安定運用するための機能を紹介します。

Automated QAが保存するスクリーンショットの退避

uGUIコンポーネントのシナリオテストを行なう UGUIPlaybackAgent の実装には、 Automated QAパッケージ のRecorded Playback機能を使用しています。 Playback機能は再生操作の前後にスクリーンショットを撮影して Application.persistentDataPath 下に書き出してくれるのですが、新しい操作シナリオの実行がはじまる契機で削除されてしまいます。

Anjinでは複数のシナリオを連続して実行しますので、スクリーンショットを残すのであればすべてのシナリオ分を残したくなります。 そこでAnjinでは、シナリオ実行が終わるたびにAutomated QAの出力先パスを求め*1、退避させる手段を取りました。

詳しくは、UGUIPlaybackAgent クラスの StoreScreenshots メソッドを参照してください。

Unityエディター終了時のクラッシュ対策

Anjinを活用している開発チームでは、Anjinを夜間に定期実行されるように設定しています。 GitHub ActionsワークフローやJenkinsから起動されたAnjinは一定時間動作したらUnityエディターを終了しますが、このとき終了コード0以外で終わってしまうことがあります*2

これを起動元がエラーと判断してアラートを上げてしまうことを回避するため、Anjinは終了時にJUnit Report形式*3のXMLファイルを出力しています。 これにより起動元は、Unityエディターの終了コードではなく、JUnit Reportファイル内の errors の数で合否を判断できます。

JUnit Reportファイル出力の実装は、JUnitReporter クラスを参照してください。

サーバ通信エラーなどによるタイトル戻し対策

「タイトル戻し」とは弊社内での通称ですが、通信エラーなどで進行不能となったときに「タイトル画面に戻ります」といったボタンを表示してタイトル画面からやり直させる振る舞いのことを指します。 もしエラー発生時に動作していたのがモンキーAgentであれば、Agentはそのままタイトル画面に戻るボタンをクリックし、そのままAnjinの実行は継続されます。

しかし、プレイバックAgentなど操作シナリオを再現するAgentでは、本来クリックしたかったボタンの代わりに別のボタンが出現したことでシナリオ継続不可能となってしまい、アラートが上がってしまいます。 エラーの内容が報告すべきものであればこの振る舞いでよいのですが、通信エラーなど無視したいもの(むしろ継続して動作してほしいもの)もあります。 *4

この問題は、次のビルトインAgentを組み合わせることで回避できます。

EmergencyExitAgent

このAgentは、EmergencyExit コンポーネントがアタッチされたボタンの出現を監視し、Scene内に出現したら即時クリックする機能を持っています。 つまり、エラー発生時に表示される「タイトル画面に戻ります」ボタンにこれをアタッチしておけば、プレイバックAgentがシナリオ継続不能と判断する前にタイトルSceneへと遷移できるのです。 Sceneが切り替わった時点でプレイバックAgentは終了してタイトル画面用のAgentに切り替わります。

EmergencyExit は、Anjinが動作している間は常駐して監視を続ける必要があります。それには、AutopilotSettingsにある Observer Agent として登録して使用します *5

OneTimeAgent

たとえばタイトルSceneの操作は、起動後1回目の表示時と2回目以降で異なるシナリオを要求されることがほとんどではないでしょうか。 初回起動では年齢確認やチュートリアルの操作が必要になるためです。

このAgentは1つの子Agentを設定でき、それをオートパイロット実行期間を通じて1回だけ実行します。2回目以降の実行はスキップされます。 これを利用して次のようにAgentを組合わせることで、1回目と2回目以降で異なるシナリオを実行できます。

タイトルScene向け SerialCompositeAgent
    ├── 子1: OneTimeAgent
    │   └── 子: 1回目のシナリオの PlaybackAgent
    └── 子2: 2回目以降シナリオの PlaybackAgent

まとめ

このように、できるだけ単機能・最小限のAgentだけをビルトインAgentとして提供し、Unityエディター上で組み合わせることにより(ノーコードで)ゲームタイトルに合わせたオートパイロット動作を実現できるようにしています。

とはいえ、あまりに複雑な組み合わせを強いるのは本末転倒です。 複雑な要件に対しては、ゲームタイトル固有のカスタムAgent*6をエンジニア(プログラマー)が作成することを検討してください。

We are hiring

SWETグループでは、このようなUnityプロジェクトに対する品質向上アプローチも行っています。 興味を持たれた方は、ぜひ採用情報を確認ください!

*1:パスにはタイムスタンプが含まれるため

*2:プロジェクトによっては高頻度で発生していました

*3:JUnit形式を選択したのは、これを扱えるライブラリがGitHub ActionsやJenkinsプラグインに存在するためです

*4:Anjinはログを監視してエラーを報告しますが、指定した文字列を含むエラーログを無視する設定ができます。前回のブログ記事を参照してください

*5:Observer Agent に設定されたAgentは、DontDestroyOnLoad ではなく、新しいSceneがロードされる度に破棄・生成される点に留意してください

*6:AbstractAgent を継承して実装できます

GitHub Actionsのセルフホストランナーでジョブごとにディレクトリを分離する方法

CI/CDマニアの加瀬(@Kesin11)です。先日、DeNA/setup-job-workspace-action をOSSとして公開しました。これは、GitHub Actionsでは本来1つのリポジトリに対して作業用のディレクトリは1つだけになるところを、ジョブごとにディレクトリを分離できるActionです。大規模なリポジトリをセルフホストランナーで扱うというニッチ環境用ですが、当てはまるユースケースではとても便利です。

本記事ではDeNA/setup-job-workspace-actionを作成した経緯や仕組みについて紹介しますが、これに関連する内容も含む発表を2023/03/02開催のDeNA TechCon 2023においてSWETの加瀬とゲーム事業部SREチームの白柳が次の2つをテーマに登壇する予定です。

  • SWETがこれまで注力してきたゲーム開発向けのJenkinsの整備
  • ゲーム事業部にSREチームが誕生するまで

ぜひこちらもご参加頂けると嬉しいです!

techcon2023.dena.dev

JenkinsからGitHub Actionsへ移行する際の課題

DeNAのゲーム開発ではCI/CD基盤として主にJenkinsを利用しており、SWETではゲーム開発にてJenkinsを効果的に活用してもらうための仕組みづくりや運用コストの省力化に取り組んできました。一方でここ数年知名度が急上昇しているGitHub Actionsにも注目しており、JenkinsからGitHub Actionsに乗り換えることを視野に入れて調査・検討をしています。

検討する中で大きな懸念であったのは、ゲーム開発のような大規模なリポジトリの場合、ビルドキャッシュを次のビルドにおいて利用できないことでビルド時間が多くかかってしまうという問題でした。

一般的なGitHub Actionsの使い方ではGitHubがホストしているランナーのVMを毎回のビルドのタイミングで立ち上げるため、ビルドキャッシュなどを次のビルドに持ち越すためにはactions/cacheなどでキャッシュするというのが基本的な戦略です。
一方で、DeNAではほとんどのチームがGitHub Enterprise Server(GHES)を利用しており、GitHubがホストしているランナーを利用できないためセルフホストランナーを利用することになります。セルフホストランナーでは前回ビルドしたときのディレクトリの状態がそのまま維持されてしまうのですが、actions/checkoutがデフォルトの挙動として git clean -ffdx も実行してくれるためセルフホストランナーであっても基本的には毎回のビルドはほぼクリーンな環境で実行されます1

逆にこの挙動があるため、次回ビルドの効率化のためにビルドキャッシュを残したい場合にはactions/checkoutのオプションにある clean: false を指定することで自動的に git clean -ffdx が実行されないようにする必要があります。JenkinsでUnityのゲームをビルドするパイプラインを作成したときには、Unityが利用しているUPMパッケージやプラットフォーム向けに最適化されたアセットのキャッシュが含まれる Library/ ディレクトリを.gitignoreに追加した上で明示的に git clean -ffd(-x無し)を実行することでこのディレクトリが削除されないように工夫して上手くビルドキャッシュを効かせることを実現していました。

当初はGitHub Actionsでもこの戦略を実行していたのですが、ある日気がついたら Library/ ディレクトリが意図せずに削除されてしまう現象が発生しました。原因を調べたところ、Unityのビルド別のWorkflowが実行された際に Library/ ディレクトリが削除されていたことが分かりました。

GitHub Actionsは1つのリポジトリに対してWorkflow(リポジトリ中の .github/workflows/*.yml)を複数登録可能で、さらに1つのWorkflowの中に複数のJobを登録できるという構造です。しかし実はセルフホストランナー内では1つのリポジトリに対して1つのディレクトリ(ワークスペース)しか持てないディレクトリ構造になっています。この制約により、たとえば次のような状況で意図せずにキャッシュが削除されるということが発生していました。

  1. 手動トリガーによる build.yml でUnityビルドが行われ、ビルドキャッシュが Library/ に残る
  2. 2回目以降の build.yml でUnityのビルドが行われたときは Library/ が存在するためキャッシュが効く
  3. 別のpull-reqが作られたタイミングでlint.yml によるLintのWorkflowが実行される
    • このときにactions/checkoutのデフォルトオプションにより、意図せずに Library/ が削除されてしまう
  4. 3以降のタイミングで初回の手動トリガーによる build.yml のUnityビルドでは Library/ が存在しないためキャッシュが効かない

問題は3の lint.yml の中でactions/checkoutに clean: false のオプションを渡していないことなのですが、GitHub Actionsに慣れた人ほどこのオプションに馴染みがないでしょうからWorkflowを編集する際に忘れないことを徹底してもらうのは無理があります。また今回の例のように異なるWorkflowではなく、同じWorkflow内でも新たなJobを追加した際のactions/checkoutにオプションを忘れてしまえば同じ問題が発生します。そこまで意識を徹底するのは自分でも難しいと思いました。

別の方法として clean: false を諦め、セルフホストランナーでもGHES v3.5から利用可能となったactions/cacheで Library/ をキャッシュさせるという戦略も考えたのですが、Library/ は軽くGB単位の大きさであったことからキャッシュの上限や転送時間を考えると現実的ではないと諦めざるを得ませんでした。ここまでの事情はQualiArtsさんの 【Unity】ゲーム開発の現場でなぜJenkinsが利用され続けるのか で紹介されていたGitHub ActionsよりJenkinsの方がゲーム開発には向いている理由とほぼ同じとなります。

QualiArtsさんの記事にあるように "ジョブごとにワークスペースが維持される" というJenkinsの挙動をGitHub Actionsのセルフホストランナーで再現させる、あるいは別の代替案がないことにはJenkinsから本格的にGitHub Actionsに移行することは難しいと考えました。

シンボリックリンクを用いたワークスペースの分離

そもそも根本的な問題はセルフホストランナーのディレクトリ構造が1リポジトリに対して1ワークスペースしか持てないことです。Jenkinsでは同様の問題が起きなかった理由をディレクトリ構造を比較して説明します。

Jenkinsではまずジョブというリポジトリに紐付かない概念を作成し、ジョブ1つに対して基本は1つのディレクトリ(ワークスペース)が作成されます。したがって次の図のようにjob_AAAjob_BBBという2つのジョブは別々のワークスペースで実行されますので、同じリポジトリrepo_Aを利用していたとしてもGitHub Actionsのように前ビルドで生成されビルドキャッシュとして残しておきたいファイルなどがうっかり消されてしまうという問題は発生しません。さらにいえば、プラグインを利用したりジョブの中でcheckout処理を工夫することでジョブとワークスペースの関係も1:Nにさらに拡張できる柔軟性がありました。

# JenkinsのAgentマシンのディレクトリ構造

$HOME/jenkins-agent/workspace/
├── job_AAA
│   └── repo_Aをcheckoutした中身
├── job_BBB
│   └── repo_Aをcheckoutした中身
└── job_CCC
    └── repo_Bをcheckoutした中身

一方、GitHub Actionsのセルフホストランナー内のディレクトリ構造はこのようになっています。

# GitHub Actionsセルフホストランナーのマシンのディレクトリ構造

$HOME/actions-runner/_work/
├── repo_A
│   └── repo_A
│       └── repo_Aをcheckoutした中身
└── repo_B
    └── repo_B
        └── repo_Bをcheckoutした中身

repo_A はリポジトリの名前の文字列で、なぜか2回同じディレクトリ名でネストされていました2

$HOME/actions-runner/_work/repo_A/repo_A までのパスはGitHub Actionsの実行中に環境変数の$GITHUB_WORKSPACEとして参照可能であり、すべてのactionはこのパスを起点に動作します。たとえばactions/checkoutを実行するとこのディレクトリの中に.gitやその他のファイルが生成されます。色々なドキュメントやランナーのオプションなども調べたのですが $GITHUB_WORKSPACE 自体のパスを変更する方法は発見できませんでした。

しかし逆にいえば $GITHUB_WORKSPACE は必ずこのパス構造になっていると思われるので、シンボリックリンクという古典的な方法で擬似的にディレクトリを変更するという方法をひらめきました。コマンドとしては次のようになります。

# 以下2つの環境変数はジョブ実行中にランナーに自動的にセットされているもの
# GITHUB_WORKSPACE = _work/repo_A/repo_A までの絶対パス
# RUNNER_WORKSPACE = _work/repo_A までの絶対パス

# デフォルトのワークスペースを.bakという名前で一時的に退避
mv ${GITHUB_WORKSPACE} ${GITHUB_WORKSPACE}.bak

# 差し替える予定のディレクトリを WorkflowとJob のペアでユニークなるような名前で作成
TMP_DIR="${RUNNER_WORKSPACE}/${WorkflowのYAMLのファイル名}-${Job}"
mkdir -p ${TMP_DIR}

# 作成したディレクトリをGitHub Actionsの元々のディレクトリの位置にシンボリックリンクを作成
ln -s "${TMP_DIR}" ${GITHUB_WORKSPACE}

この時点でのディレクトリ構造はこのようになります。本来ワークスペースとして使用されるはずの repo_A ディレクトリのパスを新たに作成した new-workspace ディレクトリへのシンボリックリンクで置き換えます。

# リポジトリ名 = repo_A
# {WorkflowのYAMLのファイル名}-{Job名} = new-workspaceとした場合

$HOME/actions-runner/_work/
└── repo_A
    ├── new-workspace
    ├── repo_A -> $HOME/actions-runner/_work/repo_A/new-workspace へのシンボリックリンク
    └── repo_A.bak

このままではシンボリックリンクで置き換えた repo_AGITHUB_WORKSPACE) が次のJobでも使われてしまうので、Jobが完了したタイミングで次のコマンドによって元に戻す必要もあります。

# シンボリックリンクを削除
unlink ${GITHUB_WORKSPACE}

# .bakに退避させていたデフォルトのワークスペースを元々のパスに戻す
mv ${GITHUB_WORKSPACE}.bak ${GITHUB_WORKSPACE}

この一連のコマンドをJobの開始時と終了時に実行できれば、若干無理やりではありますが元々のGitHub Actionsのリポジトリとワークスペースが1:1である制約を突破して1:Nに拡張できると考えました。

actionとして実装(DeNA/setup-job-workspace-action

bashコマンドはすでに用意できているのでcomposite actionsで実装しようと考えたのですが、Job終了時に.bakを元に戻す一連のコマンドが確実に実行される必要があるため、Jobの終了時に確実に実行されるpost処理を利用できるJavaScript actionとして実装しました。JavaScriptで実装したといってもNode.jsには元々ファイル操作系のAPIが用意されているため、ほとんど先述の一連のコマンドを愚直に置き換えただけのシンプルな実装になっています。

このシンボリックリンクを用いた方法で実装したactionの特に優れている点は、このactionを使わない場合は元々のセルフホストランナーのワークスペースの構造が維持されるのでこのactionを利用してディレクトリを分けたいJobとそうでないJobが共存できる点です。実は別の手段として最近セルフホストランナーに追加されたjob hook scriptも検討していたのですが、こちらで実現しようとした場合はすべてのJobで一律にディレクトリを分けることを強制させる実装になったと思います。actionとして提供することで、ワークスペースを分けたい場合のみ明示的にYAMLで呼び出すようにユーザー側でコントロールできる方がシンプルで分かりやすいと今では思っています。

DeNA/setup-job-workspace-actionの使い方

基本(オプション無し)

setup-job-workspace-actionは先述の仕組みでディレクトリを作成してシンボリックリンクなどを追加するため、actions/checkoutよりも前に実行されないと意味がありません。必ず先に呼ばれるように順番に気をつけてください。

jobs:
  default:
    runs-on: [self-hosted]
    steps:
      # 必ずactions/checkoutより前にsetup-job-workspace-actionを追加してください
      - uses: DeNA/setup-job-workspace-action@v2

      - uses: actions/checkout@v3

      # ... 通常のビルド処理のステップを記述

いずれのオプションも指定しないデフォルトの挙動では {WorkflowのYAMLのファイル名}-{Job名} という名前のディレクトリが作成されてここが新たなワークスペースとなります。WorkflowとJobのペアでユニークなディレクトリ名ですので、Jobごとに独立したワークスペースが与えられていたJenkinsに近いディレクトリ構造となります。

GHESのバージョンが多少古い場合のデフォルト挙動の違い

具体的にはセルフホストランナーのactions/runner@2.300.0未満の場合なのですが、作成されるディレクトリの名前が {Workflow名}-{Job名} と若干変わります。これはYAMLのファイル名を特定するための GITHUB_WORKFLOW_REF という環境変数が用意されるようになったのがactions/runner@2.300.0からであるためです。セルフホストランナーのバージョンによってディレクトリ名が変化することを回避したい場合は、後述の workspace-name オプションで任意のディレクトリ名で固定してください。

prefix, suffix

jobs:
  with_prefix_and_suffix:
    runs-on: [self-hosted]
    steps:
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          prefix: "foo-"
          suffix: "-bar"
      - uses: actions/checkout@v3

prefix, suffixのオプションを指定するとディレクトリ名の前後に特定の文字列を追加可能です。仮にこのYAMLのファイル名がbuild.ymlの場合は先述のデフォルト名の挙動と合わせて、foo-build-with_prefix_and_suffix-bar というディレクトリ名になります。
このオプションが想定している用途はWorkspace名やJob名に加えて追加のパラメーターによってワークスペースの使い分けをさらに細分化したいケースです。たとえば workflow_dispatch で手動トリガーした際の何らかのパラメーターの値を context.inputs で取得してそれを prefix に与えることで、パラメーターに応じてワークスペースを細かく切り替えることが可能になります。

workspace-name

jobs:
  given_dir_name:
    runs-on: [self-hosted]
    steps:
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          workspace-name: foo_bar_workspace
      - uses: actions/checkout@v3

workspace-name を指定すると作成されるディレクトリの名前を完全にコントロール可能です。この例では foo_bar_workspace というディレクトリ名になります。
このオプションが想定している用途はデフォルトよりも荒い粒度としてWorkspace名だけでワークスペースを分けたい、あるいは逆にさまざまな変数などを駆使してフルカスタムしたい場合です。たとえばactions/github-scriptなどと組み合わせることで、jsを駆使して動的に組み立てた文字列を渡すといったことも可能です。次の例では手動トリガーの場合のみブランチ名が含まれるディレクトリ名を動的に生成しています。

jobs:
  given_dir_name_dynamically:
    runs-on: [self-hosted]
    steps:
      - uses: actions/github-script@v6
        id: set-workspace-name
        with:
          result-encoding: string
          script: |
            const branch = context.ref.split('/').slice(2).join('/')
            return (context.eventName === "workflow_dispatch")
              ? `manual_trigger_${branch}`
              : "default"
      - uses: DeNA/setup-job-workspace-action@v2
        with:
          workspace-name: ${{ steps.set-workspace-name.outputs.result }}
      - uses: actions/checkout@v3

DeNA/setup-job-workspace-actionを利用するメリット・デメリット

最後に自分が考えているDeNA/setup-job-workspace-actionのメリットと、デメリットについて紹介しておきます。

メリット

  • Jenkins時代と同様にジョブ単位でディレクトリが分かれるため、ビルドキャッシュを活かしやすい
    • Unityのビルドキャッシュとして重要な Library/ ディレクトリを確実に維持できる
  • リリース候補となるビルドの作成時など、完全にクリーンな環境からビルドする場合に開発用ビルドのためのキャッシュを削除してしまうことがなくなる
    • 特殊な事情があるジョブは workspace-name でディレクトリ名を固定するなどの工夫で他のジョブのワークスペースとは完全に隔離が可能
  • このactionsを使わないジョブでは元々の挙動が維持されるため、GitHub Actionsに詳しくない人にも負担を強いることがない

デメリット

まとめ

JenkinsからGitHub Actionsに移行するにあたって障壁のひとつであったワークスペースの分離をGitHub Actionsでも可能にするDeNA/setup-job-workspace-actionを紹介しました。

特にゲーム開発のような大規模なリポジトリでJenkinsからGitHub Actions + セルフホストランナーの構成に移行する場合には便利なはずです。実際にDeNA内で導入してくれたチームから嬉しいコメントをもらいました!

  • キャッシュ管理がかなり楽になりました
  • UnityのLibraryディレクトリが毎回消されなくなったので、Unityのテストがめっちゃ早く終わるようになりました

最後に宣伝ですが、2023/03/02のDeNA TechCon 2023で「ゲーム開発のJenkins整備から横断SREチームが誕生するまで」という発表をいたします。自分からはSWETグループによるこれまでのJenkinsについての取り組みを、ゲーム事業部SREチームの白柳からSREチーム誕生までの経緯とその取り組み、そして最後にゲーム開発においてGitHub Actionsを利用し始めたという内容を紹介いたします。

techcon2023.dena.dev

本記事の内容に興味を持った方には面白い発表だと思いますのでぜひ当日DeNA TechCon 2023にお越しください!


  1. -ffd のオプションによりgit管理されているファイル以外は削除され、さらに通常は削除されない.gitignoreで定義されているファイルも-xのオプションにより削除されます。
  2. 推測としては、今回の動作確認で使用したOrganizationレベルのセルフホストランナーであればリポジトリ名だけでユニークであることが保証できるからかもしれません。Enterpriseレベルのセルフホストランナーではもしかしたら {ORG}/repo_A のような構造かもしれませんが、試していないので完全な推測です。
  3. Jenkinsでは一定期間以上ビルドされていないジョブのディレクトリは自動で削除されるなど、ディスク容量の消費を抑える機構が存在しています

iOSのOOMクラッシュをみつける

こんにちは、SWETグループ所属のkariadです。

昨年10月に開催されたiOS Test OnlineにてSWETチームのkuniwakが「実践9つのメモリリークどう見つける?」というタイトルで発表しました。 その発表では触れられなかった、メモリリークから引き起こされるOOMクラッシュを発見する手法についてSWETで実践したことを紹介します。

メモリリークについての説明は多くの記事で説明されているため、省略します。

OOMクラッシュ

メモリリークが発生するとOOMクラッシュの危険性があります。 OOMとはOut Of Memoryの略であり、アプリが確保しているヒープ領域を超えてメモリを利用しようとした際に、OSからアプリがキルされクラッシュしてしまいます。 通常のクラッシュにおいては大半のアプリで導入されているであろうFirebase Crashlyticsにて検知可能です。 一方でOOMクラッシュはCrashlyticsで検知できません。 これはFirebase Crashlyticsで検知できるものが、Swiftのランタイムエラー、NSExceptionに限られているためです。 OOMクラッシュはこれらとは異なりOSからの命令でアプリケーションが強制終了されるため、Crashlyticsで検知できません。

現時点でFirebase CrashlyticsやAppleから提供されているツールにおいて、OOMクラッシュを即座に検知し教えてくれるものはありません。(その他3rdツールで可能なものももしかしたらあるかもしれないですが) そうした状況において可能な限り、OOMクラッシュを発見したい、検知したいと思ったときに取れる方法をiOS Test Onlineで紹介しきれなかったものとして2つをタイミング別に説明していきます。

本番環境での検知

OOMクラッシュの即時検知は難しいという話をさきほどしました。 しかし、MetricKitを用いることで発生回数は取得可能です。

MXAppExitMetricのForeground,Backgroundそれぞれのクラス内にプロパティとしてcumulativeMemoryResourceLimitExitCountが定義されています。 こちらは説明をそのまま読むと、「システムがフォアグラウンドからメモリ使用量が多すぎるためにアプリを終了させた回数」とあります。 厳密にはOOMクラッシュの回数とイコールにはなりませんが、継続的に記録していけばアプリのバージョン毎に比較して新たなバージョンで極端に増えた場合はメモリリークの発生を疑ってもいいでしょう。 発生が検知できたら、iOS Test Onlineで紹介されたツール群を使い、具体的な原因の特定につなげていくことをお勧めします。

検証中などに不意に発生したOOMクラッシュや特殊環境でのOOMクラッシュの特定

続いては開発中や検証中にクラッシュしたが、Crashlyticsにログも出力されないという場合や、Crashlyticsが利用できない環境(実際に私が直面したのはこちらでした)などに、端末のログからOOMが原因でクラッシュしたかどうかを判別する方法を紹介します。 端末でメモリ不足となった場合にシステムからアプリが終了させられ、メモリが解放されることをJetsamイベントと言います。 Jetsamイベントが発生すると、端末にJetsamEventReportというログが出力されます。 このJetsamイベントレポートには端末の各プロセスがどの程度メモリを使用していたか、解放された原因がなにであるか記載されています。

では具体的にJetsamEventReportを見ていきます。

JetsamEventReportはクラッシュのログ等と同様に、「プライバシーとセキュリティ」 > 「解析と改善」 > 「解析データ」から見ることができます。

{
  "crashReporterKey" : "2de183711d66c974754eca20731a63a9ace8490f",
  "kernel" : "Darwin Kernel Version 21.4.0: Mon Feb 21 21:26:14 PST 2022; root:xnu-8020.102.3~1\/RELEASE_ARM64_T8020",
  "product" : "iPad8,1",
  "incident" : "93704C46-9EC0-4497-96D9-003069B18B71",
  "date" : "2022-05-24 08:13:28.88 +0900",
  "build" : "iPhone OS 15.4.1 (19E258)",
  "timeDelta" : 81,
  "memoryStatus" : {
  "compressorSize" : 17626,
  "compressions" : 258335,
  "decompressions" : 49705,
  "zoneMapCap" : 1443561472,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41271296,
  "pageSize" : 16384,
  "uncompressed" : 198018,
  "zoneMapSize" : 146276352,
  "memoryPages" : {
    "active" : 94122,
    "throttled" : 0,
    "fileBacked" : 92968,
    "wired" : 34174,
    "anonymous" : 94049,
    "purgeable" : 28,
    "inactive" : 92012,
    "free" : 3679,
    "speculative" : 883
  }
},
  "largestProcess" : "SignalPlayground",

サンプルとなるJetsamイベントレポートの先頭部分です。 ここには端末の情報が主に記録されています。 アプリが、メモリリークが原因で強制終了してしまったかどうかを調べるために、注目するのは先頭部分が終わった直後に記録されているlargestProcessです。 largestProcessではもっともメモリを使用しているプロセス名が記録されます。 調査したいアプリのメモリリークが原因で強制終了した場合はlargestProcessにアプリ名が入ってくることが高いです。 今回の例でいうとSignalPlaygroundというプロセスが記録されています。 SignalPlaygroundはメモリリークを意図的に発生させるために作成したアプリです。

SignalPlaygroundでJetsamEventReport内を検索すると次のようなブロックが見つかります。

{
    "uuid" : "bffca555-98df-3618-a95e-e722ad0f0066",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 30379,
    "genCount" : 0,
    "purgeable" : 0,
    "age" : 164612203,
    "fds" : 25,
    "coalition" : 699,
    "rpages" : 183494,
    "priority" : 10,
    "reason" : "per-process-limit",
    "physicalPages" : {
      "internal" : [
        46477,
        136905
      ]
    },
    "freeze_skip_reason:" : "none",
    "pid" : 2530,
    "cpuTime" : 0.79734499999999997,
    "name" : "SignalPlayground",
    "lifetimeMax" : 183494
  },

ここで注目するポイントは、reasonです。 reasonにこのプロセスが終了するに至った原因が記録されています。 per-process-limitがここでは記録されています。 per-process-limitはシステムが設定したアプリ毎のメモリ制限を超えた場合に記録されます。 このメモリ制限を超えた場合にシステムからアプリが、強制終了させられることがあります。 メモリリークが疑われる状況において、JetsamEventReportにてアプリの終了原因としてper-process-limitが記録されていた場合、メモリリークが原因による強制終了と見ていいでしょう。

また、rpagesの値から実際にどの程度メモリを利用していたかを計算できます。 JetsamEventReportの先頭部分に記録されている、pageSizeの値とrpagesの値を乗算することで実際のメモリ使用量を求められます。 今回の例ではpageSizeが16384、rpagesが183494のため、3006365696(Byte)となり約3GBにもなります。 さらにuuidでどのビルドかなどを絞り込むことはできますが、詳細な原因を特定まではできません。 JetsamEventReportで分かることは、あくまでもシステムから強制終了させられたかどうかとその理由、そしてどの程度メモリを使用していたか、になります。

再現可能なメモリリークであれば、Instrumentsを使い調査できますが、突然発生してしまった場合などよくわからない場合にJetsamEventReportを活用できます。 JetsamEventReportについては次のドキュメントを参考にしています。もっと知りたい、という方は参考にしてみてください。 Identifying high-memory use with jetsam event reports

今回紹介した方法はいずれも単独で原因をみつけられるものではなく、手がかりのひとつとして利用可能な手段です。 iOS Test Onlineで発表された手法も合わせ、状況に応じていくつかある手段を用いてメモリリークに対処していくことが大切になります。

Lint Night #1を開催しました!

SWETグループの稲垣(IK)です。

2022-11-18にLint Night #1を開催しました!

今回の発表の録画とスライドを紹介します!

Lint Nightとは

Lintとはソースコードや文書を静的に解析して問題をみつけるツールのことです。ただ、どこまでをLintとするかには幅があるようです。Lintの話は奥が深く、こうした話ができる勉強会は面白そうだと判断し、Lint Nightを立ち上げました。

プログラミング言語不問でLintに関するトピックについて、技術共有できる場としてLint Nightを運用できたらと考えています。

当日の登壇内容

YouTubeにLint Night #1のアーカイブをアップロードしました。 当日参加できなかった方、もう一度発表内容を見たい方は是非ご覧ください!

聴衆の反応

Lint Night #1にtweetをまとめてみました!

各セッションスライド紹介

各登壇者のスライドを紹介していきます!

@orga_chem「LintオタクによるLint解説」

@koic「RuboCopの仕組み」

@azu_re「textlint - Linterの作り方」

個人の感想とまとめ

いつもお世話になっているLintの作者から、内部構造の話を聞けたり、Lintを作る時に気をつけることを聞けたりと、貴重な発表を聞くことができたと思いました。私も業務でLintを作るときがあるのですが、今回の発表は大いに参考になりました。

また、コロナ禍ではありますが、感染対策を行った上でオフライン懇親会を開催しました!

終わりに

Lint Night #2の開催時期は未定ですが、開催したいと考えております。ご興味がある方は是非お待ちしております!もしよろしければ、Lint Nightグループのメンバーになってください!

また、SWETグループではLintに限らずテストの自動化やCI/CDに興味があり、私達と一緒に働いてくれる仲間を募集しています。ご応募お待ちしています!

Unityプロジェクト向けオートパイロットフレームワークの作りかた

SWETグループの長谷川(@nowsprinting)です。

開発者自身の手によるUnityプロジェクトの品質向上にはさまざまなアプローチがあります。 当ブログでもこれまでに、 ユニットテスト静的解析 といった、コーディング段階でC#コードの品質(特に内部品質)を高めるアプローチを紹介してきました。

本記事では少し目線を変えて、ゲームがほぼ組み上がった状態でのゲームプレイを自動化する、オートパイロットによる検証アプローチを紹介します。

オートパイロットによる検証の位置付けと目的

ゲームが組み上がった状態とは、ゲームを構成するC#コードや3Dモデルなどのアセットファイルを組み合わせた、結合度の高い状態を指します。 結合度を突き詰めると、IL2CPPビルドしてスタンドアロンプレイヤー(モバイル端末やコンソール機)で実施するテストになりますが、今回は結合度を上げすぎず、実行環境はUnityエディターの再生モードを使用します。

このアプローチのメリットは、エンジニア(プログラマー)個々が書いたコード間の連動や、アセットやマスターデータのバリデーションをすり抜けてしまった問題をQA工程に入る手前で検知できることです。 またユニットテストに比べてゲーム本体のテスタビリティ*1に影響されにくく、ユニットテストが困難なプロジェクトにも比較的低コストで導入できます。

しかし厳密な検証まで行なうのは難しく、たとえばシナリオに沿った操作をした結果の画面表示内容まで細かく検証しようとすると、テスト実装コストが高くなってしまいます。 自動テスト導入時には見逃されがちですが、運用をはじめてからはゲーム本体の仕様変更に追随していくメンテナンスコストが大きくのしかかります。このコストが払えないとせっかくの自動テストもすぐに使いものにならなくなります。

ここで妥当な検証方法として次の2つがあげられます。

  1. モンキーテストによる、クラッシュや進行不能バグの検出
  2. 少数のシナリオテストによる、主要な画面遷移の確認

それぞれ長所・短所がありますので、続いて説明していきます。

モンキーテスト

モンキーテストとは、お猿さんが操作するかのように無作為にゲームをプレイするテストです。ユーザーが行う操作をカバーさえできれば、シナリオの作成やメンテナンスは不要です。

ただし無作為な操作ですので、その操作の結果が意図したものかといった検証まではできません。 期待できるのは、クラッシュや進行不能といった、クリティカルではあるものの、まれな不具合に限られます。

また、たとえばゲーム開始時に接続するサーバ選択では常に特定のサーバを選択させる、チュートリアルには時間をかけずスキップするといった、完全ランダムではない振る舞いが求められるケースもあります。

シナリオテスト

シナリオテストとは、シナリオやユースケースにそってゲームをプレイし、設定されたゴール(たとえばデッキ編成の完了)に行き着くことができるかを検証するテストです。 主に「主要な機能を完遂できること*2」や「開発者・QA担当者ともめったに触らない機能が壊れていないこと」の確認を目的に、少数の簡易なシナリオを実行します。 画面遷移の途中でのクラッシュや進行不能のほか、想定されたボタンが表示されない、もしくはタップできないといった問題も検知できます。

なお、ここではシナリオのゴール判定は画面遷移されたこと、くらいの簡易な検証を想定しています。 詳細な検証(たとえば変更したデッキの戦闘力の数値が期待値と一致するか)も可能ではありますが、シナリオの作成・メンテナンスコストが増大します。それはユニットテストなどで担保するようにすべきです。

しかし少数・簡易なものに絞っても、操作シナリオをコード化していくコストはかかります。 開発中には画面遷移にも頻繁に変更が入りますし、運用に入ってもイベントや機能追加に対応するメンテナンスは続きます。

Anjin

DeNA内で開発・導入を進めているフレームワークAnjin(あんじん)*3は、モンキーテストとシナリオテストを組み合わせることによって双方のデメリットを補い、低コストでオートパイロットを運用できることを目指しています。

Unityの画面単位であるSceneごとに特定の操作を行なうAgentを割り当てておき、アクティブなSceneの切り替えに応じて動的にAgentを切り替えて動作します。 Agentは拡張できるようになっており、たとえばインゲームの操作を行なうAgentはゲームタイトルごとに個別に実装して組み込むことができます。

Sceneの切り替わりイベントは、UnityのSceneManagerに次のようにリスナを登録して受け取ることができます。

SceneManager.activeSceneChanged += _dispatcher.DispatchByScene;

このイベントを受け取るActionには、切り替え前後のSceneが引数で渡されます。 これを用いて、次のように切り替え後のScene(next)に対応するAgent(存在しない場合はFallbackに指定されているAgent)を起動しています。

public void DispatchByScene(Scene current, Scene next)
{
  var agent = GetAssignedAgent(next);
  if (agent == null)
  {
    agent = GetFallbackAgent();
  }

  var agentName = agent.GetType().Name;
  var gameObject = new GameObject(agentName);
  var token = gameObject.GetCancellationTokenOnDestroy();

  agent.Logger = _logger;
  agent.Random = _randomFactory.CreateRandom();
  agent.Run(token).Forget();

  // snip
}

Agentには、専用のロガーのほか、擬似乱数シードを与えています。 疑似乱数シードを生成する_randomFactoryの生成時に与えるシード値は、オートパイロットの設定で固定できるようになっており、同じシード値を指定することでモンキーテストのランダムな操作を再現できるようにしています*4

代表的なAgentの設定と実装

たとえばスマートフォン向けのゲームで、次のようなScene構成のものを例とします。

  • タイトル画面
  • ホーム画面・各種機能(アウトゲーム)
  • バトル画面(インゲーム)

タイトル画面には、接続サーバなど決まったものを選択する必要があるためシナリオ操作を行なうAgentを割り当てます。

ホーム画面には、ログインボーナスを受け取り、主要な機能を巡回するシナリオを実行した後、モンキーテストを行なうAgentを割り当てます。

バトル画面には、ゲームタイトル固有の自動操作を行なうAgentを割り当てます。 オートパイロットの目的から、これもモンキーテストもしくはすぐにリタイアしてアウトゲームに戻る振る舞いを想定しています。

Anjinでは、これを次のようにScriptableObjectに設定します*5

この例で用いているAgentは汎用的なもので、Anjinにビルトインされています。 続いて、代表的なビルトインAgentを紹介します。

アウトゲームのモンキーAgent

uGUI Monkey Agentは、Canvas上のUIコンポーネントをランダム操作するAgentです。 生存時間と、操作と操作の間の遅延時間を設定できるようにしています。

このAgentは、UnityEngine.UI.Selectable.allSelectablesArrayを利用して操作対象候補となるUIコンポーネントをリストアップ、Raycasterによるヒットテストを経て実際にプレイヤーが操作可能なUIコンポーネントを絞り込み、操作候補としています。 この中から擬似乱数によって操作するUIコンポーネントを抽選し、タップなどの操作を行ないます。 一般にモバイルアプリのモンキーテストではランダムな座標をタップしていく方式が取られますが、効率を考えてUIコンポーネント単位で操作するようにしています。

なお、広告表示や他のアプリを起動するボタンなどの操作させたくないオブジェクトに対しては、あらかじめIgnoreUGUIMonkeyというアノテーションコンポーネントをアタッチすることで操作対象外とする仕組みにしています。

アウトゲームのプレイバックAgent

uGUI Playback Agentは、UIコンポーネントをシナリオにそって操作するAgentです。 Recorded Jsonには、Automated QAパッケージ(com.unity.automated-testing)を使ってUI操作を記録したJSONファイルを指定します。

Automated QAパッケージのRecorded Playback機能は、Unityエディター上で実際のゲームUIをマウスで操作したシナリオをJSONファイルに記録・再生するものです。 Anjinで使用するテストシナリオ作成は、このレコーディング機能を利用してコーディングレスで行ないます。

実行時は、次のようにAutomated QAパッケージの公開されているAPIを使用してJSONファイルに記録されたUI操作を再生させています。

using Unity.AutomatedQA;
using Unity.RecordedPlayback;

// snip

private static IEnumerator Play(TextAsset recordingJson)
{
  RecordedPlaybackPersistentData.SetRecordingMode(RecordingMode.Playback);
  RecordedPlaybackPersistentData.SetRecordingData(recordingJson.text);

  CentralAutomationController.Instance.Reset();
  CentralAutomationController.Instance.AddAutomator<RecordedPlaybackAutomator>(
    new RecordedPlaybackAutomatorConfig { loadEntryScene = false });
  CentralAutomationController.Instance.Run();

  while (!CentralAutomationController.Instance.IsAutomationComplete())
  {
    yield return null;
  }
}

コンポジットAgent

Serial Composite Agentは、登録された複数のAgentを逐次実行するAgentです。Composite Agentを入れ子にもできます。

この例では、ホーム画面ではまずログインボーナスを受け取る操作、続いて機能Fooの操作、機能Barの操作、機能Bazの操作を行なうシナリオを実行します。 そして規定のシナリオが終わったら、残り時間はモンキーテストを実行する設定になっています。

このAgentは、シナリオとランダム操作を組み合わせられるというだけでなく、シナリオを細かく区切ることで仕様変更の影響によるメンテナンス範囲を最小限にとどめることにも役立ちます。

ゲームタイトル固有Agentの実装

ゲームタイトル固有のAgentを作る場合は、AbstractAgentクラスを継承してRunメソッドをオーバーライドします。 たとえば、指定された秒数待機するAgentは次のように実装できます。

[CreateAssetMenu(fileName = "New WaitAgent", menuName = "Anjin/Wait Agent", order = 20)]
public class WaitAgent : AbstractAgent
{
  public long lifespanSec = 0L;

  public override async UniTask Run(CancellationToken token)
  {
    if (lifespanSec > 0)
    {
      await UniTask.Delay(TimeSpan.FromSeconds(lifespanSec), cancellationToken: token);
    }
    else
    {
      await UniTask.Delay(-1, cancellationToken: token); // 無期限に待機する場合は -1
    }
  }
}

Agent内では、AbstractAgentクラスに定義されているLoggerおよびRandomが使用できます。

その他の機能

レポーティング

Anjinの実行中にErrorExceptionが発生した場合など、スクリーンショットを撮影してSlack通知するようになっています。 エラーなく操作を完遂した場合の通知は、Anjinを実行するGitHub ActionsやJenkinsのジョブで行ないます。

ログ監視

Anjinは、Unityのログに流れるゲームタイトルのログを監視し、あらかじめ設定された条件にマッチするログメッセージをエラーとして扱います。

Unityのログは、次のようにリスナを登録して受け取ることができます。

Application.logMessageReceived += _logMessageHandler.HandleLog;

このイベントを受け取るActionは次のように実装しています。

public async void HandleLog(string logString, string stackTrace, LogType type)
{
  if (IsIgnoreMessage(logString, stackTrace, type))
  {
    return;  // 報告・停止トリガでないログメッセージであればなにもしない
  }

  await UniTask.SwitchToMainThread();
  await PostToSlack(logString, stackTrace, type);  // Slack通知

  var autopilot = Object.FindObjectOfType<Autopilot>();
  if (autopilot != null)
  {
    autopilot.Terminate(Autopilot.ExitCode.UnCatchExceptions);  // Anjin停止処理
  }
}

IsIgnoreMessage()では、設定ファイルにしたがってそのログメッセージをエラー扱いするかどうかを判定しています。 設定には、AssertWarningをエラー扱いするか、また、ログメッセージに特定の文字列が含まれている場合は既知の問題として無視するといったことを定義できます。

今後の展望

Anjinはこれまでに、ゲームタイトル開発終盤に導入し、ナイトリービルドがQAに渡る前のスモークテスト*6で一定の成果を上げています。 この用途では、主要な画面遷移をパスできるというシナリオテストが中心でした。

現在は、より早い開発段階からモンキーテストをメインとして投入し、ゲーム内に埋め込まれたAssertを踏ませることで未知の不具合を早期発見できないかの検証を進めています。

We are hiring

SWETグループでは、このようなUnityプロジェクトに対する品質向上アプローチも行っています。 興味を持たれた方は、ぜひ採用情報を確認ください!

*1:テスト容易性とも呼ばれます。テスト対象への入力の与えやすさ・出力の観測しやすさなど、テストコードによるテストを可能にするためにはプロダクトコード側の品質もそれなりに要求されます。これが低いと、テストを書くコストがとても高くなります

*2:テストを実行するタイミングにもよりますが、QAによる検証作業を止めてしまう問題を事前にみつける目的を持ちます

*3:航海士(パイロット)の古語で、戦国時代の日本に漂着したイングランド人ウィリアム・アダムスの日本名(三浦按針)にもなっています

*4:ただしゲーム本体にもランダム要素がある場合、そちらのシードを固定できる機構も必要です

*5:Anjinでは、ゲームタイトル固有の拡張が必要な部分を除き、オートパイロットの設定はすべてUnityエディター上で完結するようになっています

*6:機械の電源を入れたときに煙を吹かないかを見るような簡素なテスト

Gradle Managed Devicesでテストを動かしてみよう

こんにちは。SWETのAndroidチームに所属している外山(@sumio_tym)です。 SWET AndroidチームではAndroidのプロダクトに対して自動テストのサポートをしています。

はじめに

先日開催されたDroidKaigi 2022で「Gradle Managed Virtual Devicesで変化するエミュレータ活用術」というタイトルで登壇しました。

本セッションの動画もYouTubeのDroidKaigiチャンネルに公開されていますので、合わせて参考にしてください。

さて、本記事では、上記セッションの内容から開発PC上でGradle Managed Devices (GMD)を試すのに必要な部分だけを抜粋して紹介していきます。 本記事を読むだけで手っ取り早くGMDを試せるように構成してありますので参考にしてみてください。

Gradle Managed Devices (GMD)とは

Gradle Managed Device(GMD)とは、Gradle Managed Virtual Devicesとも呼ばれる機能で、Android Gradle Plugin 7.3より本格的に導入されました。 本機能を使うと、build.gradleに使いたいエミュレータの情報を書くだけで、Gradleタスクひとつ(コマンドひとつ)で次の一連の工程を実行できるようになります。

  1. build.gradleで指定されたエミュレータ種別に合うAVD(Android Virtual Device)を作成する
    (すでにあれば再利用する)
  2. 作成したAVDでエミュレータを起動する
  3. 起動したエミュレータ上でテスト(Instrumented Test)を実行する
  4. テスト実行が終わったら、インストールしたアプリ(apk)を削除する
  5. 起動したエミュレータを終了する
  6. テスト結果レポートや実行ログを保存する

build.gradleの記載内容だけでテストを実行するエミュレータの環境が定まり、コマンドひとつで実行できることから、次のようなメリットが得られます。

  • 開発者が手動で(Android Studioを操作して)AVDを作成しなくてよい
  • テスト実行時におけるエミュレータの環境差異をなくせる
    • エミュレータを使ったテストのCI上での実行が簡単にできる

GMDのセットアップとタスクの実行

GMDを使うための手順は次のような流れになります。順を追って説明します。

  1. 利用するAGP(Android Gradle Plugin)のバージョンをGMD対応版にする
  2. build.gradleにテストを実行したいAVD(エミュレータの種別など)を定義する
  3. GMD上でテストを走らせるGradleタスクを実行する

Android Gradle Pluginのバージョン指定

前述のとおり、GMDに本格対応したバージョン7.3.0以降を使うようにしてください(本原稿執筆時点では7.3.1が最新の安定バージョンです)。

buildscript {
  ...
  dependencies {
    classpath "com.android.tools.build:gradle:7.3.1"
    ...
  }
}

テストを実行したいAVDの定義

モジュールレベルのbuild.gradleapp/build.gradleなど)に次の形式で定義します。

android {
  testOptions {
    managedDevices {
      devices {
        pixel5api33 (ManagedVirtualDevice) { //・・・(1)
          device = "Pixel 5"                 //・・・(2)
          apiLevel = 33                      //・・・(3)
          systemImageSource = "google"       //・・・(4)
          require64Bit = false               //・・・(5)
        }
      }
    }
  }
}

それぞれの指定の意味は次のとおりです
(番号は上記ビルド定義ファイル中のコメントと対応しています)

  • (1) pixel5api33: デバイス(AVD)を一意に識別する名前を指定します。適当な名前を付けてください
  • (2) device: Device Profileを指定します。 Android StudioのDevice Manager>Create deviceで表示されるデバイス名から選択してください
  • (3) apiLevel: 起動したいエミュレータのAPI Levelを指定します
  • (4) systemImageSource: 利用するシステムイメージの種別を指定します。後述します
  • (5) require64Bit: 64ビットのABIを強制的に使うか否かを指定します。通常はfalseで問題ありません

systemImageSourceで指定できるシステムイメージの種別は次のとおりです。

従前のシステムイメージ ATD
無印(AOSP) aosp aosp-atd
Google APIs google google-atd
Google Play google_apis_playstore N/A

表の右カラムに記載されているATD(Automated Test Device)は、Instrumented Test用途に最適化された軽量版のシステムイメージで、本原稿執筆時点ではAPI Level 30と31で利用可能です。 ATDではハードウェアレンダリングが無効化され、さらにテストで使わなさそうなアプリやサービスが削除・無効化されています。 削除・無効化されている機能の一覧は公式ドキュメントを参照してください。これらの機能なしでテストが動きそうであればATDを試してみるのもよいと思います。

Gradleタスクの実行

GMD上でテストを走らせるGradleタスクは次の形式になっています

{デバイス名}{ビルドバリアント}AndroidTest

たとえば、デバイス名pixel5api33に対して、debugビルドバリアント(フレーバー未定義でビルドタイプがdebugの場合)のテストを走らせたい場合は、次のコマンドでテストを実行できます。

./gradlew pixel5api33DebugAndroidTest

テストの結果レポートやログは次のディレクトリに保存されます。

  • app/build/outputs/androidTest-results/managedDevice/flavors/{フレーバー名}/{デバイス名}/
    • JUnit互換のテスト結果XML
    • ログ(logcatや、AGPが内部で実行したadbなどのコマンドログ)
  • app/build/reports/androidTests/managedDevice/flavors/{フレーバー名}/{デバイス名}/
    • HTML形式のテスト結果レポート

知っておくと便利なテクニック

GMDは、コマンド1つでエミュレータ上でのテストの実行を完結してくれる反面、運用上使いにくいと感じる場面もあります。 たとえば、次のような場面です。

  • テスト実行中の画面が見えない
  • テストが終わるとapkがアンインストールされてしまうため、テスト実行中に作られたファイルも消えてしまう
  • Android Studioでじっくりデバッグできない

これらの問題解決の助けになるテクニックを3つ紹介します。 この3つのテクニックを知っておくと、GMDを試すときの動作確認が格段にやりやすくなります。是非お試しください。

テスト実行中に画面を表示する--enable-display

GMDによるテスト実行タスクに--enable-displayオプションを付けると、テスト実行中にエミュレータの画面が表示されます。 テスト失敗時に画面の状態を目視したいときなどに便利です。

./gradlew pixel5api33DebugAndroidTest --enable-display

なお、軽量版システムイメージであるATDでは、本オプションを付けても真っ黒な画面しか表示されません。ご注意ください。

テスト終了時にファイルをpullするadditionalTestOutputDir

Test Instrumentation Runnerに渡せるadditionalTestOutputDir引数にディレクトリを指定すると、 テスト終了時にそのディレクトリ内のファイルをpullさせることができます。

android {
  defaultConfig {
    testInstrumentationRunnerArgument(
        "additionalTestOutputDir",
        "/sdcard/Android/media/com.example.gmd/result"
    )
  }
}

テストが終了するときに、この例ではエミュレータのディレクトリ/sdcard/Android/media/com.example.gmd/result内のファイルを開発PCに取り出します。

取り出されたファイルはapp/build/outputs/managed_device_android_test_additional_output(AGPのバージョン8.0以上ではapp/build/intermediates/managed_device_android_test_additional_output)ディレクトリに保存されます。 テスト実行中に保存したスクリーンショットファイルやログファイルを取り出したいときなどに便利です。

なお、Scoped Storageが有効なOSのバージョンでは、本オプションに指定できるディレクトリのパスに制限があります。 /sdcard/Android/media/{アプリケーションID}/のような、アプリから書き込めて、かつadb pullできるディレクトリを指定するようにしてください。 その条件を満たさないパスに保存したファイルは本オプションを指定しても取り出せません。

GMDで使われるAVDでエミュレータを起動できるANDROID_AVD_HOME環境変数

GMDによるテストで使われるAVD(Android Virtual Device)は隠されており、Android StudioのDevice Managerからは確認できません。 そのため、GMDで使われるのと同じAVDのエミュレータに対してAndroid Studioからテストするには、コマンドラインを使って事前に対象のエミュレータを起動しておく必要があります。

これから説明する方法でエミュレータの起動さえできてしまえば、普段どおりAndroid Studioからアプリのインストール・デバッグ実行・テストの実行などが行えます。 この方法でテストが十分安定して動くことを確認してから、最終確認としてGMDでテストを実行するようにすると、あまり手戻りすることなくテストを書き進められます。

GMDで使われるAVDが表示されない理由は、デフォルトの場所($HOME/.android/avd)にAVDが保存されていないからです。 Android Gradle Plugin 7.3以上であれば、GMDで使われるAVDは次のディレクトリに保存されています。

$HOME/.android/avd/gradle-managed

このディレクトリを環境変数ANDROID_AVD_HOMEに指定してemulatorコマンドを実行すると、GMDで使われるAVDのエミュレータを起動できます。

まず目的のAVD名を特定するところから始めます。 GMDで使われているAVDの一覧を表示するには次のコマンドを実行してください。 emulatorコマンドは($ANDROID_HOME/toolsではなく)$ANDROID_HOME/emulatorディレクトリにあるものを使ってください。

env ANDROID_AVD_HOME=$HOME/.android/avd/gradle-managed $ANDROID_HOME/emulator/emulator -list-avds

dev33_google_x86_64_Pixel_5 のような、APIレベル・システムイメージ・ABI・Device Profileを組み合わせたAVD名の一覧が表示されますので、目的のAVDをみつけてください。

目的のAVDをみつけたら、次のコマンドでエミュレータを起動できます。

env ANDROID_AVD_HOME=$HOME/.android/avd/gradle-managed $ANDROID_HOME/emulator/emulator -avd {AVDの名前}

直前にGMDでテストを実行している場合は別の手段を取ることもできます。GMDの実行ログを活用する方法です。 エミュレータを起動したときの実行ログは次のファイルに保存されています。

app/build/outputs/androidTest-results/managedDevice/flavors/{フレーバー名}/{デバイス名}/emulator.1.ok.txt

このファイルを開くと次のような内容になっているはずです。

EXECUTING: /usr/local/share/android-sdk/emulator/emulator @dev33_google_apis_x86_64_Pixel_5 -no-window -no-audio -gpu auto-no-window -read-only -no-boot-anim -id :app:pixel5api33googleDebugAndroidTest
CURRENT_WORKING_DIRECTORY: (略)
START_TIME: 2022-10-04 22:26:40.754
START_TIME-NANOS: 2022-10-04 22:26:40.754965000
ENVIRONMENT:
(略)
ANDROID_AVD_HOME=/Users/sumio.toyama/.android/avd/gradle-managed
*****************************************
STDOUT/STDERR BELOW
===================
INFO    | Android emulator version 31.3.10.0 (build_id 8807927) (CL:N/A)
(略)
===================
END_TIME: 2022-10-04 22:28:27.392
END_TIME-NANOS: 2022-10-04 22:28:27.392521000
DURATION: 106637ms
EXIT CODE: 0

ENVIRONMENT欄に書かれているANDROID_AVD_HOME環境変数を指定しつつ、EXECUTING欄に書かれているコマンドを実行すると目的のエミュレータを起動できます。

ただし、このファイルに記載されているコマンドラインオプションのままだとエミュレータの画面が表示されないため、-no-windowオプションは外して起動してください。 起動したエミュレータを操作した後の状態を永続化したいときは-read-onlyオプションも外す必要があります。

両方のオプションを外した場合、このemulator.1.okの例だと目的のAVDでエミュレータを起動するコマンドは次のようになります。

env ANDROID_AVD_HOME=/Users/sumio.toyama/.android/avd/gradle-managed \
    /usr/local/share/android-sdk/emulator/emulator @dev33_google_apis_x86_64_Pixel_5 \
    -no-audio -gpu auto-no-window -no-boot-anim -id :app:pixel5api33googleDebugAndroidTest

まとめ

DroidKaigi 2022で発表した「Gradle Managed Virtual Devicesで変化するエミュレータ活用術」の内容から、 開発PC上でGradle Managed Devices(GMD)を試すのに必要な部分を抜粋して紹介しました。

DroidKaigi 2022のセッションでは、ここで触れられていない次の内容についても説明しています。 より深くGMDに触れてみたい方は、是非そちらもご覧ください。

  • GMDの使い方
    • 複数のデバイスをまとめてテストできるデバイスグループの使い方
  • Tips
    • デバイスの言語を(日本語などの)英語以外の言語に変更してからテストを実行する
    • build.gradleでは設定できない項目(ストレージ容量など)を変更する
    • テストを録画する
    • GMDで使うエミュレータが不安定になったときにCold Bootする
    • GMDで使うエミュレータをキッティングする
    • 複数のデバイスをまとめてテストすると動作が不安定になる場合の回避策
  • テストの種類別GMD活用シーン
    • Robolectricが必要なLocal Testの移行
    • UIテスト
    • スクリーンショットテスト
  • CI環境で動かすときのポイント

個人的には、今回の登壇は久しぶりのオフライン登壇でした。 久しぶりの雰囲気に緊張しつつも、聴きに来て下さった皆さんの様子を見ながら話すことの楽しさを改めて実感しました。 このような機会を提供して下さったDroidKaigi 2022スタッフをはじめ、関係者のみなさんに感謝いたします。

宣伝

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!

Lintを使う人、作ってみたい人、問題解決の引き出しを増やしたい人のための勉強会Lint Nightを開催します!

SWETグループのLint大好きマンKuniwakです。2022/11/18にオフライン・オンライン同時開催の勉強会「Lint Night #1」を開催します!

Lint Nightはプログラミング言語不問でLintに関するトピックを取り扱う勉強会です。ここでLintとはソースコードや文書を静的に解析して問題をみつけるツールのことです。ただ、どこまでをLintとするかには幅があるようです。

さて、Lintの面白いところはソースコードや文書を入力データとして扱うプログラムであることです。ソースコードを入力データとするプログラムといえばコンパイラやインタプリタがあげられますがいずれも実装がかなり大変です。しかしLintはそこまでではありません!実は手軽に実装できるんです(Lintの作り方については次のスライドをご覧ください)。

しかもそれでいてコードレビューを一部自動化できて実用的ですし、ソースコードや文書を入力とするプログラムは応用の幅も広い!

実際に私は業務で何度もLintの関連技術で問題を解決してきました。たとえば、巨大なCSSをバグ0でCSS→Less移行した時にもLintの技術を応用しましたし、C#で作られたアプリケーションのバグを分析するための大規模データ収集にもLintの技術を応用しました。ソースコードや文書を入力データとして扱うプログラムはこのように応用が広いのです。

この話はいくらでも続けられるのでこの辺りで切り上げますが、まさにこのようなLintの話を言語不問でできる勉強会があったらいいなと思いLint Nightを企画しました。

Lintを使ったことがある人、Lintを作ってみたい人、なんでもいいから問題解決の引き出しを増やしたい人、ぜひご参加ください!Lint NightはLintに興味のあるすべての方のための勉強会です。

初回の発表者は、Rubyの代表的なLintであるRuboCopのcore teamメンバーである@koicさんと、textlintの開発者である@azu_reさんです。

twitter.com twitter.com

それでは11/18のLint Night #1でお会いしましょう!

lintnight.connpass.com