やらなイカ?

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

UI Test Helper パッケージ v1.0.0 リリース

Unity Test Framework でUIを操作するテストを書くときに便利なライブラリ UI Test Helper パッケージのバージョン 1.0.0 をリリースしました。

元々モンキーテスト用のライブラリ(旧称 Monkey Test Helper)として開発していましたが、モンキーに限らずUIテストに使えるAPIが増えてきたので改名しました*1

github.com

インストールは、GitHubリポジトリ指定や、openupm.comから可能です。 openupm-cli であれば次のコマンドでインストールできます。

openupm add com.nowsprinting.test-helper.ui

以下、v1.0.0で実装している機能を簡単に紹介します*2

TOC

GameObjectの検索

GameObjectFinderクラスでScene上のGameObjectを検索できます。GameObject.Findと異なる次のような特徴があります。

  • GameObjectが見つからない場合、指定したタイムアウトまでポーリング*3
  • ユーザーが到達可能(カメラからのレイキャストが通ること)を条件にできる*4
  • 操作可能な状態を条件にできる*5
  • GameObjectのパスで検索するとき、globのワイルドカード書式が使用できる
  • Button を text および textureファイル名で検索できる(ButtonMatcherを使用)
  • ルーセルやスクロール領域内のオブジェクトも検索できる(uGUIのScrollRectであればUguiScrollRectPaginatorを使用)

使用例

using NUnit.Framework;
using TestHelper.UI;

[TestFixture]
public class MyIntegrationTest
{
    [Test]
    public async Task パスで検索()
    {
        var finder = new GameObjectFinder(5d); // 5 seconds timeout
        var result = await finder.FindByPathAsync("/**/Confirm/**/Cancel", reachable: true, interactable: true);
        var cancelButton = result.GameObject;
    }

    [Test]
    public async Task ボタンのテキストで検索()
    {
        var finder = new GameObjectFinder();
        var matcher = new ButtonMatcher(text: "Click Me");
        var result = await finder.FindByMatcherAsync(matcher, reachable: true, interactable: false);
        var button = result.GameObject;
    }

    [Test]
    public async Task ScrollRect内のボタンを検索()
    {
        var finder = new GameObjectFinder();
        var matcher = new NameMatcher("Button_10");

        var scrollView = GameObject.Find("Scroll View");
        var scrollRect = scrollView.GetComponent<ScrollRect>();
        var paginator = new UguiScrollRectPaginator(scrollRect);

        var result = await finder.FindByMatcherAsync(matcher, paginator: paginator);
        var button = result.GameObject;
    }
}

GameObjectの操作

操作に応じたオペレーターが定義・実装されており、uGUIのイベントシステムを使用してクリックなどのイベントをGameObjectに送って操作します。

たとえば UguiClickOperator であれば、次のイベントを再現します。

  1. OnPointerEnter
  2. OnSelect*6
  3. OnPointerDown
  4. OnInitializePotentialDrag
  5. OnPointerUp
  6. OnPointerClick
  7. OnPointerExit

使用例

using NUnit.Framework;
using TestHelper.UI;

[TestFixture]
public class MyIntegrationTest
{
    [Test]
    public async Task InputFieldに文字入力してSubmitButtonをクリック()
    {
        var finder = new GameObjectFinder();

        var message = await finder.FindByNameAsync("Message", interactable: true);
        var inputOperator = new UguiTextInputOperator();
        await inputOperator.OperateAsync(message.GameObject, "Hello, Hurry?");

        var submit = await finder.FindByNameAsync("SubmitButton", interactable: true);
        var clickOperator = new UguiClickOperator();
        await clickOperator.OperateAsync(submit.GameObject);
    }
}

モンキーテスト

画面上の操作可能なUI要素をでたらめに操作します。 テスト実行時間や操作間隔、また使用する操作の種類は MonkeyConfig で指定できます。

なお、モンキーテストで操作させたくないUI要素を無視させる IgnoreAnnotation のほか、操作する座標をずらしたり入力する文字種類の指定を行うアノテーションコンポーネントを利用できます。

使用例

using System;
using System.Threading.Tasks;
using NUnit.Framework;
using TestHelper.UI;

[TestFixture]
public class MyIntegrationTest
{
    [Test]
    public async Task モンキーテスト()
    {
        var config = new MonkeyConfig
        {
            Lifetime = TimeSpan.FromMinutes(2),
            DelayMillis = 200,
            SecondsToErrorForNoInteractiveComponent = 5,
        };

        await Monkey.Run(config);
    }
}

エディター拡張

パスのコピー

ヒエラルキーウィンドウでGameObjectを右クリックして Copy to Clipboard > Hierarchy Path でGameObjectのパスをクリップボードにコピーできます。

インスタンスIDのコピー

ヒエラルキーウィンドウでGameObjectを右クリックして Copy to Clipboard > Instance ID でGameObjectのインスタンスIDをクリップボードにコピーできます。

カスタムUIフレームワークへの対応

uGUIに準拠していないカスタムUIフレームワークを使用している場合、ビルトイン機能をそのまま使用できない場合があります。 UI Test Helperは次の拡張ポイントを用意しており、必要に応じてゲームタイトル側に実装することで紹介したAPI(モンキーテストも含め)をそのまま利用できます。

  • オブジェクトが操作可能かを判断する関数
  • オブジェクトを無視するべきかを判断する関数
  • ユーザーがオブジェクトに到達可能かを判断する関数
  • 検索に使用するMatcherの実装
  • 検索に使用するPaginatorの実装
  • Operatorの実装

関連

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

*1:前々から機会を伺っていたのですが、CEDEC2025登壇に合わせてやりました

*2:詳しくはリポジトリのREADMEを参照してください

*3:固定秒数待機するのはアンチパターンです

*4:スマートフォンゲームでよくある、別のオブジェクトで隠してタップを受け付けない状態では待つ

*5:uGUIであればInteractableで判断します

*6:オブジェクトがSelectableのときのみ。なお、OnDeselectはuGUI側で発行されるため再現していません

CEDEC2025『E2Eだけがテスト自動化じゃない! Unity製ゲームの開発者テスト チュートリアル』フォローアップ

CEDECはじめゲーム領域でもテスト自動化の事例紹介が増えましたが、従来の手動テストを自動テストに置き換えるアプローチが紹介されることが多く*1、ROIが高く小規模なタイトルであっても取り組める開発者テストの事例・情報は少ないのが現状です。

そんな中、CEDEC2025 で開発者テストについての招待講演のお話をいただき登壇してきました。 タイムシフト視聴は 8/4 10:00 AMまでなので、パスをお持ちの方はぜひご視聴ください。

cedec.cesa.or.jp

スライドはこちら(CEDiLと同じもの)

参考リンク

スライド中にも埋め込んでありますが、リンクを書き出します。

Automated Testing of Gameplay Features in 'Sea of Thieves' (GDC2019)

バグ数のグラフだけ引用しましたが、開発者テストの導入だけで得られた結果ではなく、テストの運用も徹底した成果です。 それも含め、参考になるはずです。

スライド

www.gdcvault.com

動画

www.youtube.com

Unity公式パッケージ

OSSパッケージ

参考書籍

ユニットテストと統合テストについて、ゲーム本体のテスタビリティとテストコードの保守性にフォーカスしてお話しました。 またAnjinによるゲームプレイテストについても、ごく一部のユースケースしか紹介できませんでした。 いずれも、詳しくは同人誌に書いておりますので参考にしてください。

セッションでも触れましたが、『Unity Test Framework完全攻略ガイド』は夏のコミックマーケット106で第3版に改版予定です。 電子版は今BOOTHに出ているものをご購入いただければアップデートされます。技術書典マーケットも同様にアップデートしますが、タイミングは次のイベント開催時期(11月)になりますのでご注意ください。

ikagoya.booth.pm

ikagoya.booth.pm

ikagoya.booth.pm

ユニットテストのセクションで紹介した各種パターンの名称は、xUnit Test Patterns (xUTP) で紹介されているものです。 やや古い書籍ですが、現在でも現役で使用しているパターン集です。

Ask the Speaker

多数の方に来ていただき、ほぼ時間いっぱい色々お話させていただきました。覚えている範囲かつ書けるものだけ紹介します。

Test RunnerウィンドウのPlayerタブが使いにくい件

Unity Test Frameworkパッケージ(以下UTF)v1.3.xまでは、Play Modeテストを名前やカテゴリで絞り込んでから、エディタ内で実行ボタンかプレイヤー実行ボタンで実行できました。つまり、同条件での実行が簡単でした。

UTF v1.4からプレイヤー実行はPlayerタブで実行するようになったため、開発(変更)した箇所のPlay Modeテストをエディタ実行した後、同条件でプレイヤー実行するには、タブを切り替え、名前やカテゴリの絞り込みを再度行なう必要があり不便になりました。

同じ不満を持った方によるフォーラムの投稿 Test Framework 1.4 / 1.5 feedback and bugs (IN-88016) がありますので、同様の感想をお持ちの方はLikeするなどしてほしいです。

また、タブを分けるのであればテストの有効/無効に UnityPlatform属性を反映してほしい(現状はPlayerタブでもEditorと扱われる)という要望かIssueを投げてリジェクトされた記憶があるのですが探し出せませんでした*2

開発者テストをどこから導入すべきか

テストが開発の役に立つという体験が先決だと思っています。2つアプローチがあります。

  1. Anjinで広く荒くカバーするテスト(アウトゲームの画面遷移やチュートリアルなど)をCIで定期実行し、「QA向けビルドが壊れた」ときに早く検知できるようにする
  2. 実際の開発プロジェクトではなく、ユニットテスト習得のための題材*3でワークショップを実施する

ワークショップで実際のプロジェクトを使わないのは、テスタビリティが低いところがあったり担当箇所によって体験に差が出るためです。 ワークショップ実施前に参加メンバーで『Unity Test Framework完全攻略ガイド』の読書会を実施するのもおすすめです。

統合テストにはどう取り組めばいいのか

統合テストのターゲット、粒度の設定などは、ゲームタイトルごとに異なります。ゲームジャンルによる差異だけでなく、そもそも工程の考え方もドキュメントの粒度も標準的なものがなくバラバラなので。

まず観点として「なにをテストするのか」(もしくは「どのようなバグを見つけたいのか」)から出発します。わかりやすいのは仕様書に書かれている粒度・精度(解像度)、機能実装のPull Requestに書いている確認内容をテストコードにするという考え方です。

たとえばアウトゲームであれば、UI操作を通して「〇〇画面に遷移して〇〇機能を実行すると何が起こるのか」といったテストが中心になるでしょう。 インゲームの例として前掲の Sea of Thieves では「敵キャラが索敵、プレイヤーを発見、接近、攻撃」するという一連の振る舞いをActorのテスト*4として実施したことが紹介されています。

バグが出たときに「自動テストがあれば見つかっていた」と言う話はすべきか

大いにすべきだと思っています。目的は将来もしくは次のプロジェクトでの改善で、すぐには着手できなくともチームの課題意識を高め解像度を上げていく効果が見込めます。 ただし漠然とした「テスト」という表現では意味がなく、具体的にどのような観点のテストであれば発見できたかまで言及しないと机上の空論となり無意味です。

CEDEC AWARDS 2025

招待講演のほか、CEDEC AWARDS 2025 エンジニアリング部門で 『Unity開発におけるシフトレフトの推進、オープンソース化による技術の伝搬』 ということで優秀賞をいただきました!

開発者テストという地味な取り組みを拾い上げていただいて感謝です。テストに興味を持ってくれる人が増えるきっかけになれば幸いです。

cedec.cesa.or.jp

C106

コミックマーケット106に出ます! 2日目(日曜)東地区 “U”ブロック-37a、東7ホール入口(MAPの下側)から比較的近い島です。 お隣は『UNIBOOK16』の日本Androidの会Unity部さんです。

新刊は3年ぶりの改版『Unity Test Framework完全攻略ガイド 第3版』*5。 既刊Anjin本とTAROMAN本の在庫も持っていきます。

お近くまでお越しのついでにでも寄っていただけると嬉しいです。お待ちしております!

*1:見栄えしますし難しい取り組みだからこそ講演の題材になり得るためだと思われます

*2:このissue https://unity3d.atlassian.net/servicedesk/customer/portal/2/IN-72066 でテスト実行結果だけは反映されるよう修正してもらえらのですが

*3:時期は未定ですが、サイバーエージェント様で使用しているものを公開できる予定です

*4:Unreal EngineにおけるIntegration testsはかなり重いようで、その手前に独自にActor testsのレイヤーを定義して実装されています

*5:間に合わなかったらごめんなさい…

Unityプロジェクトでも Claude Code に自走させるワークフロー

最近は Claude Code 中心に使っていて、Unityプロジェクト(といってもゲームでなくUPMパッケージ開発が多い)でもそこそこ安定するワークフローが固まってきました。 一例として紹介します。

目的

紹介するワークフローの目的は次の2点です。

  • できるだけ自律的に、自走させたい
  • レビュー負荷を減らしたい

前提として、すべてこのワークフローを使うのではなく、例えばコードを書きながら探索的に設計したいケースでは*1 従来通りGitHub Copilotのサジェストを使ってTDDで書いています。

また、いわゆる "Vibe Coding" ではなく、実装コードもテストコードもレビューし、保守性を高める修正を入れています。

ワークフロー

次の手順で開発を進めます。

  1. Plan modeで、実装する機能の要件、仕様、一部の実装方法などを伝え、それを一旦ドキュメントに書き出し
  2. ドキュメントをもとにテストケースを作成、ドキュメントに追記
  3. ドキュメントに従って実装。テストファースト(TDDではない)で、テストをパス、Roslynアナライザなどの診断にも対応させて完了

ドキュメントを経由する理由は、チェックポイントとして使うことと、コンテキストウィンドウの圧縮です。 各工程でドキュメント出力したら /clear コマンドなどでコンテキストをクリアしています*2

Claude Codeに限らず、ルールの強制力は弱いのであまり信用していなくて、プロンプトによる指示を重視していました。 Claude Codeにはカスタムコマンド機能があるので、各工程に対応したコマンドファイルを用意して使用しています。

/create-doc コマンド

工程1で要件などを伝え終えた後で(Claude Codeが実装に進もうとするのを止めて)実行します。

これまでの会話内容を仕様書としてファイルに書き出してください。

以下の手順に従ってください。

1. 会話中に、要件(なぜ)、仕様(何を)、設計(どのように)が明確になっているか分析してください。明確でない場合は、このコマンドの実行を中止してください。
2. ファイルに書き出してください。
  - 形式:Markdown
  - 言語:日本語
  - パス:./Documentation/yyyy-mm-dd-subject.md
    - yyyy-mm-dd は現在日付です。
  - セクション:
    - 要件、問題、または動機(適切なものを選択してください)
    - 仕様
    - 設計
3. Git にコミットしてください。

ドキュメント化に必要な情報を伝えきれていれば、ファイルが作られます。 軸になるのは「仕様」の部分ですが、LLMへの指示が目的ですので、ある程度の実装方式まで盛り込むほうが後工程が安定します。

この時点でレビューし、不足情報があれば付け足すなど修正します。 修正は Claude Code に頼んでも、ファイルを直接書き換えても構いません。

/create-test-cases コマンド

/clear 後もしくは新しい会話セッションで入力します。引数に、create-doc で生成したドキュメントファイルパスを渡します。

$ARGUMENTS ファイルの仕様が満たされていることを確認するために必要なテストケースを考え、ファイルに追加してください。

以下の手順に従ってください。

1. 仕様を読み、分析します。明確でない場合は、このコマンドの実行を中止してください。
2. テスト対象の public クラスとメソッドを、統合度の低い順に選択します。
3. カバレッジを考慮したテストケースを導出するために使用するテスト技法(同値分割法、境界値分析、状態遷移テストなど)を選択します。
4. テスト技法を使用して、自然言語でテストケースを作成します。以下の点に注意してください。
  - 連続した ID は作成しないでください。
  - 検証内容を記述します。検証できない場合はテストケースを削除します。
5. テスト対象のすべてのクラスがカバーされるまで、手順 2 から 4 を繰り返します。
6. $ARGUMENTS ファイルにテストケースを追加します。
7. Git にコミットします。

これで、ドキュメントにテストケースが追記されますので、レビューします。 検証しにくく、かつ価値の低いテストケースを挙げられることもあるので、この時点で刈り取っておきます*3

テストケースを見て仕様の不備に気づくことも多いので、その場合は仕様部分も同時に修正したり、問題が大きい場合は revert して前工程に戻ってもいいでしょう。

なお、手順2で結合度の低い順と指示しているのは、逆だと無駄にテストダブルを使ってテストを書こうとするためです。

/implement-code コマンド

/clear 後もしくは新しい会話セッションで入力します。引数に、create-doc で生成したドキュメントファイルパスを渡します。

$ARGUMENTS ファイルの仕様を満たすコードを実装してください。

以下の手順に従ってください。

1. 仕様にテストケースが含まれていない場合は、このコマンドの実行を中止してください。
2. 製品コードに対して、コンパイル可能な型とパブリックメソッドシグネチャのみを作成します。動作しなくても問題ありません。
3. ドキュメントに記載されているテストケースに基づいてテストコードを実装します。
4. 追加したテストを実行し、失敗することを確認します。
5. Git にコミットします。
6. 製品コードを実装します。
7. `mcp__jetbrains__open_file_in_editor` および `get_current_file_errors` ツールを使用して、重大度レベルが `error` の診断を解決します。
8. テストを実行し、すべて合格します。
9. Git にコミットします。
10. KISS および SOLID 原則を念頭に置いてリファクタリングし、合格するようにテストを再実行します。
11. 重大度レベルが `suggestion` 以上の診断を解決し、テストを再実行して合格を確認します。
12. `mcp__jetbrains__reformat_file` ツールを使用して、変更したファイルを再フォーマットします。
13. Git にコミットします。

注:
- テストの実行方法については、@.claude/commands/run-tests.md ファイルを参照してください。

ドキュメントの指示通りにテストファーストで実装が進められ、最終的にテストがパスし、Roslynアナライザも含めコンパイラの警告も解消したコードができあがります。

手順10でリファクタリングを指示していますがあまり期待はしておらず、たとえば CognitiveComplexity プラグインでコグニティブ複雑度の高いコードに警告が出るようにしておき、それを手順11で検出・修正させているのが重要です。

最低限、テストコードが適切な検証(アサーション)をしているかをレビューし、問題があれば修正します。 問題が大きい場合は revert して前工程に戻ってもいいでしょう。 動作に問題がなくなったら(今後の変更可能性の高い箇所なら特に)Tidy/リファクタリングまで行ないます。

なお、実はTDD版コマンドも作ってあるのですが使っていません。設計がほぼ固まっている、コンテキストウィンドウを圧迫するなどの理由でLLM向きではないと思っています。

/run-tests コマンド

これはワークフロー外ですが、コードを修正した後などにUnityエディター上でテストを実行するコマンドを定義しています。 必要に応じて個別に使います。

`UnityNaturalMCP` 経由で Unity エディターでテストを実行してください。

## 適切なツールの選択

変更したクラスの名前空間またはアセンブリ名に「Editor」が含まれている場合は、`mcp__UnityNaturalMCP__RunEditModeTests` ツールを使用してください。
変更したクラスの名前空間に「Editor」が含まれていない場合は、`mcp__UnityNaturalMCP__RunPlayModeTests` ツールを使用してください。

## フィルターの指定

実行されるテストの数を最小限に抑えるため、フィルターは以下の順序で決定されます。

1. **testNames**: 特定のテストのみが失敗している場合、または影響を受けるテストが限られた数のみである場合に指定します。
2. **groupNames**: 変更したクラスに対応するテストクラスを指定します。名前空間は変更したクラスと同じで、クラス名に「Test」が付加されます。
3. **assemblyNames**: 変更したクラスを含むアセンブリに対応するテストアセンブリの名前を指定します。アセンブリ名に「.Tests」を追加して指定してください。

## トラブルシューティング

ツールが接続エラーで失敗する場合は、以下の原因が考えられます。

- コンパイルなどによるドメインの再読み込みにより、接続が切断されている可能性があります。しばらく待ってから再試行してください。
- コンパイルエラーがある場合、Play Mode テストは実行できません。`mcp__jetbrains__open_file_in_editor` ツールと `get_current_file_errors` ツールを使用して、コンパイルエラーがないか確認してください。
- 実行時間が長いため、テストがタイムアウトしている可能性があります。フィルター設定を確認して実行するテストを絞り込むか、ユーザーにタイムアウト設定を長くするよう依頼してください。

環境

前後しますが、上記ワークフローを成立させている環境は次のとおりです。

Unityエディターでテストを実行するために UnityNaturalMCP パッケージを使用しています。Unityエディター向けMCPサーバはいくつか公開されていますが、テストを実行できるものは限られます。 前述の /run-tests コマンドではフィルターの指定などが UnityNaturalMCP に最適化されています。

また、Roslynアナライザなどの診断結果を取得するために、Riderに Claude Code プラグインもしくは JetBrainsの MCP Server プラグインをインストールします。

Claude Code の設定

紹介したコマンドファイルを含む Claude Code 設定ファイルを次のリポジトリに公開しています。

github.com

このまま使うのであれば、プロジェクトルートにサブモジュールとして追加します。

git submodule add git@github.com:nowsprinting/claude-code-settings-for-unity.git .claude

.mcp.json

MCP設定ファイルはプロジェクトルートに置く必要があるので、コピーするかシンボリックリンクを張ります。

ln -s .claude/.mcp.json .mcp.json

CLAUDE.md

最後に CLAUDE.md ファイルを作ります。リポジトリには含まれていません。

プロジェクト固有の情報はこのファイルに書き、コーディングガイドラインなど共通的なものは rules/ 下にあるファイルを読み込ませています。 Claude Codeがメモリとして読んでいるファイルは /status コマンドで確認できます。

# Development Guidelines

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

プロジェクト固有の情報

## 行動規範

Claude Code の行動規範、開発の進め方に関するグランドルールは、次のファイルを参照してください。

- @.claude/rules/00-code-of-conduct.md

## コーディングおよびテストのガイドライン

次のファイルを参照してください。

- @.claude/rules/01-coding.md
- @.claude/rules/02-testing.md

## Unityプロジェクトの構造およびガイドライン

次のファイルを参照してください。

- @.claude/rules/10-unity-project.md
- @.claude/rules/11-unity-documentation.md
- @.claude/rules/12-unity-yaml.md

~/.claude/CLAUDE.md(任意)

ユーザースコープのCLAUDE.mdでは、チャットの応答について指示しています。言葉の乱れはコンテキストウィンドウの乱れです。

# CLAUDE.md

## チャットの応答

チャットの応答は日本語で、ギャル口調で返してね☆

~/.claude/settings.json(任意)

ユーザースコープのsettings.jsonには、次の2項目だけ書いています。

  • permissions.deny: プロジェクトにsettings.jsonを入れずにclaude実行しちゃった対策でdenyだけ書いています。permissionsは丸ごとプロジェクトスコープで上書きされるようです(allowはリストアイテム単位でlocalがマージされるようですが)
  • hooks.Notification, Stop: 通知音を鳴らす設定を入れています。ただしhooksはファイルに書くだけでは有効化せず、/hooks コマンドで登録する必要があります

現状の課題

紹介したワークフローは完全とは言えず、いくつかの課題を残しています。解決策などあればぜひ教えて下さい。

  • テスト実行前のコンパイル確認がスルーされることがある(なぜかテストが実行できない… となる。そもそもUintyのエディタ拡張でMCPサーバを立てることの限界もある)
  • implement-code.md の手順11で重大度レベル suggestion 以上を指示していますが、現状では Claude Code プラグインide__getDiagnostics でも、JetBrains MCP Server プラグインget_current_file_errors でも、warning までしか拾うことはできません
  • テストの下手さ、保守性の低さは解決しきれておらず、まだ手作業でのTidy/リファクタリングが必要

参考

permissions.denyの設定はこちらの記事を参考にしています。

izanami.dev

宣伝

LLM はテストを書くのが下手くそなので、本書を読んでテストコードを書くスキルを身に着けておくことが役に立つはずです。 ikagoya.booth.pm

CEDEC 2025 で登壇します。開発者テストの話ですが、Agentic Coding にからめた話もする予定です。 cedec.cesa.or.jp

*1:使ったことのないライブラリに強く依存する、外向けのインタフェースを決めきれない等

*2:筆者はセッション自体を切ってしまうことが多いです。RiderのTerminalもクリアしたいため

*3:検証できない==アサーションの無いテストでコードカバレッジだけ上げるのは避けるべきです

Anjinに関する記事・リソースまとめ

株式会社ディー・エヌ・エー(以下DeNA)が公開しているオープンソースのUnity向けオートパイロットフレームワーク Anjin(あんじん)に関する記事・リソースのまとめです。随時更新予定。

github.com

DeNA公式

swet.dena.com

swet.dena.com

メディア

gamemakers.jp

カンファレンス

www.docswell.com

cedec.cesa.or.jp

cedec.cesa.or.jp

cedec.cesa.or.jp

cedec.cesa.or.jp

同人誌

booth.pm

ブログ記事

myudon.hatenablog.com

qiita.com

www.nowsprinting.com

technote.qualiarts.jp

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

依存ライブラリに関する記事

www.nowsprinting.com

www.nowsprinting.com

統合テストの合否判定を Gemini API の画像解析で行なう Visual Verification Agent 実装サンプル

Googleの提供するマルチモーダルLLM Gemini を利用して、ゲームプレイの自動テストの合否判定を画像(スクリーンショット)ベースで行なう方法を紹介します。

これまでも、Anjin などのオートパイロットフレームワークを利用することで規定のシナリオに沿った自動テストは実現できていました。 しかし、テストの合否判定は「シナリオの最後まで画面遷移できたこと」をもって合格とすることが多く、操作完了時点のSceneの状態まで検証することは高コスト*1になるため避けてきました。

本記事では、操作完了など合否判定を行ないたいタイミングでスクリーンショットを撮影し、Gemini APIを使用して「画像になにが描かれていれば合格か」をプロンプトとして与えるだけで検証ができるAnjinのカスタムAgentを実装します*2

利用イメージ

まず、どのように動作するかを紹介します。 動作するプロジェクトは、Unity社の提供する2Dシューターサンプル GalacticKittens をフォークしたリポジトリjudge-by-gemini-agent ブランチにあります。

事例1: マルチプレイが成立していることを検証する

GalacticKittensは4人までのマルチプレイが可能なゲームです。マルチプレイのテストシナリオでは、別プロセスで立ち上げたプレイヤーがJoinできていればインゲームで2機の戦闘機が表示されます。

これを、次のAgentで検証します。

Prompt には「スクリーンショットに何が写っていれば成功と判断できるか」を自然言語で書きます。ここでは「右向きの赤い戦闘機と青い戦闘機が表示されていること」としています。

Success Threshold には、Geminiが返すスコア(0.0〜1.0)がいくつ以上であればテスト成功とみなすかのしきい値を設定します。LLMの性質上、プロンプトの工夫だけでは偽陰性偽陽性はゼロにはできません。そのテストシナリオの目的によって偽陰性偽陽性どちらを許容するかは異なるため、このプロパティで調整します。

Anjinを起動し、インゲームが開始されて数秒経つと、Geminiから次のようなレスポンスが返されます。 スコアが0.8以上ですので、テストは成功と判断されます。

Response: {
  "comment": "画像には右向きの赤い戦闘機と青い戦闘機が表示されています。",
  "score": 1.0
}

もし別プレイヤーのJoinに失敗した場合、次のようなレスポンスが返ります。 スコアが0.8未満なので、テストは失敗と判断されます。

Response: {
  "comment": "画像には右向きの赤い戦闘機が表示されていますが、青い戦闘機は表示されていません。",
  "score": 0.5
}

事例2: リザルト画面の文字を判読する

GalacticKittensでは、ボスを倒したとき、もしくは全滅したときにリザルト画面が表示されます*3

これを、次のAgentで検証します。

Prompt は「VICTORY!と表示されていること」としています。 これはボスを倒したときに画面に表示される文字で、テキストでなくスプライトです。

Geminiからは次のようなレスポンスが返ります。ちゃんと文字が読めています。

Response: {
  "comment": "画像には「DEFEAT」と表示されており、「VICTORY!」とは表示されていません。",
  "score": 0.0
}

実装方法

続いて実装方法を紹介します。 環境は Unity 6000.0.23f1、Anjin v1.9.0 です。

GemiNetのインストール

Gemini には公式 C# SDK は存在しないため、REST APIもしくはサードパーティSDKを利用します。

今回は、GemiNet v1.0.3を使用しました。 APIデザインが公式SDKに準じているため、公式ドキュメントのサンプルコードを見ながら実装するのがとても楽でした。

github.com

NuGetパッケージですので、NuGetForUnityを使用してインストールします。 NuGetForUnityについては次の記事を参照してください。

www.nowsprinting.com

インストールしたら、Agentを置くasmdefのAssembly Referencesに次のDLLを追加します。

  • GemiNet.dll
  • System.Text.Json.dll

続いて、Agentのコードを実装していきます。

Geminiクライアントの初期化

Geminiクライアントの初期化にはGeminiのAPIキーが必要です。APIキーはハードコードするのではなく、コマンドライン引数か環境変数から受け取るようにします。 Anjinに含まれるユーティリティクラス Argument<T> が便利なので使っています。

var apikey = new Argument<string>("GEMINI_API_KEY");
if (!apikey.IsCaptured())
{
    return;
}

using var ai = new GoogleGenAI();
ai.ApiKey = apikey.Value();

Unityエディターで実行するときは、Unity Hubでプロジェクト右端のドロップダウン > Add command line arguments クリックで表示されるダイアログでコマンドライン引数を設定できます。

スクリーンショットの撮影とアップロード

スクリーンショットを撮影し、GoogleGenAI.Files.UploadAsync メソッドでGeminiにアップロードします。

画像は、後で紹介する GoogleGenAI.Models.GenerateContentAsync メソッドに直接渡すこともできるのですが、20MB制限があるため、事前にアップロードします。

var texture = ScreenCapture.CaptureScreenshotAsTexture();
var bytes = texture.EncodeToPNG();
var base64 = Convert.ToBase64String(bytes);

var file = await ai.Files.UploadAsync(
    new UploadFileRequest()
    {
        File = new UploadFileContent(new Blob() { Data = base64, MimeType = "image/png" }),
        MimeType = "image/png",
    },
    cancellationToken: cancellationToken);

画像解析

次に、GoogleGenAI.Models.GenerateContentAsync メソッドに画像やプロンプトを渡してレスポンスを受け取ります。

var response = await ai.Models.GenerateContentAsync(new GenerateContentRequest
    {
        Model = Models.Gemini2_0Flash,
        Contents = Content.CreateUserContent(
            Part.FromUri(file.Uri, file.MimeType),
            Part.FromText(prompt)),
        SystemInstruction = "Analyze the image and determine whether it meets the user prompt's requirements. The response consists of a score (maximum = 1.0) and a corresponding comment in Japanese.",
        GenerationConfig = new GenerationConfig
        {
            ResponseMimeType = "application/json",
            ResponseSchema = new Schema
            {
                Properties = new Dictionary<string, Schema>
                {
                    { "score", new Schema { Type = DataType.Number } },
                    { "comment", new Schema { Type = DataType.String } }
                },
                Required = new[] { "score", "comment" },
                Type = DataType.Object
            },
        }
    },
    cancellationToken: cancellationToken);

引数の GenerateContentRequest に設定している内容は次のとおりです。

  • Model: 推論に使用するモデルを指定します。ここではGemini 2.0 Flash
  • Contents: アップロードした画像の情報と、SerializeFieldに設定されたユーザープロンプトをそのまま渡します
  • SystemInstruction: システム命令は固定にしています。内容は「画像を解析し、ユーザープロンプトの要件を満たしているかどうかを判断します。応答は、スコア(最大1.0)と、それについての所見を日本語で返すこと」としています
  • GenerationConfig: レスポンス本文のフォーマットを指示します。JSONで "score" と "comment" を含むフォーマットと定義しています

レスポンスの処理

レスポンスは指定したJSON形式なので、System.Text.Jsonパッケージを使ってスコアとコメントを取り出し、合否を判定します。

var json = JsonDocument.Parse(response.GetText()).RootElement;
var score = json.GetProperty("score").GetDouble();
var comment = json.GetProperty("comment").GetString();

if (score < successThreshold)
{
    var message = $"Visual verification is a failure! score:{score} comment:{comment}";
    AutopilotInstance.TerminateAsync(ExitCode.AutopilotFailed, message).Forget();
}

テストシナリオ(AutopilotSettings)への組み込み

作成した VisualVerificationAgent は、他のAgentと同じようにコンテキストメニューからインスタンスを生成し、プロンプトを設定します。 そして、テストシナリオの終端(および途中の要点)に配置すれば完了です。

※ 実際は2つのテストシナリオにそれぞれ使用するべきところ、1つのAutopilotSettingsにまとめて設定した例

注意事項

画像解像度

画像解析の精度は、画像の解像度に影響を受けるようです*4。当初、320x180サイズで試していたところ、インゲームの画面で次のレスポンスが返ってきました。

Response: {
  "comment": "画像には赤い戦闘機が表示されていません。宇宙空間のような背景の中に、赤い惑星が見られます。",
  "score": 0.0
}

背景は認識しているようなので、プロンプトを「宇宙にいる」に変えてみたところ、次のレスポンスが返りました。

Response.text: {
  "comment": "画像は宇宙空間を示しているようです。",
  "score": 0.95
}

ゲームタイトルによって異なるはずですが、今回の GalacticKittens では、800x480以上で安定したレスポンスが返るようになりました。

消費トーク

画像解像度によって消費トークンが変わってきます。 320x180の場合、300くらい。 800x480の場合、1900くらいでした。

なお、800x480より大きくしても結果の精度も消費トークンも上がりませんでした*5

まとめ

マルチモーダルLLMの登場で、これまでルールベースでの自動化が困難だった分野も低コストで自動化できる可能性が出てきました。 ゲーム領域において統合/E2Eレベルのテストは難しく、しかしコストはかけられず、ただ動かして進行不能にならないかを見るだけだったり、自動でスクリーンショットだけ撮影して後で人間がチェックする「半自動化」が最適解だったわけですが、状況は変わってきそうです。

なお、LLMとルールベースとは異なる特性を持っています。 LLMは人間に近く、柔軟ですがミスもします。 従って、たとえば自動で操作する部分は従来通りルールベースやキャプチャ/プレイバックのほうが向いており、LLMの推論ベースに置き換える必要はないと考えています。 適材適所です。

関連

本記事のように小さな部品を簡単に組み合わせて使えるAnjinは良いフレームワークなので(自画自賛)、ぜひ使ってみてください。使いかたから拡張方法まで、こちらの同人誌に詳しく載っています。

ikagoya.booth.pm

CEDEC 2025で登壇します。Anjinの話もしますが、ユニットテストを含めた開発者テスト全般についてのセッションです。

cedec.cesa.or.jp

*1:ゲームは画面に表示される内容が決定的でないことが多く、またそうでなくてもヒエラルキーをたどる実装コスト、仕様変更に対応するメンテナンスコストがかかります

*2:将来的にAnjinパッケージのSamplesに追加するかもしれません

*3:実はボスを倒したときと全滅したときで異なるSceneに遷移するのでこのような仕組みは不要なのですが、文字を読む事例として…

*4:サイバーエージェントのAI Labの方々に伺ったところ、前処理とかコントラストとかを考えるよりも、まず解像度だそうです

*5:サイバーエージェントのAI Labの方曰く、Gemini内でサイズに上限があり圧縮されているのではないかとのこと

Anjinでシナリオ終了前N秒の動画を保存するレポーターの実装サンプル(Instant Replay for Unityパッケージを利用)

株式会社ディー・エヌ・エー(以下DeNA)が公開しているオープンソースのUnity向けオートパイロットフレームワーク Anjin(あんじん)は、シナリオ終了時に動作するReporterを設定できます。 ビルトインではJUnit形式のテストレポートを出力する JUnitXmlReporterスクリーンショット付きでSlack投稿を行なう SlackReporter が提供されていますが、ゲームタイトル個々にニーズに合うものを実装して使用できます。

本記事では、株式会社サイバーエージェントが公開しているオープンソースのプレイ動画保存ライブラリ Instant Replay for Unity パッケージを使用して、Anjinのシナリオ終了前N秒の動画を保存するレポーターを実装する方法を紹介します。

blog.sge-coretech.com

環境

  • Unity 6000.0.23f1
  • Anjin v1.9.0
  • Instant Replay for Unity v0.2.0

動作するプロジェクトは、Unity社の提供する2Dシューターサンプル GalacticKittens をフォークしたリポジトリinstant-replay ブランチにあります。

Instant Replay for Unity のインストール

まず、UnityNuGetから次のNuGetパッケージをインストールします。

  • System.IO.Pipelines
  • System.Threading.Channels

2025年3月以降、UnityNuGetレジストリはOpenUPMにホスティングされ、package.openupm.com レジストリからアップリンクされているため、透過的に使用できます。 詳しくは次の記事を参照してください。

www.nowsprinting.com

続いて Instant Replay パッケージをインストールします。 こちらはGitHubリポジトリのURLを指定します。

https://github.com/CyberAgentGameEntertainment/InstantReplay.git?path=Packages/jp.co.cyberagent.instant-replay

VideoRecordingReporter の実装

AnjinのReporterは、AbstractReporter を継承して実装します。 2つのメソッドを実装します。

Instant Replay セッションの開始

AnjinのReporterは、Anjinの実行終了時にのみ明示的に呼び出されます。専用の初期化メソッドはありません。

そこで、Instant Replayセッションを開始する契機には InitializeOnLaunchAutopilot 属性を使用します。この属性を付与したstaticメソッドは、Anjinのシナリオ開始時にコールバックを受けられます。 ここでは、実際に実行されるAutopilot設定に紐づいた VideoRecordingReporter を探してInstant Replayセッションを開始します。

なお、numFramesなどの引数はReporterのシリアライズフィールドに指定された値を渡しています。

[InitializeOnLaunchAutopilot]
private static void InitializeReporter()
{
    foreach (var reporter in AutopilotState.Instance.settings.reporters.OfType<VideoRecordingReporter>())
    {
        reporter._session = new InstantReplaySession(
            numFrames: reporter.NumFrames,
            fixedFrameRate: reporter.FixedFrameRate,
            maxWidth: reporter.MaxWidth,
            maxHeight: reporter.MaxHeight);
    }
}

動画の書き出し

Anjinのシナリオ実行が終了するとき、Reporterの PostReportAsync メソッドが呼ばれます。 ここでInstant Replayセッションを停止し、動画をファイルに書き出します。

public override async UniTask PostReportAsync(string message, string stackTrace, ExitCode exitCode, CancellationToken cancellationToken = new CancellationToken())
{
    var outputPath = await _session.StopAndTranscodeAsync(ct: cancellationToken);
    if (outputPath != null)
    {
        var exportPath = Path.Combine(AutopilotState.Instance.settings.ScreenshotsPath, $"{this.name}.mp4");
        File.Move(outputPath, exportPath);
    }
    else
    {
        Debug.LogWarning("Video Exporting failed.");
    }

    _session.Dispose();
}

Reporterアセットの設定

実装した VideoRecordingReporter は ScriptableObject です。コンテキストメニューから.assetファイルを生成し、動画に関する設定を行ないます。

実装例では最大フレーム数、フレームレート、画面サイズを指定できるようにしてあります。 シナリオの実行に失敗したときの調査用途を想定しているので、解像度は低めにしてみました。フレームレートももっと下げていいはず。

設定したアセットをオートパイロット設定ファイルの Reporters に追加すれば動作します。

補足

VideoRecordingReporterの運用についての補足

本記事では動画を保存する単独のReporterを実装する方法を紹介しました。 動画は、実行したマシンのローカルディスクに保存されます。 これは、GitHub ActionsやJenkinsによって定期実行され、問題が生じたときにアーティファクトを参照する運用を想定しています。

もしシナリオ実行に問題があったときのSlack投稿に動画を添付したいニーズがあるなら、ビルトインの SlackReporter をコピーして独自Reporterを作り、そこに本記事の内容をマージすることで実現できます。

AgentにInstant Replayを組み込む場合の補足

Instant ReplayをReporterでなくAgentに組み込むアプローチも考えられます。 用途によっては機能しますが、制限が生じるためお勧めしません。 Anjinは停止時、Agentの実行タスクをキャンセルし、その終了を待たずにプロセスを終了します。 そのためテストシナリオの終了契機で動画を書き出しをはじめても、完了できずに終了してしまうでしょう。

ただ、Agentとして利用したいニーズはありそうなので、Anjin本体の変更も視野に入れて検討はしてみます。 それも踏まえて、本記事の VideoRecordingReporter は当面ビルトインしません。

参考

Anjinは、本記事で紹介したReporterのほかにも Agent、Loggerをさまざまに拡張できます。 詳しくは『Anjin非公式ファンブック』を参照してください。

ikagoya.booth.pm

『Anjin非公式ファンブック』は、5/31から開催される技術書典18でも頒布予定です。

techbookfest.org

Unityプロジェクト向け .editorconfigサンプル

EditorConfig は、異なるIDE間でもコーディングスタイルや静的解析の設定を共有できる仕組みです。 JetBrains Riderをはじめ、多くのIDE/ エディタでサポートされています。

本稿では、筆者の使用している設定を紹介します。

Roslynの.editorconfig

元にしているのはRoslynの.editorconfigファイルです。RoslynはC# 6.0から導入された.NETコンパイラプラットフォームの通称で、MITライセンスで公開されています。

roslyn/.editorconfig at main · dotnet/roslyn · GitHub

このファイルそのままで、Unity社の公開しているコードスタイルガイド『Use a C# style guide for clean and scalable game code』に準拠したコードフォーマットが得られます。 同ガイドのサンプルコードをこの.editorconfigでフォーマットしてみたところ、行末やコメントのスペースと一部の空行を除いて差分は出ませんでした。

unity.com

github.com

なお、file_header_template にはライセンス表記が入っています。ここだけはプロジェクトに応じて修正が必要です。

スタイルの変更

個人的な好みで、いくつか追加しています。詳細はキーで検索すればJetBrainsのページが見つかりますのでそちらを参照してください。

なお、.editorconfigはファイルパスごとにセクションが分かれています。[*.{cs,vb}] の下に追加してください。

フィールドなどと属性を別の行にする

csharp_place_type_attribute_on_same_line = false
csharp_place_method_attribute_on_same_line = false
csharp_place_accessorholder_attribute_on_same_line = false
csharp_place_field_attribute_on_same_line = false

SerializeField 属性などを別の行に書きたいので次の定義を追加しています。 すべての属性ではなく、たとえば Values 属性のように引数につけるものは除外しています。

連続した行のコメントの開始位置をそろえる

resharper_csharp_int_align_comments = true

C#8.0以降の構文をサジェストしない

resharper_convert_to_using_declaration_highlighting = none
resharper_convert_to_null_coalescing_compound_assignment_highlighting = none
resharper_merge_into_logical_pattern_highlighting = none
resharper_use_negated_pattern_in_is_expression_highlighting = none

筆者がメンテナンスしているUPMパッケージがUnity 2019 LTSをサポートしているため、Unity 2020.2以降で使用できる構文はサジェストされないように設定しています。

コードインスペクションの設定

switch文にenumを使用するとき、すべての値をcaseで列挙することを強制する

resharper_switch_statement_handles_some_known_enum_values_with_default_highlighting = warning

類似の設定に resharper_switch_statement_missing_some_enum_cases_no_default_highlighting がありますが、こちらは default がないときに検出されるものです。

一方この resharper_switch_statement_handles_some_known_enum_values_with_default_highlighting は、default があってもすべての値を case に書かないと検出されます。 ゲームの開発中や運用中、enum が追加されたときの対応漏れを検知できます*1リグレッションの原因として割と怖いものなので、error でもいいかもしれません。

BannedApiAnalyzersによる禁止をエラーにする

dotnet_diagnostic.RS0030.severity = error

BannedApiAnalyzers ではデフォルトの重大度が warning なのですが、チームで禁止にするなら error でいいはずです。

テストコード向け設定

テストコードは /Tests/ を含むパスに置くようにしているので、以下は [**/Tests/**/*.cs] と指定することでテストコードにのみ適用させています。

親クラスに定義されたstaticメソッドを許容する

resharper_access_to_static_member_via_derived_type_highlighting = none

一般的な例では、GameObject.FindAnyObjectByType<T>() と書くと検出されます(Objectクラスのメソッドなので)。

Unity Test Framework を使っていると、制約モデルの Is クラスを拡張することがあるのですが、それを使うときに煩わしいので none にしています。

複数のAssertを許容する

dotnet_diagnostic.NUnit2045.severity = none

テストメソッドに複数のアサーションを書くことは原則しないのですが、たとえば生成されたオブジェクトのプロパティを検証するときなど例外はあります。

NUnit.Analyzers ではこのとき Assert.Multiple を使うようサジェストするのですが、これはUnity Test Frameworkでは使用できないため、抑止しています*2

サンプル.editorconfig

以上を取り込んだ.editorconfigファイルが『Unity Test Framework完全攻略ガイド』*3のサンプルプロジェクト(MITライセンス)に置いてあります。 なお、file_header_template にはライセンス表記が入っています。ここだけはプロジェクトに応じて修正が必要です。

UnityTestExamples/.editorconfig at master · nowsprinting/UnityTestExamples · GitHub

また、上記のほかに次の差分があります。ご注意ください。

  • spelling_exclusion_path を削除:Riderの辞書を使っているため
  • charsetutf-8-bom から utf-8 に変更:Windowsを使っている場合はBOMつけたままでいいと思います

関連

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

*1:Unityでも record や record struct が使えるようになったら変わっていくとは思いますが

*2:サジェストがなければ警告させて、例外となるテストだけ個別にサプレスすべきだとは思うのですが

*3:夏に改版予定です