やらなイカ?

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

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:夏に改版予定です

Monkey Test Helper v0.15.0 マイグレーション ガイド

Unity上でオブジェクトベースのモンキーテストを実行できる Monkey Test Helper パッケージ*1v0.15.0 では、いくつか破壊的変更が入りました*2。 そのマイグレーションについて解説します。

Monkey Test Helper は、オートパイロットフレームワーク AnjinUGUIMonkeyAgent の内部実装にも使われています。Monkey Test Helper v0.15.0 は、Anjin v1.9 で反映予定です。

なお Monkey クラスおよび Anjin をカスタマイズなしで使用しているプロジェクトには、本変更の影響はありません。

IOperator.OperateAsync() のシグネチャ変更

まず、第一引数の型、つまり操作対象の指定が Component から GameObject に変更されました。 この変更は実際のマウスやタッチ操作と合わせるためで、1つのGameObjectにイベントを受けられるコンポーネントが複数あるとき、そのすべてにイベントが飛びます。

また、第二引数として RaycastResult を受け取るようになりました。 これは Camera からのレイキャストで返される先頭(最前面)の RaycastResult で、クリック座標などを含みます。

RaycastResult は、後述の IReachableStrategy および GameObjectFinder の戻り値として取得できます。 オペレータの実装によっては使用しませんし(たとえば UGUITextInputOperator)、ゲームタイトル側で EventData の中身を参照しないのであれば default で構いません。

この変更により、たとえば『Anjin非公式ファンブック』7.4.2で紹介している RandomClickOperator のようなカスタムオペレーターにおいて、レイキャストを通した座標を引き継いでクリックできるようになります。

IOperator.OperateAsync() にログおよびスクリーンショット出力の責務が移動

v0.14 までは呼び出し元(たとえば Monkey クラス)がログおよびスクリーンショットの出力を担っていましたが、 v0.15.0 以降はオペレーターの責務となります。

この変更は、将来の機能追加への布石です。 ドラッグ操作などのオペレーターや、操作座標をGameViewにオーバーレイしてスクショに収めることを想定しています。

なお、この変更に伴ない OperateAsync() の引数に ILoggerScreenshotOptions が追加されています。

IsIgnored() および IsReachable() がインスタンスメソッド化

モンキーテストで無視するオブジェクトを判断する IsIgnored() とオブジェクトがユーザー操作可能かを判断する IsReachable() が、static 関数からインスタンスメソッドに変更されました。

それぞれ、IIgnoreStrategy.IsIgnored()IReachableStrategy.IsReachable() になります。 デフォルト実装も提供されています。

この変更により、各ストラテジは状態を持てるようになります。

IReachableStrategy および GameObjectFinder のシグネチャ変更

IReachableStrategy.IsReachable() が、out パラメータで RaycastResult を返すようになりました。

また GameObjectFinderFindByNameAsync() および FindByPathAsync() メソッドの戻り値の型が GameObject から GameObjectFinderResult に変更されました。 GameObjectFinderResult は、GameObjectRaycastResult を保持する struct です。

この変更によって得られるようになった RaycastResult は、前述 IOperator.OperateAsync() の第二引数に渡されることを想定しています。

コード例

v0.14以前

var finder = new GameObjectFinder();
var buttonObject = await finder.FindByNameAsync("StartButton", reachable: true, interactable: true);
var buttonComponent = buttonObject.GetComponent<Button>();
var clickOperator = buttonComponent.SelectOperators<IClickOperator>(_operators).First();

// ここでスクリーンショット撮影・ログ出力(呼び出し側の責務)

await clickOperator.OperateAsync(buttonComponent);

v0.15.0以降

var finder = new GameObjectFinder();
var result = await finder.FindByNameAsync("StartButton", reachable: true, interactable: true);
var buttonObject = result.GameObject;
var clickOperator = buttonObject.SelectOperators<IClickOperator>(_operators).First();

await clickOperator.OperateAsync(buttonObject, result.RaycastResult);

関連

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

*1:近い将来、UI Test Helper に改名予定です

*2:major version上げなかったけれどv0なのでゆるして

Monkey Test Helper パッケージのトラブルシュート

Unity上でオブジェクトベースのモンキーテストを実行できる Monkey Test Helper パッケージ*1のトラブルシュート資料を和訳しました。 対応バージョンは 0.14.0 です。

Monkey Test Helper は、オートパイロットフレームワーク AnjinUGUIMonkeyAgent の内部実装にも使われています。

Monkey

TimeoutExceptionがスローされた

次のメッセージを伴なう TimeoutException がスローされたとき、操作可能な GameObject がSceneに1つもない状態が5秒間続いたことを示します。

Interactive component not found in 5 seconds

デフォルト実装では*2、次の要件をすべて満たす GameObject が1つもないことを示します。

  • Selectable を継承かつ interactable プロパティが true であるか、EventTrigger か、IEventSystemHandler インタフェースを実装したコンポーネント
  • IgnoreAnnotation コンポーネントがアタッチされていないこと
  • Camera.main からピボット座標へのレイキャストが通ること(ほかのオブジェクトで隠されていないこと)

どの条件で対象外とされたかを知るには、バーボーズログ(後述)を有効にします。

なお、タイムアウトまでの秒数は、 MonkeyConfig.SecondsToErrorForNoInteractiveComponent で指定できます。 この機能を無効にするには 0 を指定します。

InfiniteLoopExceptionがスローされた

次のメッセージを伴なう InfiniteLoopException がスローされたとき、指定されたバッファ長内で繰り返し操作が検出されたことを示します。

Found loop in the operation sequence: [44030, 43938, 44010, 44030, 43938, 44010, 44030, 43938, 44010, 44030]

上記メッセージでは、パターン [44030, 43938, 44010] がループしています。 数字は、操作された GameObjectインスタンス ID です。

検出可能な繰り返しパターンの最大長は、バッファ長の半分です。 バッファ長は MonkeyConfig.BufferLengthForDetectLooping で指定できます。 この機能を無効にするには 0 を指定します。

この例外は、Monkey Test Helper v0.15.0で追加されました。Anjinには v1.9で反映予定です。[3/8追記]

操作ログ

UGUIClickOperator operates to StartButton (UGUIMonkeyAgent01_0001.png)

このログは、オペレーター UGUIClickOperator が、StartButton という名前の GameObject を操作する直前に出力されます。 "UGUIMonkeyAgent01_0001.png" は、操作直前のスクリーンショットのファイル名です。

スクリーンショットは、MonkeyConfigScreenshots を設定すると撮影されます。

バーボーズログ

詳細なログは、MonkeyConfigVerbose に true を設定すると出力されます。

抽選対象
Lottery entries: [
  StartButton(30502):Button:UGUIClickOperator,
  StartButton(30502):Button:UGUIClickAndHoldOperator,
  MenuButton(30668):Button:UGUIClickOperator,
  MenuButton(30668):Button:UGUIClickAndHoldOperator
]

各エントリのフォーマットは「GameObject 名(インスタンスID):コンポーネントの型:オペレーターの型」です。

このメッセージは、Monkeyが操作するオブジェクトおよびオペレーターの抽選対象をすべて出力しています。 uGUI 互換コンポーネントかつ interactable プロパティが true であるものです。 この段階では IsIgnore および IsReachable による判定は行われていません。

なお、この時点で抽選対象となるオブジェクトがひとつもないときは、次のメッセージが出力されます。

No lottery entries.
無視されたGameObject

抽選した GameObject が無視するように指定されたもの(デフォルトでは IgnoreAnnotation コンポーネントがアタッチされたもの)であったとき、次のメッセージを出力して再抽選されます。

Ignored QuitButton(30388).
ユーザーが到達不可能なGameObject

抽選した GameObject がユーザー到達不可能(デフォルトでは Camera.main からピボット座標へのレイキャストが通らないもの)であったとき、次のメッセージを出力して再抽選されます。

Not reachable to CloseButton(-2278), position=(515,-32). Raycast is not hit.

もしくは

Not reachable to BehindButton(-2324), position=(320,240). Raycast hit other objects: [BlockScreen, FrontButton]

前者は画面外など、後者はほかのオブジェクトによってピボット座標が隠されている状態です。 レイキャストを送る座標は ScreenOffsetAnnotation などのアノテーションコンポーネントでアレンジできます。

操作可能なGameObjectがひとつもない

操作可能なGameObjectがひとつもなかったとき、次のメッセージが出力されます。 この状態が一定時間続くと TimeoutException がスローされます。

Lottery entries are empty or all of not reachable.

GameObjectFinder

TimeoutExceptionがスローされた

名前が一致するものが見つからない

指定された名前を持つ GameObject が見つからなかったとき、次のメッセージを伴なう TimeoutException がスローされます。

GameObject `Target` is not found.
パス不一致

指定された名前を持つ GameObject のパス(Sceneのヒエラルキー)が一致しないとき、次のメッセージを伴なう TimeoutException がスローされます。

この判定は FindByPathAsync メソッドでのみ行われます。

GameObject `Target` is found, but it does not match path `Path/To/Target`.
ユーザー到達不可能

指定された名前を持つ GameObject がユーザー到達不可能(デフォルトでは Camera.main からピボット座標へのレイキャストが通らないもの)であったとき、次のメッセージを伴なう TimeoutException がスローされます。

この判定を行なうか否かは FindByNameAsync および FindByPathAsync の引数 reachable で指定できます。デフォルトは true(判定する)です。

GameObject `Target` is found, but not reachable.

詳細なログが必要な場合は、ILogger インスタンスGameObjectFinderコンストラクターに渡してください。

操作不可能

指定された名前を持つ GameObject が操作不可能(uGUI 互換コンポーネントでない、もしくは interactable プロパティが false)であったとき、次のメッセージを伴なう TimeoutException がスローされます。

この判定を行なうか否かは FindByNameAsync および FindByPathAsync の引数 interactable で指定できます。デフォルトは false(判定しない)です。

GameObject `Target` is found, but not interactable.

関連

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

*1:近い将来、UI Test Helper に改名予定です

*2:ストラテジパターンを採っているので、カスタムUIフレームワークを使っているタイトルでは判定関数を挿し替えできます