Unity BatchModeでテストを実行する

# はじめに

UnityのBatchMod（Headlessモード）を用いたEditTestの実行でハマったので、備忘録がてらまとめます。

* `Unity 2022.1.24f1` を使ってます

# BatchModeでテストを実行する流れ

## 1.テストを用意する

EditorModeTestを用意します。

```cs
using System.Collections;
using NUnit.Framework;
using UnityEngine.TestTools;

namespace TORISOUP.Tests
{
    [TestFixture]
    public class SampleTest
    {
        [Test]
        public void 成功するテスト()
        {
            Assert.AreEqual(1, 1);
        }

        [UnityTest]
        public IEnumerator 非同期を含んだテスト()
        {
            yield return null;
            Assert.AreEqual(1, 1);
        }
        
        [Test]
        public void 必ず失敗するテスト()
        {
            Assert.Fail("Assert.Failによって失敗");
        }
        
        [Test]
        public void 必ずスキップするテスト()
        {
            Assert.Ignore();
        }
    }
}
```

![1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/8844a4fe-014c-0674-be4c-c2af8935efbf.jpeg)
（TestRunnerで動作確認）

## 2. コマンドラインで外から実行する関数を用意する

Editor拡張として、`static`クラスに`static`メソッドを定義しておきます。

```cs
using UnityEditor;
using UnityEngine;

namespace TORISOUP.Editor.TestExecutor
{
    public static class EditorModeTestsExecutor
    {
        // Editorからも実行できるようにメニューに追加しておく
        [MenuItem("TORISOUP/Tests/Execute Editor Mode Tests")]
        public static void RunTests()
        {
            Debug.Log("Hello!");

            // 終了
            EditorApplication.Exit(0);
        }
    }
}
```

コマンドラインから`BatchMode`でこの関数を実行するときはこんな感じでコマンドを叩けばOK。
名前空間から関数名までをフルパス指定することで、コマンドラインからこの関数を実行することができます。

```batch
"C:\Program Files\Unity\Hub\Editor\2022.1.24f1\Editor\Unity.exe"^
 -batchmode^
 -logFile result.log^
 -projectPath <YOUR_PROJECT_PATH_HERE>^
 -executeMethod TORISOUP.Editor.TestExecutor.EditorModeTestsExecutor.RunTests
```
（対象のプロジェクトをUnityEditorで開いている場合は実行できないため、UnityEditorを終了してから試してください）

## 3. TestRunnerApiを用いてテストを実行できるようにする

[TestRunnerApi](https://docs.unity3d.com/Packages/com.unity.test-framework@1.1/manual/extension-run-tests.html)というクラスを用いることでテストをスクリプトから呼び出せるようになります。
これをさきほど用意した関数で呼び出すようにします。

```cs:テストを実行する（まだ不完全）
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;

namespace TORISOUP.Editor.TestExecutor
{
    public static class EditorModeTestsExecutor
    {
        // Editorからも実行できるようにメニューに追加しておく
        [MenuItem("TORISOUP/Tests/Execute Editor Mode Tests")]
        public static void RunTests()
        {
            var testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();

            // 実行するテストを指定
            var filter = new Filter()
            {
                testMode = TestMode.EditMode
            };
            
            // 実行
            testRunnerApi.Execute(new ExecutionSettings(filter));

            // 終了
            EditorApplication.Exit(0);
        }
    }
}
```

これでもテストは実行されますが、どのような結果になったのかがこれだけではわからず意味がないです。
そのため結果を取得できるようにコールバックを登録する必要があります。

* [UnityEditor.TestTools.TestRunner.Api.ICallback](https://docs.unity3d.com/Packages/com.unity.test-framework@1.1/manual/reference-icallbacks.html)

```cs:テストを実行する（完成版）
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;

namespace TORISOUP.Editor.TestExecutor
{
    public static class EditorModeTestsExecutor
    {
        // Editorからも実行できるようにメニューに追加しておく
        [MenuItem("TORISOUP/Tests/Execute Editor Mode Tests")]
        public static void RunTests()
        {
            var testRunnerApi = ScriptableObject.CreateInstance<TestRunnerApi>();

            // 実行するテストを指定
            var filter = new Filter()
            {
                testMode = TestMode.EditMode
            };

            testRunnerApi.RegisterCallbacks(new MyTestCallbacks());

            // 実行
            testRunnerApi.Execute(new ExecutionSettings(filter));
        }

        // 結果を取り出すためのコールバック処理を定義
        private class MyTestCallbacks : ICallbacks
        {
            private StackTraceLogType _tmpStackTraceLogType;
            
            /// <summary>
            /// テスト全体を実行開始する前に呼ばれる
            /// </summary>
            public void RunStarted(ITestAdaptor testsToRun)
            {
                // スタックトレースを一時的に無効化
                _tmpStackTraceLogType = Application.GetStackTraceLogType(LogType.Log);
                Application.SetStackTraceLogType(LogType.Log, StackTraceLogType.None);
                
                Debug.Log("Test Started.");
            }

            /// <summary>
            /// すべてのテストが完了した後に呼ばれる
            /// </summary>
            public void RunFinished(ITestResultAdaptor result)
            {
                Debug.Log($"All test finished: {result.TestStatus}");
                Application.SetStackTraceLogType(LogType.Log, _tmpStackTraceLogType);
                
                // 最終結果を表示
                Debug.Log($"Passed:{result.PassCount} Skipped:{result.SkipCount} Failed:{result.FailCount}");

                // BathMode時はテスト完了後に終了する
                if (Application.isBatchMode)
                {
                    // Failedがあったなら異常終了
                    // 失敗ゼロの場合は正常終了
                    EditorApplication.Exit(result.FailCount == 0 ? 0 : 1);
                }
            }

            /// <summary>
            /// 各テストが実行される前に呼ばれる
            /// </summary>
            public void TestStarted(ITestAdaptor test)
            {
            }

            /// <summary>
            /// 各テストが実行された後に呼ばれる
            /// </summary>
            public void TestFinished(ITestResultAdaptor result)
            {
                // ITestResultAdaptorはツリー構造であり、dll、クラス、各メソッドすべてがごちゃまぜになって結果が届く
                // そのまま出力すると重複した結果が出てしまうので、親要素は無視して末端の各テストの結果のみを出すようにする
                if (result.HasChildren) return;

                // 結果をログに出力
                Debug.Log($"{result.FullName}:{result.TestStatus}");

                // テストがコケた場合は詳細も出す
                if (result.TestStatus == TestStatus.Failed)
                {
                    Debug.Log($"{result.Message}");
                    Debug.Log($"{result.StackTrace}");
                }
            }
        }
    }
}
```

そのままログを出すとスタックトレースが大量に出力されることになるため、テスト実行中は`Debug.Log`がスタックトレースを出さないように設定しておきます。



## 4.テストが実行され結果が出力されるか試す

実際にテストを実行して、期待した結果が出力されるかを見ましょう。

#### Editor上で実行したとき

![2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/b4e94077-550f-7809-db37-d27881353764.jpeg)

#### BatchModeで実行したとき

出力結果からテスト周辺の結果を抜粋。

```
Test Started.
TORISOUP.Tests.SampleTest.成功するテスト:Passed
DisplayProgressbar: Test Runner
TORISOUP.Tests.SampleTest.非同期を含んだテスト:Passed
TORISOUP.Tests.SampleTest.必ずスキップするテスト:Skipped
TORISOUP.Tests.SampleTest.必ず失敗するテスト:Failed
Assert.Failによって失敗
at TORISOUP.Tests.SampleTest.必ず失敗するテスト () [0x00001] in M:\UnityProject\BatchModeTestSample\Assets\TORISOUP\Tests\SampleTest.cs:26
Unloading 0 Unused Serialized files (Serialized files now loaded: 0)
Unloading 0 unused Assets / (0 B). Loaded Objects now: 3913.
Memory consumption went from 95.7 MB to 95.7 MB.
Total: 3.771100 ms (FindLiveObjects: 0.135800 ms CreateObjectMapping: 0.083600 ms MarkObjects: 3.544100 ms  DeleteObjects: 0.007100 ms)

All test finished: Failed
Passed:2 Skipped:1 Failed:1
```

良さそう。

## 5.終わり

以上です。
あとはCIなどと組み合わせて使いましょう。

# メモ

## ExecutionSettings.runSynchronously はfalseのままでいいかも

[ExecutionSettings.runSynchronously](https://docs.unity3d.com/Packages/com.unity.test-framework@1.1/manual/reference-execution-settings.html?q=runSynchronously)を設定することで、テストが同期処理のみに限定されます。`[UnityTest]`など、コルーチンを使ったテストはすべてスキップされてしまいます。

```cs
var settings = new ExecutionSettings(filter)
{
    runSynchronously = true
};
testRunnerApi.Execute(settings);
```

では`runSynchronously=false`だと何か不都合があるのかというと、自分が試した限りではよくわかりませんでした。

別に`false`にしたところでテスト自体もともと並行処理されているわけではなく、メインスレッド以外でテストが走っているような感じでもありませんでした。むしろ`true`にしたままの場合、`async/await`を使ったテストが動かなくなってしまうため特別に理由がない限りは`false`のままでよいかも。


## Filterでグループの除外設定ができない?

`Filter`を使うことで**テストを実行したい対象を指定**することはできるが、逆に「このテストを除外したい」は設定できないみたい。

```cs
var filter = new Filter()
{
    testMode = TestMode.EditMode,
    groupNames = new []{ "IOTests" }
};
```

「この`Category`のテストは時間がかかるので今回はスキップさせる」みたいな設定ができないのはちょっと面倒かも。

## async/awaitをテストで使いたい場合は

* [Unity EditTestでasync/awaitが使いたい
](https://qiita.com/toRisouP/items/5f5807097a5d44be672f)

# 参考文献

* [【Unity2020対応】CLIから単体テスト(Edit Mode Test)を実行する](https://blog.virtualcast.jp/blog/2022/03/cli_test/)