DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

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:機械の電源を入れたときに煙を吹かないかを見るような簡素なテスト