最強のTipsが生まれるきっかけ
自分が現在開発に携わっているゲームでは、作中に多くのシナリオが含まれています。
その数はデータにして200を超えていますが、このデータが増えるにつれて、ある問題が発生しました。
それは、シナリオデータの数が多すぎて、不具合のあるデータがあることに気付けないということです。
(特定の選択肢を選ぶと、進行不能になるデータがいくつか見られました)
これに対処するため、**「全シナリオの動作チェックを行うUIのテストコードを書こう!」**と思ったのが、事の始まりです。
まさか、あんな苦肉の策を取ることになるとは、この時は考えてもいませんでした…
目次(Tips一覧)
自分がテストコードを書いて会得したTipsを紹介します。
ちなみに、最強のTipsは「6. テストの失敗が後続のテストにまで影響を与えないようにする」です。
それ以外は、まとも(のはず…)なTipsになります。
1. Play Modeテストで使うAttributeとその注意点
2. テスト時間を短縮する
3. 手軽にUIテストをできるようにする
4. テストの失敗をハンドリングする
5. コード側からTest Runnerを制御する
6. テストの失敗が後続のテストにまで影響を与えないようにする
7. CommandLineからTest Runnerを実行する
Tipsを紹介する前に
テストコードを書くにあたって、Unity上でテストできるというメリットから、Unityに標準で付属している**Test Runner**のPlay Modeテストを使用しています。
Play Modeテストはコルーチンを使って記述され、このコルーチンが最後までエラーを吐かずに終了すればテストが成功扱いになるという、とてもシンプルなものです。
public class ScenarioTest
{
[UnityTest]
public IEnumerator Run()
{
Debug.Log("テスト開始");
// シナリオの再生処理
yield return RunScenario();
Debug.Log("最後までいったのでテスト成功!!");
}
}
これはUnityエディタから実行できるようになっており、実行すると自動的にUnityがPlay状態になり、直後にコルーチンを実行する仕組みとなっています。
そしてもちろん、CommandLineからの実行にも対応しています。完璧ですね。
Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>
これから紹介するTipsは、このTest RunnerのPlay Modeテストを行う際のTipsとなります。
Tips
1. Play Modeテストで使うAttributeとその注意点
Test Runnerを使う際に覚えておきたいAttributeがあります。
また、Play Modeテストにおいて注意すべき点もあるので、それも交えて紹介します。
テストメソッド用
-
[UnityTest]
- テストしたいコルーチンに必ず付ける必要がある
-
[TestCase]
- テストケースを定義する
-
[TestCaseSource]
- テストケースをリストで定義する(動的に定義することができる)
-
[Timeout]
- テストのタイムアウト時間を指定する
public class ScenarioTest
{
// テストしたいコルーチンに必ず付ける必要がある
[UnityTest]
public IEnumerator Run()
{
yield return RunScenario();
}
// コルーチンに引数として値を渡すことができる(複数指定可)
// この例だと、RunWithTestCase("file_0001")、RunWithTestCase("file_0002")として扱われる
// 【注意】 コルーチンに[TestCase]を使う場合、ExpectedResult = null を記述しないとエラーになる
[TestCase("file_0001", ExpectedResult = null)]
[TestCase("file_0002", ExpectedResult = null)]
[UnityTest]
public IEnumerator RunWithTestCase(string file)
{
yield return RunScenarioWithFile(file);
}
// [TestCaseSource]に渡すリスト
public static IEnumerable FILES
{
get
{
foreach (var path in Directory.GetFiles("<path>"))
{
// 【注意】 [TestCase]同様、Returns(null) を記述しないとエラーになる
yield return new TestCaseData(path).Returns(null);
}
}
}
// [TestCase]をリストで管理できるようにしたもの(動的に定義したい場合などで有用)
// 各要素ごとを渡したテストとして扱われる
// Directory.GetFilesから取得できる文字列がfile_0001、file_0002とすると、
// RunWithTestCaseSource("file_0001")、RunWithTestCaseSource("file_0002")となる
[TestCaseSource("FILES")]
[UnityTest]
public IEnumerator RunWithTestCaseSource(string file)
{
yield return RunScenarioWithFile(file);
}
// テストのタイムアウト時間を指定する
// デフォルトだと30秒
// ms単位で指定(この例だと5分になる)
[Timeout(300000)]
[UnityTest]
public IEnumerator RunWithTimeout()
{
yield return RunScenario();
}
}
ちなみにUnityエディタ上ではこう見えています。
コールバック
-
[OneTimeSetUp]
- クラス内で最初のテストが実行される前に一度だけ呼ばれる
-
[SetUp]
- 各テストの最初に呼ばれる
-
[TearDown]
- 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
-
[OneTimeTearDown]
- クラス内で最後のテストが実行された後に一度だけ呼ばれる
public class ScenarioTest
{
// クラス内で最初のテストが実行される前に一度だけ呼ばれる
[OneTimeSetUp]
public void OneTimeSetUp()
{
Debug.Log("テスト開始");
}
// 各テストの最初に呼ばれる
[SetUp]
public void SetUp()
{
Debug.Log("file_0001 のテスト開始");
}
// 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
[TearDown]
public void TearDown()
{
Debug.Log("file_0001 のテスト終了");
}
// クラス内で最後のテストが実行された後に一度だけ呼ばれる
[OneTimeTearDown]
public void OneTimeTearDown()
{
Debug.Log("テスト終了");
}
}
なお、Test RunnerにはNUnitと呼ばれるテストフレームワークが使われているため、もっと知りたい方はNUnitのドキュメントを参照ください。
2. テスト時間を短縮する
Play Modeテストでは、UnityをPlay状態にしてテストを実行するため、最速でも実プレイと同じ速度しか出ません。
そこで、ゲーム内速度やFPSを上げることでスピードUPを図ります。
public class ScenarioTest
{
// クラス内で最初のテストが実行される前に一度だけ呼ばれる
[OneTimeSetUp]
public void OneTimeSetUp()
{
// テスト時間短縮のために高速化する
Application.targetFrameRate = 60;
Time.timeScale = 2.0f;
}
}
これだけで、テスト時間を大幅に削減することができます。
Play Modeテストならではの手法ですね。
3. 手軽にUIテストをできるようにする
シナリオの動作チェックのようなUIテストを行う場合、画面タップなどのUIの操作が必要となります。
ただ、これを自前で実装するのは手間になるため、すでにあるUnity UI Test Automation Frameworkというフレームワークを使用することをオススメします。
これを使えば、
public class ScenarioTest : UITest
{
[UnityTest]
public IEnumerator Run()
{
// targetという名前のボタンを押す
yield return Press("target");
// Scenarioシーンをロードする
yield return LoadScene("Scenario");
// targetというオブジェクトが登場するまで待機する
yield return WaitFor(new ObjectAppeared("target"));
}
}
といったことが簡単にできるようになります。
4. テストの失敗をハンドリングする
テストコードを書いていて、テストが失敗した場合のみ特定の処理をしたい、ということがあると思います。
もし、[TearDown]
で指定したメソッドに引数として情報が渡ってきたり、テスト失敗時に呼ばれるコールバックなどがあればよかったのですが、NUnitのドキュメントを見た限りなさそうでした。
ではどうするか。
Play Modeテストはエラーログが出れば失敗となるので、Unityのログメッセージを取得することでハンドリングできるようになります。
public class ScenarioTest
{
private List<string> _errorLogs = new List<string>();
// クラス内で最初のテストが実行される前に一度だけ呼ばれる
[OneTimeSetUp]
public void OneTimeSetUp()
{
// ログを残す
Application.logMessageReceived += Log;
}
public void Log(string logString, string stackTrace, LogType type)
{
// エラーログを保持しておく
if (type == LogType.Error || type == LogType.Exception)
{
_errorLogs.Add($"{logString}\n{stackTrace}\n");
}
}
[UnityTest]
public IEnumerator Run()
{
// 各テストの頭でクリアする
_errorLogs.Clear();
yield return RunScenario();
}
// 各テストの最後に呼ばれる(テストが失敗しても呼ばれる)
[TearDown]
public void TearDown()
{
// エラーログがあるならテスト失敗
if (_errorLogs.Count > 0)
{
Debug.Log("テスト失敗");
Debug.Log(string.Join("\n", _errorLogs.ToArray()));
}
}
}
こうすれば、失敗時のみ処理をすることができます。
5. コード側からTest Runnerを制御する
Test RunnerがUnityエディタやCommandLineから実行できる術を用意しているとしても、やはり自前で拡張したい時はあります。
そういった場合、コード側からTest Runnerを制御することになると思いますが、実はこれがかなり面倒です。
理由は単純。Test RunnerのAPIが公開されていないからです。
そのため、リフレクションを使って無理やり実行することになります。
public class ScenarioTestCommand
{
public static void Execute()
{
// テスト情報を追加する
var engineAssembly = Assembly.Load("UnityEngine.TestRunner");
var testFilterType = engineAssembly.GetType("UnityEngine.TestTools.TestRunner.GUI.TestRunnerFilter");
var testFilter = Activator.CreateInstance(testFilterType);
// テストの名前を使って実行する
// 実行するテスト名(メソッド名)は名前空間とクラス名も含めること
var testNamesField = testFilterType.GetField("testNames");
testNamesField.SetValue(testFilter, new string[] { "ScenarioTest.Run(\"file_0001\")", "ScenarioTest.Run(\"file_0002\")" });
// Test Runnerを実行できるクラスを参照する
var editorAssembly = Assembly.Load("UnityEditor.TestRunner");
var runnerWindowType = editorAssembly.GetType("UnityEditor.TestTools.TestRunner.TestRunnerWindow");
var runnerWindow = runnerWindowType.GetField("s_Instance", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null);
var listGUIField = runnerWindowType.GetField("m_PlayModeTestListGUI", BindingFlags.Instance | BindingFlags.NonPublic);
var listGUI = runnerWindow != null ? listGUIField.GetValue(runnerWindow) : Activator.CreateInstance(listGUIField.FieldType);
// Test Runnerを実行する
var runMethod = listGUIField.FieldType.GetMethod("RunTests", BindingFlags.Instance | BindingFlags.NonPublic);
runMethod.Invoke(listGUI, new object[] { testFilter });
}
}
正直「何やっているんだ、このコードは」状態になると思うので、dnSpyなどのデコンパイラを使ってTestRunner.dllの中身を見ることをオススメします。
6. テストの失敗が後続のテストにまで影響を与えないようにする
Play Modeテストでは、複数のテストを実行した場合も、同じPlay状態でテストを実行します。
そのため、変数などで状態を持っていれば、それは後続のテストにも引き継がれることになります。
この特性は、途中のテストが失敗した時に厄介で、
- テストAが失敗する
- テストAが途中で失敗したので、途中の状態が保持されたままになる
- 次のテストBに移る
- 想定しない状態が保持されていることで、問題ないはずのテストBが落ちてしまう
ということが起きてしまいます。
これに対処するには、各テストが正しく初期化された状態で実行される必要があります。
ただ、もしstaticやDontDestroyOnLoadをよく使っているプロジェクトであれば、これは困難を極めます。
シーンの再ロードなどでは初期化できず、しらみつぶしに初期化されていないところを探していくしかないからです。
自分が現在開発に携わっているゲームも同じ状況で、初めはしらみつぶしに探していましたが、途方もなく途中で断念しました。
打開策はないか…そう思案して、考えついた最強のTips、それが
一旦UnityのPlay状態を止めて再度Play状態にすれば、事実上初期化されたことと同義になる!!(スマホのタスクキルと同じ)
というものでした。
実際のコードを見てみましょう。
public class ScenarioTest
{
private static object _testFilter = null;
private List<string> _errorLogs = new List<string>();
private List<string> _runTestNames = new List<string>();
// クラス内で最初のテストが実行される前に一度だけ呼ばれる
[OneTimeSetUp]
public void OneTimeSetUp()
{
// 【注意】 この例では、Logメソッドを省略
Application.logMessageReceived += Log;
// Restartするため、テスト情報を保持しておく
var assembly = Assembly.Load("UnityEngine.TestRunner");
var controllerType = assembly.GetType("UnityEngine.TestTools.TestRunner.PlaymodeTestsController");
var controllerObject = GameObject.Find("Code-based tests runner");
var controller = controllerObject.GetComponent(controllerType);
var settingsField = controllerType.GetField("settings");
var settings = settingsField.GetValue(controller);
var settingsType = assembly.GetType("UnityEngine.TestTools.TestRunner.PlaymodeTestsControllerSettings");
var filterField = settingsType.GetField("filter");
_testFilter = filterField.GetValue(settings);
}
// 【注意】 この例では、FILES変数の宣言は省略
[TestCaseSource("FILES")]
[UnityTest]
public IEnumerator Run(string file)
{
// 前回のテストが失敗していたら、UnityのPlay状態を止める
if (_errorLogs.Count > 0 && _testFilter != null)
{
// 【注意】 テスト情報の更新処理(今回は名前で更新しているが、カテゴリーなら別の処理が必要)
var assembly = Assembly.Load("UnityEngine.TestRunner");
var testFilterType = assembly.GetType("UnityEngine.TestTools.TestRunner.GUI.TestRunnerFilter");
var testNamesField = testFilterType.GetField("testNames");
var testNames = (string[])testNamesField.GetValue(_testFilter);
// すでに実行しているテストは削除する
testNames = testNames.Where(testName => !_runTestNames.Any(runTestName => testName.Contains(runTestName))).ToArray();
testNamesField.SetValue(_testFilter, testNames);
// Unityを止めて、Test Runnerを再び実行する
EditorApplication.isPlaying = false;
EditorApplication.update += OnRestart;
}
_errorLogs.Clear();
_runTestNames.Add(file);
yield return RunScenarioWithFile(file);
}
private static void OnRestart()
{
Restart(_testFilter);
}
// コード側からTest Runnerを実行する
private static void Restart(object testFilter)
{
var assembly = Assembly.Load("UnityEditor.TestRunner");
var runnerWindowType = assembly.GetType("UnityEditor.TestTools.TestRunner.TestRunnerWindow");
var runnerWindow = runnerWindowType.GetField("s_Instance", BindingFlags.NonPublic | BindingFlags.Static).GetValue(null);
var listGUIField = runnerWindowType.GetField("m_PlayModeTestListGUI", BindingFlags.Instance | BindingFlags.NonPublic);
var listGUI = runnerWindow != null ? listGUIField.GetValue(runnerWindow) : Activator.CreateInstance(listGUIField.FieldType);
var runMethod = listGUIField.FieldType.GetMethod("RunTests", BindingFlags.Instance | BindingFlags.NonPublic);
runMethod.Invoke(listGUI, new object[] { testFilter });
EditorApplication.update -= OnRestart;
}
}
やっていることは、以下の通りです。
- テスト開始時にTest Runnerからテスト情報を抜き出す
- 各テスト開始時、前回のテストが失敗したか確認する
- テストに失敗していれば、すでにテスト済みのものを、開始時の保持しておいたテスト情報から削除する
- UnityのPlay状態を止め、更新したテスト情報をもとにTest Runnerを再実行する
Test Runnerを実行すれば、UnityはPlay状態になるため、事実上の初期化が完成です。
注意として、テスト情報が名前ではなくカテゴリーで管理されていることもあるため、その際は別の更新処理が必要です。
苦肉の策ではありますが、これで全シナリオのテストが可能となりました。
ただ一点、この方法で問題になることがありました。
次のTipsに移ります。
7. CommandLineからTest Runnerを実行する
通常のケース
冒頭でも記述した、Test Runnerで用意されているコマンドで実行可能です。
Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>
これに-quit
が指定されていないことにお気づきでしょうか?
実は、このコマンドを実行すると、テスト終了時にUnityが自動的に終了するようになっています。
逆に、-quit
を付けると失敗してしまうためご注意ください。
特殊なケース
「6. テストの失敗が後続のテストにまで影響を与えないようにする」で紹介した方法だと、通常のケースと同じコマンドを実行しても上手くいきません。
問題は2つあります。
解決策とともに、一つずつ紹介していきます。
Unityが終了しない問題
通常のケースにて紹介した、-quit
を指定せずとも、テスト終了時にUnityが自動的に終了する機能。
本来であれば問題がないこの挙動ですが、先ほど紹介した一旦Play状態を止める方法だと厄介です。
というのも、一旦Play状態を止めるという動作は、Test Runnerが不正終了したとみなされ、その機能が働かなくなってしまうからです。
このままでは、CommandLineからの実行時、途中でテストに失敗すると、Unityが終了されません。
そこで、batchmodeでの実行であれば、全テスト終了時にUnityを終了するようにしました。
public class ScenarioTest
{
// クラス内で最後のテストが実行された後に一度だけ呼ばれる
[OneTimeTearDown]
public void OneTimeTearDown()
{
// batchmodeなら終了させる
if (Environment.CommandLine.Contains("-batchmode"))
{
// エラー扱いにするため、1以上を返しても良い
EditorApplication.Exit(0);
}
}
}
途中でテストが失敗しPlay状態が止められても、[OneTimeTearDown]
は呼ばれないことが肝ですね。
(複数のクラスが存在する場合は、他のクラスのテストが終了しているかのチェックが必要になります)
無限に同じテストを実行され続ける問題
もしこの記事のコードをそのまま使っている場合、無限に同じテストが実行され続けてしまいます。
これは、
Unity.exe -projectPath <projectPath> -batchmode -runTests -testPlatform playmode -testResults <resultPath>
このコマンドで作成されるテスト情報は名前で管理されていないため、記事のコードのように、すでに実行したテストの名前を削除しても意味がないためです。
もし、この記事のコードを使い回したい場合は、名前で管理されているテスト情報を作成しTest Runnerで実行するメソッドを、CommandLineから呼ぶことをオススメします。
Unity.exe -projectPath <projectPath> -batchmode -executeMethod ScenarioTestCommand.Execute
実行しているScenarioTestCommand.Execute
メソッドは、「5. コード側からTest Runnerを制御する」で紹介しているコードを参照してください。
こちらのサンプルコードは、名前で管理されているテスト情報を作成し、実行しています。
まとめ
自分の開発しているゲームでは、この全シナリオ動作テストを毎朝走らせ、失敗すればslackに通知するようにしています。
今回テストコードを入れたことで、シナリオデータの不具合が消えただけでなく、シナリオ再生用のコードを変更するハードルが下がったことも嬉しいポイントです。
ゲーム開発だとテストコードを書く機会も少なかったですが、この機会に今後も積極的に取り組んでいきたいと思います。
Twitter: @yukiarrr