やらなイカ?

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

メンテナンス不要でチュートリアルを突破するAnjinのオートパイロット設定

Anjin v1.3で追加された機能を使って、スマートフォンゲームによくあるアウトゲームのチュートリアル(次に押せるボタンが限定される系)を自動で突破するオートパイロット設定の方法を紹介します。

Anjinパッケージのインストール

Anjinパッケージ(com.dena.anjin)は、株式会社ディー・エヌ・エーが開発・OSSとして公開しているUnity向けオートパイロットフレームワークです*1。 Unityエディタ上で動作させることを前提とし、また基本的な設定をすべてUnityエディタ上で完結できます。 本記事の事例もノーコードです。

github.com

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

openupm add com.dena.anjin

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

インストールしたら、.gitignoreなどで次のパスをトラッキングしないよう設定しておきましょう。

/Assets/AutopilotState.asset*

チュートリアルを突破するオートパイロット設定

最小構成として、Agentの設定を2つと、それを統括するオートパイロット設定を作成します。

uGUI Monkey Agentの設定

uGUI Monkey Agentは、uGUIで構築されたUI画面をでたらめに操作する、いわゆるモンキーテストを行なうためのAgentです。 本記事ではこれをチュートリアルの操作に利用します。チュートリアル中、ユーザが実際に操作可能なUI要素以外はブロックされているのであれば、「操作できるものを操作する」だけでチュートリアルは進行するはずです*2

Projectウィンドウで右クリック > Create > Anjin > uGUI Monkey Agent でAgent設定ファイルが作られます。 これをInspectorウィンドウで開き、動作設定を行ないます。

細かくは適用するプロジェクトによりますが、Lifespacn Sec は0以外*3チュートリアルを突破できるまでの時間を余裕を持って指定します。 また Secs Searching Components には、操作できるUI要素が1つもない時間を何秒検出したらエラー(進行不能)と判断するかを指定します。スキップできないムービー再生や演出がある場合は考慮が必要です。

なお、MonkeyAgentに操作されたくないUI要素*4がある場合は、IgnoreUGUIMonkey コンポーネントを追加することで操作を避けることができます。

Time Bomb Agentの設定

Time Bomb(時限爆弾)Agentは、Anjin v1.3で追加されたAgentです。 別のAgentを内包して動作させ、そのAgentが動作を終えるまでに指定した「解除メッセージ」がログ出力されなければエラー終了するAgentです。

Projectウィンドウで右クリック > Create > Anjin > Time Bomb Agent でAgent設定ファイルが作られます。 これをInspectorウィンドウで開き、動作設定を行ないます。

Agent には、先に作ったMonkey Agentの設定ファイルを指定します。 Defuse Message には、チュートリアルを完遂したときにログ(UnityEngine.Debug.Log)出力している文字列*5を指定します。正規表現が使用できます。

Tutorial Monkey Agentは40秒間動作する設定です。40秒以内に解除メッセージ「チュートリアル突破!」がログに出力されればTime Bomb Agentは正常終了、40秒経過してTutorial Monkey Agentが終了してしまうとエラーとなります。

オートパイロット設定

最後にオートパイロット本体の設定です。

Projectウィンドウで右クリック > Create > Anjin > Autopilot Settings でオートパイロット設定ファイルが作られます。 これをInspectorウィンドウで開き、動作設定を行ないます。

ここでは、Sceneに対して、そのSceneを操作するAgentを割り当てます。 まず、最初に表示されるTitle sceneにはTitle To Tutorial Agentという名前のuGUI Playback Agentを指定し*6、TitleからHomeに遷移するよう設定しています(詳細は割愛します)。

続いて Home scene(ここでチュートリアルが進行する想定)に先のTime Bomb Agentを指定して、チュートリアルを突破させます。

設定を終えたら、Inspectorウィンドウを下までスクロールして Run ボタンをクリックするとその場で再生モードに切り替わり、オートパイロットが実行されます。

なお、図の設定ではチュートリアル突破後、Lifespan secに設定された60秒までなにもしないで終了します。 実際のプロジェクトでは、Time Bomb Agentの後にさらにuGUI Playback Agentで各画面を巡回するシナリオを組んだり、uGUI Monkey Agentでモンキーテストを実行するなりするとよいでしょう。

その他の新機能

Anjin v1.2およびv1.3で追加された機能を簡単に紹介します。

Play Modeテストからのオートパイロット実行

Inspectorウィンドウやコマンドラインからでなく、Play Modeテストコード内でオートパイロットを起動し、終了を待つことができるようになりました。 次のように使います。

[Test]
public async Task LaunchAutopilotFromTest()
{
  await LauncherFromTest.AutopilotAsync("Assets/Path/To/AutopilotSettings.asset");
}

uGUI Monkey Agentのスクリーンショット撮影オプション

uGUI Monkey Agentに、操作ごとにスクリーンショットを撮影するオプションが追加されました。 Inspectorウィンドウで有効化できます。

デフォルトでは、Application.persistentDataPath 下の "/TestHelper/Screenshots/" に Agentの名前+連番のファイルが保存されます。

batchmode起動サポート

CIなどでコマンドラインから起動するとき、-batchmodeを指定しても動作するようになりました。 Unityのbatchmode(ヘッドレスモード)には様々な制限がありますが、それを回避するハック(下記)によるものです。

参考

swet.dena.com

swet.dena.com

www.nowsprinting.com

*1:筆者がDeNA在籍時代に作ったものですが、すでにメンテナではありません。本記事で紹介している新機能は、現在お手伝いしているプロジェクトで使いそうなものをPull Requestし採用されたものです

*2:進行しない場合、専用のAgentを実装する必要があります

*3:0は無制限に動作します

*4:課金要素、広告表示、外部アプリ起動、デバッグメニューなど

*5:なにも出力していなければ、プロダクト側に手を加える必要があります

*6:uGUI Playback Agentは、マウス操作を記録してその通りに操作を再現するAgent

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は近い将来のバージョンで変わるかもしれません

#C103 ありがとうございました

コミックマーケット103、無事終わりました。 ご購入いただいた方々、また、スペースにお立ち寄りいただいた方々、準備会やインフラの方々、そしてお手伝いいただいたきみかさん、本当にありがとうございました。

毎回楽しいのですけれど、今回特に楽しくて(サークルとしても買う側としても)、個人的には色々あった一年でしたが、よい締めくくりになりました。

色々お話できたりしたのですけれど、今回さまざまな国のかた*1がスペースに立ち寄ってCopilotとかTDDの話をしていったのが印象的でした。 日本語は読めないけど電子版だと機械翻訳して読めるから嬉しいとか、現金よりカードで決済したいとかも直接聞けて、次回サークル参加するとき考慮したいなと。

おかげさまで多めに刷った新刊『GitHub CopilotとのペアプロTDDでつくるローグライクRPG』も、閉会15分前くらいに完売しました。 新刊は電子版のみ、既刊は電子・物理ともBOOTHで頒布しています。

ikagoya.booth.pm

頒布した書籍の詳細・フォローアップは次の記事を参照してください。

www.nowsprinting.com

www.nowsprinting.com

www.nowsprinting.com

来年は放置気味のOSSをメンテしたりVision Proで遊んだりもしたいので執筆はどうするか未定ですが、なにかネタが沸いたら書くのではないかと。

*1:確認したわけではないですが

GitHub CopilotとのペアプロTDDでつくるローグライクRPG

GitHub Copilotはそのまま使っても便利ですが、その特性を活かす共同作業の形として「ペアプロTDD」がお勧めです。 実際にCopilotとのペアプロTDDで「ローグライクRPG」を開発していく過程を紹介する同人誌を、 コミックマーケット103の2日目(日曜)東U44b「いか小屋」で頒布します。

[12/31追記]電子版(PDF)をBOOTHで頒布しています

ikagoya.booth.pm

想定読者

何らかの言語でのプログラミング経験のあるソフトウェアエンジニア(プログラマー)を想定しています。

サンプルコードにはゲームエンジンUnityおよびC#言語を使用していますが、ゲームエンジン固有の機能には縛られないロジック部分にフォーカスしていますので、Unityの知識は不要です。 Unity固有の機能については、本書を読み進めるのに問題がない程度の解説をつけています。

また、「人間がテストコードを書けばCopilotがプロダクトコード(ゲーム本体の実装)を書いてくれる」というコンセプトなので、ぜひテストエンジニアの方にもお試しいただきたいです。

目次の紹介

第1章 GitHub Copilot

Copilot*1の利用方法、IDEへのプラグインインストールと操作方法*2、Coilotの仕組みを紹介しています。

第2章 ペアプログラミングとTDD

人間が行なうペアプログラミングテスト駆動開発(TDD)それぞれの簡単な紹介と、本書で提唱する「CopilotとのペアプロTDD」について紹介しています。

CopilotとのペアプロTDDには、Copilotの提案を受け入れる判断基準になるテストコードが得られるだけでなく、Copilotから精度の高い提案を引き出す効果があります。 ただし(まだ)万能な方法ではないため、注意すべき点も掲載しています。

第3章 開発の準備

本書サンプルプロジェクトで使用しているゲームエンジンUnityのインストールとプロジェクトの設定方法について、簡単に紹介しています。

第4章 ダンジョンの自動生成

ローグライクRPGの特徴であるランダム生成のダンジョンマップを、CopilotとのペアプロTDDで作っていきます。

本章では、実現したい仕様をテストコードに書く、それをもとにCopilotがプロダクトコードを提案、テストを実行して成功させる、というサイクルをステップ・バイ・ステップで解説しています。

また途中に、Copilotからよりよい提案を受けるためのTipsを盛り込んでいます。

第5章 プレイヤーキャラクター

ダンジョンにプレイヤーキャラクターを表示し、キーボード操作で移動させるまでをCopilotとのペアプロTDDで作っていきます。

それだけ?

「キャラが動くだけでローグライクRPGと呼べるのか」それはごもっともですが、ここまでです。

締切的な問題もありましたが、すでに全体で100ページ、4・5章あわせて60ページあります。 ここまでにペアプロTDD、CopilotのTips、パラメタライズドテスト、ランダム性のテスト、非同期処理のテスト、テストダブル、と書くべきことは書き尽くしました。

書き続けても、淡々とローグライクRPGの実装を進めるだけになり、書くのも読むのもモチベーションが続かないと判断しました*3

ただ、サンプルプロジェクトは今後時間を見つけて実装を進めていくつもりです*4

表紙について

きみかさん(@kimika127)にユニティちゃんを描いていただきました。

なお、背後の黒いのはメンダコです。何かに似て見えるかもしれませんがメンダコです。

C103

2日目(日曜)東U44b「いか小屋」でお待ちしております。 東2ホールの真ん中あたりです。

既刊在庫も持っていきます。

www.nowsprinting.com

www.nowsprinting.com

信販売・電子版について

[12/31追記]電子版(PDF)をBOOTHで頒布しています

ikagoya.booth.pm

*1:個人向けのCopilot Individualのみ解説しています

*2:本書で使用するインライン候補とCopilot Chatのみ解説しています

*3:続刊することは無く、もし改版することがあればもう少し続きを書くかもしれません

*4:ただしCopilotでなくJetBrains AI Assistantに切り替えることになりそうです。比較もしたいので

Unity NuGetとNuGetForUnity

本記事はUnity Advent Calendar 2023 シリーズ2 21日目の記事です。

UnityでNuGet Galleryに公開されている.NET向けパッケージ(以下NuGetパッケージ)を使う場合、nugetコマンドでは管理できないためパッケージ(依存パッケージもすべて)を自分でダウンロードしてDLLを取り出し、Unityプロジェクト下に配置する必要があります。

その手間を省くためのソリューションとして代表的なものに、Unity NuGetとNuGetForUnityがあります。

Unity NuGetのほうが取り扱いは楽な反面、利用できるパッケージに制限があるなど、目的によってはNuGetForUnityのほうが便利なケースもあります。 双方の違いも含めて紹介します。

Unity NuGet

Unity NuGetは、Unityで利用可能なNuGetパッケージをUPMパッケージとしてラップした状態で提供してくれる、UPMパッケージレジストリです。

レジストリは公開しているものをそのまま利用することも、自分の環境やDockerコンテナ上に立ち上げることもできます(コードはオープンソース)。

github.com

Unity 2019.1以降で利用できます。

公開レジストリの利用方法

Package Managerウィンドウを使用する場合

Unityエディター上のPackage Managerウィンドウを使用する場合、次の手順でNuGetパッケージをインストールできます。

  1. UnityエディターのメニューからEdit > Project SettingsでProject Settingsウィンドウを開き、Package Managerタブを選択
  2. Scoped Registriesの下にある+ボタンをクリックし、NameURLScope(s)を入力してSave
  3. Window > Package ManagerでPackage Managerウィンドウを開き、左上にあるレジストリ選択ドロップダウンでMy Registriesを選択
  4. 目的のNuGetパッケージを探して、Installボタンをクリック

Scoped Registriesの設定内容は次のとおりです。

  • Name: Unity NuGet(任意)
  • URL: https://unitynuget-registry.azurewebsites.net
  • Scope(s): org.nuget(Package Manager上の表示を絞り込みたければ細かく指定)

これで、NuGetパッケージの依存関係解決も行われ、必要なDLLがPackages下にUPMパッケージとしてインストールされます。

なお、Unity 2022.2未満の場合、次の操作でAssembly Version Validationを無効化する必要があります(2022.2以降はデフォルトが無効)。

  1. Project Settings > Player > Other Settings > Configurationを開き、Assembly Version Validationチェックボックスをoff

openupmコマンドを使用する場合

openupmコマンドでも次のようにレジストリを指定してインストールできます。

openupm add --registry https://unitynuget-registry.azurewebsites.net パッケージのID

こちらもAssembly Version Validationの設定は必要です。

利用できるパッケージ

Unity NuGetで利用できるパッケージは登録制です。 registry.json ファイルに書かれているパッケージのみ利用できます。

.NET Standard 2.0と2.1のDLLが含まれるパッケージの場合、DLLのmetaファイルにConstraintsを設定してくれるので気にすることなく利用できるはずです。

Roslynアナライザも利用できますが、アナライザをビルドしたときのMicrosoft.CodeAnalysis.CSharpのバージョンに依存するため、インストールするバージョンを指定する必要がある場合もあります。 詳しくは次の記事を参照してください。

www.nowsprinting.com

パッケージの追加方法

利用したいパッケージがregistry.jsonにない場合、追加するPull Requestを送ります。 ただし追加できるパッケージは.NETStandard2.0をサポートしている必要があります。 また依存パッケージがある場合は依存関係すべてを追加する必要があります。

なお、Unityエディター上でしか動作しないパッケージにはdefineConstraints: ["UNITY_EDITOR"]を、Roslynアナライザの場合は"analyzer": trueを追加することを忘れないでください。

次のコマンドでテストを実行すると、依存関係も含めたバリデーションが行われます。

dotnet test src -c Release

ローカル環境でレジストリを起動する

Pull Requestを送る前に、手元でレジストリを起動して確認することもできます。 次のコマンドで起動できます。

cd examples/docker
docker-compose up

ポート5000でアクセスできます。つまりレジストリURLはhttp://localhost:5000です。 またパッケージIDのprefixがorg.nugetでなく、デフォルトではorg.customになります(docker-compose.ymlで変更できます)。

NuGetForUnity

NuGetForUnityは、Unityプロジェクトにインストールして使用するオープンソースのエディター拡張です。 Unityエディター上で直接NuGet GalleryにあるNuGetパッケージをインストールできます。

一時期メンテナンスが止まっていましたが、2023年のはじめごろ再開されました。

github.com

NuGetForUnityのインストールと設定

まずNuGetForUnity自体をUPMパッケージとしてインストールします。

Package Managerウィンドウを使用する場合

Scoped Registriesにhttps://package.openupm.comを追加してopenupmからインストールできます。 細かい手順は割愛します。

openupmコマンドを使用する場合

次のコマンドでインストールできます。

openupm add com.github-glitchenzo.nugetforunity

バージョン管理システムのトラッキング除外設定

NuGetForUnityは、ダウロードして展開したNuGetパッケージの中身を/Assets/Packages/下に置きます。 リポジトリにDLLを入れたくない場合、これを.gitignoreなどでバージョン管理システムのトラッキングから除外しておきます。

ただし、プロジェクトにインストールしているNuGetパッケージの情報は/Assets/packages.configに保存されます。 ケースインセンシティブなOSで/Assets/Packages*と書いてしまうとこちらも除外されますので注意してください。

推奨する.gitignoreの記述は次の記事を参考にしてください。

www.nowsprinting.com

NuGetパッケージのインストール

UnityエディターのメニューからNuGet > Manage NuGet PackagesでNuGet For Unityウィンドウが開きます。 インストールしたいパッケージを検索してInstallで依存関係含めインストールされます。

NuGetForUnityも、Roslynアナライザーに対応しています*1。 Unityプロジェクトで動作する条件についてはUnity NuGetと同様です。

パッケージのリストア

/Assets/Packages/下をバージョン管理していない場合でも、Unityエディターを起動したときに自動的にリストアしてくれます。 ただし、リストア前の時点でコンパイルエラーがあると実行されません。

継続的インテグレーション(CI)などではUnity起動前にリストアをCLIで実行するNuGetForUnity.Cliが利用できます。 dotnetサブコマンドとしてインストールして実行できます*2

www.nuget.org

まとめ

メジャーなパッケージを簡単に使うならUnity NuGetがおすすめです。

Unity NuGetで提供されていないもの、DLLの設定を変えたい、また自作のパッケージをテストするといった用途であれば、NuGetForUnityをおすすめします。

宣伝

年末のコミックマーケット103(C103)にサークル「いか小屋」で出ます。 2日目(日曜日)東U44b、東2の真ん中あたりです。

新刊は『GitHub CopilotとのペアプロTDDでつくるローグライクRPG』。 人間がテストコードを書き、GitHub Copilotがプロダクトコード生成という分担でのペアプログラミングテスト駆動開発(TDD)でローグライクRPGをつくろう!という本です。

既刊の在庫も持っていきます。

www.nowsprinting.com

www.nowsprinting.com

*1:私がやりました

*2:最近追加されたものでまだちゃんと検証していないのですが紹介だけしておきます

自動テストにおける「目視確認」をポジティブにこなす魔法

本記事はソフトウェアテスト Advent Calendar 2023 2日目の記事です。

自動テストを書いていて、テストコードで検証目的を達成できず「目視で確認」とするのは負けた気持ちになりますし、テストを実行するオペレーターやコントリビューターのモチベーションも下がります。 特に、技術的には可能であってもROIが悪くて予算・納期的に不可能なときはなおさらです。

そんなとき、目視確認を指示するコメントに次の言葉を添えてみましょう。 たちまち罪悪感は消え*1、またこれを見たオペレーターも高いモチベーションで取り組んでくれることでしょう*2

日本語の例

  • 君の目で確かめてくれ!
  • 目撃せよ!

実際のテストコードに適用するとこうなります*3

[Test]
public void TakeScreenshot_Gizmoあり_スクリーンショットにGizmoが写っていること()
{
  _sut.TakeScreenshot(gizmo: true);

  Assert.That(new FileInfo(path), Has.Length.GreaterThan(FileSizeThreshold));
  // Gizmoは写っているか? ぜひ君の目で確かめてくれ!
}

英語の例

  • See for yourself! (君自身で確かめてくれ!)
  • See it with your own eyes! (君の目で確かめてくれ!)
  • Be a witness! (目撃者になれ!)
  • Don’t just read a test report. Witness the drama unfold before you! (テストレポートを読むだけではだめだ。目の前でドラマが展開するのを目撃するのだ!)

1960年代の番宣とかで言ってそう…ということでChatGPT(GPT-4 with DALL・E)に生成したもらった画像がこちら。

受け取り方には個人差がありますし、コンテキストにもよるでしょう*4

実際のプロジェクトで使ってみたらどうなるか、それは君の目で確かめてくれ!

宣伝

年末のコミックマーケット103(C103)にサークル「いか小屋」で出ます。 2日目(日曜日)東U44b、東2の真ん中あたりです。

新刊は『GitHub CopilotとのペアプロTDDでつくるローグライクRPG』。 人間がテストコードを書き、GitHub Copilotがプロダクトコード生成という分担でのペアプログラミングテスト駆動開発(TDD)でローグライクRPGをつくろう!という本です。

既刊の在庫も持っていきます。

www.nowsprinting.com

また、新刊と同名のセッションを来週のソフトウェアテスト自動化カンファレンス2023でやります。 まだ参加できますので、ぜひお申し込みください!

testautomationresearch.connpass.com

*1:個人差があります

*2:個人差があります

*3:最低限、一定サイズ以上のファイルが生成されていることだけはアサーションしています

*4:私も複数回出てきたらイラッとすると思います…

Unity Test Frameworkで利用できるカスタム属性の実装方法

Unity Test Framework および NUnit では、テストコードに付与できるさまざまな属性(attribute)が提供されています。 また拡張ポイントであるインターフェイスを実装することで、プロジェクト独自のカスタム属性を定義してテストコードで使うこともできます。

本記事では、オープンソースの Test Helper パッケージにいくつかの汎用的なカスタム属性を実装するにあたって得た知見を紹介します。

github.com

本記事で紹介するインターフェイスは次の4つです。 先頭から3つが NUnit 由来のもの、最後のひとつが Unity Test Framework 独自のものです。

検証環境は次のとおりです。

  • Unity 2022.3.11f1 *1
  • Unity Test Framework v1.3.4 *2

カスタム属性実装の基本

カスタム属性は次のように定義します。 NUnitAttribute クラスを継承し、役割に応じたインターフェイスを実装(ここでは IApplyToContext)しています。

public class MyAttribute : NUnitAttribute, IApplyToContext
{
    private readonly string _message;

    public MyAttribute(string message) => this._message = message;

    public void ApplyToContext(ITestExecutionContext context)
    {
        Debug.Log(message);
    }
}

この属性を次のようにテストメソッドに付与すると*3、テストメソッドの実行直前に Test start! とログ出力されます。

[TestFixture]
public class MyTestClass
{
    [Test]
    [MyAttribute("Test start!")]
    public void MyTestMethod()
    {
        Assert.That(actual, Is.EqualTo(expected));
    }
}

インタフェースの解説

4つのインターフェイスの使用方法を順に解説します。

IApplyToTest

IApplyToTest インターフェイスを実装した属性は、テストランナーが実行するテストを決めるときに ApplyToTest メソッドが呼ばれます。 ここでテストコンテキストの RunState を変化させることで、テストの実行可否を制御できます。

次のコードは、付与されたテストを実行から除外するカスタム属性の例です。

public class MyIgnoreAttribute : NUnitAttribute, IApplyToTest
{
    void IApplyToTest.ApplyToTest(Test test)
    {
        test.RunState = RunState.Ignored; // RunStateを変更して実行されないようにする
    }
}

Unity Test Framework の Category 属性、Ignore 属性などはこのインターフェイスを実装しています。

Test Helperパッケージでは、次のカスタム属性で使用しています。

実装にあたっては、次の点に注意してください。

  • 属性の中で例外を発生させてはいけません。Test Runnerウィンドウで(スキップでなく)エラーと表示され、テスト実行結果の件数からも除外されます*4
  • 実行中のテストコンテキストを NUnit.Framework.TestContext から取得できません。引数で渡される test インスタンスを使います
  • テストメソッド実行直前だけでなく、Test Runnerウィンドウの表示更新の契機で呼ばれます。そのため、プロジェクトの設定を一時的に変更するといった用途では使用できません(変更を戻す契機がないため)

IApplyToContext

IApplyToContext インターフェイスを実装した属性は、付与されたテストの実行前に ApplyToContext メソッドが呼ばれます。 引数で渡されるテストコンテキストに変更を加えたり、何かしらの前処理を実行できます(ただし同期処理に限る)。

次のコードは、付与されたテスト実行直前にテスト名称をログ出力するカスタム属性の例です。

public class MyLoggingAttribute : NUnitAttribute, IApplyToContext
{
    public void ApplyToContext(ITestExecutionContext context)
    {
        Debug.Log($"{context.CurrentTest.Name} start!");
    }
}

Unity Test Framework の Timeout 属性はこのインターフェイスを実装しています。

Test Helperパッケージでは、次のカスタム属性で使用しています。

実装にあたっては、次の点に注意してください。

  • 属性の中で例外を発生させてはいけません。テストが無限ループします
  • 実行順は、OneTimeSetUpより後、UnitySetUp および SetUp よりも前です

ICommandWrapper

ICommandWrapper は直接実装するのではなく、サブインターフェイスである IWrapSetUpTearDown もしくは IWrapTestMethod を実装します。

テスト実行の前後に処理を行なうための属性ですが、Unityでは次の制限事項があります。 重めの問題があるため、Unityでは後述の IOuterUnityTestAction を使用することをおすすめします*5

  • テストメソッドに付与したときしか動作しません(テストクラス、テストアセンブリはNG)
  • 同期処理しか書くことができません
  • 属性の中で例外を発生させてはいけません。テストが無限ループします
  • 非同期テスト(asyncおよびUnityTest)に付与すると、実行時エラーや無限ループを引き起こします
  • IWrapSetUpTearDown を付与したテストに async SetUp メソッドがあると、無限ループを引き起こします
  • IWrapSetUpTearDown の実行順は、OneTimeSetUp および UnitySetUp より後、SetUp よりも前です *6
  • IWrapTestMethod の実行順は、SetUp の後、テストメソッドの前です
  • 引数で渡される TestCommand から取得できるテストコンテキストは、テストケースではなくテストフィクスチャ(テストクラス)のものです

Unity Test Framework の MaxTime 属性、Repeat 属性、Retry 属性は IWrapSetUpTearDown を実装しています。つまり上記の問題があるということです。 詳しくは次の記事を参照してください。

www.nowsprinting.com

IOuterUnityTestAction

IOuterUnityTestAction は、NUnit由来のものでなくUnity独自のインターフェイスです。 このインターフェイスを実装した属性は、付与されたテストの実行前に BeforeTest メソッド、実行後に AfterTest メソッドが呼ばれます。

BeforeTestUnitySetUp より前に呼ばれる、例外を投げても無限ループしないでテストが失敗してくれるなど、とても高品質なインターフェイスです*7

次のコードは、付与されたテスト前後にテスト名称をログ出力するカスタム属性の例です。

public class MyLoggingAttribute : NUnitAttribute, IOuterUnityTestAction
{
    public IEnumerator BeforeTest(ITest test)
    {
        Debug.Log($"{test.Name} start!");
        yield return null;
    }

    public IEnumerator AfterTest(ITest test)
    {
        if (TestContext.CurrentTestExecutionContext.CurrentResult.PassCount > 0)
        {
            Debug.Log($"{test.Name} success!");
        }
        else
        {
            Debug.Log($"{test.Name} failure!");
        }
        yield return null;
    }
}

Test Helperパッケージでは、次のカスタム属性で使用しています。

以下、補足です。

  • テストメソッドに付与したときしか動作しません(テストクラス、テストアセンブリはNG)
  • 実行順は、OneTimeSetUp -> BeforeTest -> UnitySetUp -> SetUp -> Test -> TearDown -> UnityTearDown -> AfterTest -> OneTimeTearDown
  • BeforeTest 内で何かしらの例外をスローすると、テスト本体は実行されず失敗扱いとなります
  • AfterTestは、テストの成否に関わらず必ず実行されます
  • AfterTest 内で例外をスローすると、テスト本体が成功していてもテストを失敗させることができます
  • AfterTest 内でテストの成否を判定するには、上例のようにテストコンテキストの PassCountFailCount を参照します

カスタム属性を実装するにあたってのTips

いくつか紹介します。

属性を付与できるシンボルを限定したい

属性の定義に AttributeUsage 属性を付与することで、属性を付与できるシンボルを限定できます。 例えばメソッドにしか付与できないようにするには、次のように書きます。

[AttributeUsage(AttributeTargets.Method)]
public class MyAttribute : NUnitAttribute, IOuterUnityTestAction {}

属性が付与されたシンボルを検索したい

テストに限らず、属性が付与されたシンボルを収集して処理したい場合、Unityエディター内ではリフレクションでなく TypeCache クラスのメソッドを使うと高速に処理できます。

次のコードは、LoadSceneAttribute が付与されたメソッドを返すメソッドの例です。

private static IEnumerable<LoadSceneAttribute> FindLoadSceneAttributesOnMethods()
{
    var symbols = TypeCache.GetMethodsWithAttribute<LoadSceneAttribute>();
    foreach (var attribute in symbols
                 .Select(symbol => symbol.GetCustomAttributes(typeof(LoadSceneAttribute), false))
                 .SelectMany(attributes => attributes))
    {
        yield return attribute as LoadSceneAttribute;
    }
}

テストから属性に情報を渡したい

属性のコンストラクタに引数を定義できます。 本記事の最初のコード例を参照してください。

なお、上例では属性のフィールドに値を保持していますが、テストコンテキストの Properties に保持することもできます(後述)。

また NUnitAttribute の代わりに PropertyAttribute も利用できます。

属性からテストに情報を渡したい

属性の処理内で得た何かしらの情報をテストメソッドに渡したい場合、テストコンテキストの Properties を使用できます。 次のように使用します。

情報のセット

public class PassesObjectAttribute : NUnitAttribute, IApplyToContext
{
    public void ApplyToContext(ITestExecutionContext context)
    {
        MyClass obj = // snip

        if (obj != null)
        {
            test.Properties.Add("PassesMyClass", obj); // nullをセットしないよう注意
        }
    }
}

情報の取り出し

[TestFixture]
public class MyTestClass
{
    [Test]
    [PassesObject]
    public void MyTestMethod()
    {
        var obj = TestContext.CurrentTestExecutionContext.CurrentTest
            .Properties.Get("PassesMyClass") as MyClass;

        Assert.That(obj, Is.Not.Null);
    }
}

なお、プロパティに null をセットしてしまうと、バッチモードでテスト実行したときにテスト全体がエラーとなります。 テストレポート出力時に次の例外を処理されていないためです。

Uploading Crash Report
NullReferenceException: Object reference not set to an instance of an object
  at NUnit.Framework.Internal.PropertyBag.AddToXml (NUnit.Framework.Interfaces.TNode parentNode, System.Boolean recursive) [0x00054] in <956b82cfdef641c6bc6a0e5b19798f05>:0 
(snip)

その他の Unity Test Framework 拡張ポイント

本記事で紹介したカスタム属性以外にも、カスタム Comparer やカスタム制約といった拡張が可能です。 詳しくは『Unity Test Framework完全攻略ガイド』を参照してください。

www.nowsprinting.com

*1:テストは 2019 LTS, 2020 LTS, 2021 LTS, および 2023.1.16f1 でも実行しています

*2:正常系は最新のv1.3.9でも動作しますが、異常系の振る舞いが異なります

*3:テストクラス、テストアセンブリにも付与できます

*4:Test Frameworkのバグですが修正されないでしょう

*5:ただし IOuterUnityTestAction は IWrapSetUpTearDown の代替になりますが、IWrapTestMethod に代わるインターフェイスはありません

*6:UnitySetUp はほかにもドメインリロードで再実行されないなどの癖があります。どうして…

*7:冷静に考えると嫌味っぽいですが、検証したとき正直感動したのです…