やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

UIテスト向けのGameObject検索API

Unity製プロジェクトにおいて、(ユニットテストでなく)UIを操作して動作を確認するテストを書くことがあります。

uGUIコンポーネントの操作に関しては『Unityバイブル R5夏号』の SECTION 06「ゲームプレイの自動テスト」の中で「アウトゲームのテスト」として紹介していますが、操作する GameObject の検索に汎用的に利用できるAPIをMonkey Test Helperパッケージ(com.nowsprinting.test-helper.monkey)*1に追加しましたので紹介します。

github.com

UIテストの目的

UIを操作するテストは、なんらかのミスでSceneファイルを壊してしまったり、Sceneに追加したアセットがアセットバンドルに存在しなかったり*2といったリグレッションを検知する目的で行われます。 多くの自動テストがそうであるように、新規のバグを見つけることはあまり期待できません。

見た目の問題を検出するのは難しいのですが、リグレッション目的であれば Graphics Tests Frameworkパッケージ(com.unity.testframework.graphics)*3を使用するなどしてスクリーンショットを比較して合否判定する手法もあります。

Graphics Tests Frameworkパッケージによるビジュアルリグレッションテストについては『Unity Test Framework完全攻略ガイド 統合テスト編』で紹介していますので参考にしてください。

Monkey Test Helperパッケージ

MITライセンスで公開しているOSSで、主用途はuGUIのモンキーテストを行なうリファレンス実装です。 モンキーテストについては、前掲の『Unityバイブル R5夏号』および『Unity Test Framework完全攻略ガイド 統合テスト編』で紹介していますので参考にしてください。

パッケージのインストールは、openupm-cli を使用して次のコマンドで行なうのが簡単です。

openupm add com.nowsprinting.test-helper.monkey

Package Managerウィンドウによるインストール方法は、リポジトリのREADMEを参照してください。

インストールしたら、Play Modeテストアセンブリ(.asmdef)のAssembly Definition Referencesに TestHelper.Monkey を追加します。

GameObjectFinder

Monkey Test Helperパッケージ v0.8.0で GameObjectFinder クラスを追加しました。 Sceneに存在する GameObject の検索は UnityEngine.GameObject.Find(string) メソッドでも行えますが、この GameObjectFinder には次の機能があります。

  • 非同期で GameObject の出現を指定秒だけ待つ
  • レイキャストが通る(==隠されていない)オブジェクトのみを対象とするオプション
  • 操作可能なコンポーネントを持つオブジェクトのみを対象とするオプション
  • GameObject のパス(Glob可)を指定して検索するメソッド

レイキャストや操作可能判定についてはストラテジパターンで判定関数を差し替え可能にしてあり、uGUIに準拠していない独自UIフレームワークにも適用可能です。

GameObjectFinder のコンストラクタには、次のように各asyncメソッドのタイムアウト時間を渡せます。デフォルトは1秒です。

private readonly GameObjectFinder _finder = new GameObjectFinder(3.0d);

独自UIフレームワーク向けの判定関数もコンストラクタ引数として渡します(本稿では詳細は割愛します)。

FindByNameAsync(名前で検索)

Sceneをロードして "Title" という名前のオブジェクト(CanvasでもPanelでも空のGameObjectでも)が表示されるのを待ち、さらに "StartButton" という名前の Button を探してクリック、"Home" という名前のオブジェクトが表示されることを検証するテストは次のように書けます。

[Test]
[LoadScene("Assets/Path/To/Scene.unity")]
public async Task ScreenTransitionTest()
{
  // "Title" という名前のオブジェクトの出現を待つ。reachableのデフォルトはtrue
  await _finder.FindByNameAsync("Title");

  // "StartButton" という名前の操作可能オブジェクトの出現を待ち、クリック
  var startButton = await _finder.FindByNameAsync("StartButton", interactable: true);
  var startComponent = InteractiveComponent.CreateInteractableComponent(startButton.GetComponent<Button>());
  Assume.That(startComponent.CanClick(), Is.True);
  startComponent.Click();

  // "Home" という名前のオブジェクトの出現を待つ
  await _finder.FindByNameAsync("Home");
}

GameObject の検索は、FindByNameAsync で行ないます。 引数として GameObject の名前のほか、レイキャストが通るオブジェクトのみを対象とするか、操作可能なコンポーネントを持つオブジェクトのみを対象とするかを指定できます。 デフォルトは「レイキャストが通る・操作可否は問わない」です。

コンストラクタで指定したタイムアウト時間までに GameObject が見つからなかった場合、TimeoutException が投げられます。 例外が投げられるとその時点でテストは失敗*4します。 そのため、このテストコードではアサーションを書いていません。

発見したオブジェクトを操作するのに InteractiveComponent を使用していますが、これはクリックなどの操作をラップしているだけです*5。説明は割愛します。

FindByPathAsync(パスで検索)

名前の代わりにScene内のヒエラルキー階層を / 区切りにしたパス文字列で検索できます。 パスにはGlobパターンを指定できますが、名前部分には含めることができません。

たとえば、"Help" という名前の GameObject の下にある "BackButton" という名前の GameObject を検索したい場合、次のように書くことができます。

var backButton = await _finder.FindByPathAsync($"**/Help/**/BackButton", interactable: true);

このように、名前だけでは特定できない GameObject が存在する場合でもピンポイントで検索できます。 またGlobパターンを使用できることで、ある程度はSceneの構造が変えられてもテストが壊れることを回避できるはずです。

テストシナリオ作成にあたっての注意

GameObjectFinder を使うことで比較的簡単にUIを操作するシナリオテストを書けますが、シナリオテストは実行時間もかかり、ゲーム本体の変更によって壊れやすいテストです。 最低限の量にとどめ、特に、長い操作シナリオを多量に作ることは避けるのが賢明です。

たとえば画面遷移に着目したテストであれば、状態遷移は0スイッチカバレッジで十分なはずです。 『Unity Test Framework完全攻略ガイド』でも紹介しているテスト技法も参考に、無理なくメンテナンスできるテストを書きましょう。

参考

www.nowsprinting.com

www.nowsprinting.com

*1:扱う範囲がモンキーテストに限らなくなってきたので、近々com.nowsprinting.test-helper.ui に改名予定

*2:バリデータを書くほうがよいのですが、それが難しい場合

*3:https://docs.unity3d.com/Packages/com.unity.testframework.graphics@latest

*4:NUnitでは厳密には「テスト失敗」でなく「エラー」とカウントされます。本APIはテスト外からも利用される想定のため、NUnitの例外にしていません

*5:このAPIは近い将来のバージョンで変わるかもしれません