はじめに
UnityのBatchMod(Headlessモード)を用いたEditTestの実行でハマったので、備忘録がてらまとめます。
- 
Unity 2022.1.24f1を使ってます
BatchModeでテストを実行する流れ
1.テストを用意する
EditorModeTestを用意します。
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();
        }
    }
}
2. コマンドラインで外から実行する関数を用意する
Editor拡張として、staticクラスにstaticメソッドを定義しておきます。
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。
名前空間から関数名までをフルパス指定することで、コマンドラインからこの関数を実行することができます。
"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というクラスを用いることでテストをスクリプトから呼び出せるようになります。
これをさきほど用意した関数で呼び出すようにします。
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);
        }
    }
}
これでもテストは実行されますが、どのような結果になったのかがこれだけではわからず意味がないです。
そのため結果を取得できるようにコールバックを登録する必要があります。
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上で実行したとき
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を設定することで、テストが同期処理のみに限定されます。[UnityTest]など、コルーチンを使ったテストはすべてスキップされてしまいます。
var settings = new ExecutionSettings(filter)
{
    runSynchronously = true
};
testRunnerApi.Execute(settings);
ではrunSynchronously=falseだと何か不都合があるのかというと、自分が試した限りではよくわかりませんでした。
別にfalseにしたところでテスト自体もともと並行処理されているわけではなく、メインスレッド以外でテストが走っているような感じでもありませんでした。むしろtrueにしたままの場合、async/awaitを使ったテストが動かなくなってしまうため特別に理由がない限りはfalseのままでよいかも。
Filterでグループの除外設定ができない?
Filterを使うことでテストを実行したい対象を指定することはできるが、逆に「このテストを除外したい」は設定できないみたい。
var filter = new Filter()
{
    testMode = TestMode.EditMode,
    groupNames = new []{ "IOTests" }
};
「このCategoryのテストは時間がかかるので今回はスキップさせる」みたいな設定ができないのはちょっと面倒かも。

