📝

Unity のテスト結果を JUnit で保存するなど

2024/01/19に公開

Unity のテスト結果を JUnit で保存するなど

Unity のテストを実行して、その結果を JUnit で保存するとか、テスト後に何か処理をしたいとかちょっと面倒です。できるんだけど、やりにくいです。

テストはジョブとして実行されるようで、TestRunnerApi.Execute() はテストが完了していなくても戻ってきます。ICallbacks.RunFinished() を実装する方法が一般的ですが、これはこれで不便なことも多いです。

詳しくは後で説明します。

Unity Test Tools

というわけで、Unity Test Tools ってのを作りました。

インストール方法は公式サイトを参考にしてください。

次のコードを Assets/Editor 以下に配置します。

public static class Test
{
    [MenuItem("Build/Test")]
    public static void Run()
    {
        var runner = UnityTestRunner();
        runner.Execute(new ExecutionSettings(new Filter { testMode = TestMode.EditMode }))
            .WithCallback(context => {
                // `TestResults-junit-****.xml` にテスト結果を保存します。
                context.ToJUnitReport().Save();
	        // テスト件数、失敗件数などを表示します。
	        context.PrintSummary();

                // Batchmode の時に、Unity Editor を終了します。
                context.ExitEditorIfBatchmode();
    });

Unity Editor のメニューから実行してもいいですし、コマンドラインから呼び出すこともできます。

$ /path/to/unity -batchmode -executeMethod Test.Run

この JUnit レポートをどうするかっていうと、GitLab CI とか GitHub Actions に食わせるんです。そうすると Merge Request / Pull Request でテスト結果を手軽に確認することができます。


ところで、たったこれだけのものを PlayMode テストも含めてちゃんと動くようにするのはとても大変でした。そのへん、興味がある人は続きを読んでください。

なんでめんどくさいの?

冒頭でも説明しましたが、テストジョブはバックグラウンドで実行され、ジョブの完了を正しく知る方法はありません。

次の例のように、TestRunnerApi.Execute() メソッドはテストジョブの完了を待たずに戻ってきます。

var runner = ScriptableObject.CreateInstance<TestRunnerApi>();
runner.Execute(new ExecutionSettings(new Filter { testMode = TestMode.EditMode }));

Debug.Log("テスト終了"); // ここはテストジョブが開始される前に呼び出されます。

そこで出番なのが、テストの状態を報告してくれる ICallbacks インターフェイスです。

まずは次のような実装を用意しておきます。

class TestCallbacks : ICallbacks
{
    public void RunFinished(ITestResultAdapter result)
    {
        // テスト結果を処理するコードを書きます。
    }
    
    // ... 残りの実装。
}

そして、TestRunnerApi インスタンスに登録します。

var runner = ScriptableObject.CreateInstance<TestRunnerApi>();
// `TestCallbacks` を登録します。
runner.RegisterCallbacks(new TestCallbacks());
runner.Execute(new ExecutionSettings(new Filter { testMode = TestMode.EditMode }));

これで目的のことができるようになった! んですけど、そのように見せかけて罠がいっぱいでした。

勘のいいひとなら気が付いたかもしれませんが、テストを実行するたびに TestCallbacks インスタンスが追加され、同じ処理を何度もするようになります。

いくつか方法がありますが、ここでは静的コンストラクターで初期化することにします。

public static Test
{
    private static readonly TestRunnerApi Runner;

    static Test()
    {
        Runner = ScriptableObject.CreateInstance<TestRunnerApi>();
	Runner.RegisterCallback(new TestCallbacks());
    }

    public static void Run()
    {
        Runner.Execute(new ExecutionSettings(new Filter { testMode = TestMode.EditMode }));
    }
}

これで OK と思いきや! PlayMode でのテストでは TestCallbacks インスタンスが呼び出されません。次のように、InitializeOnLoad 属性を付けなければならないようです。

[InitializeOnLoad]
public static class Test
{
    // ...
}

これで大体やりたいことはできるようになったのですが、Unity Test Runner や Rider から実行したときも TestCallbacks が呼び出されてしまいます。また、TestRunnerApi.Execute() でテストを実行した後に追加で何をやっているかがわかりにくいです。(TestRunnerApi.RegisterCallbacks() メソッドを呼び出しているところを探さないといけないです)

とはいえ、できないものはしょうがないので、Editor Script からのみ実行されるよう TestCallbacks にフラグを付けてみます。

class TestCallbacks : ICallbacks
{
    public bool Enabled { get; set; }
    public void RunFinished(ITestResultAdapter result)
    {
        if (!Enabled) return;

        Enabled = false;

        // テスト終了後の処理。
    }
}

そして、テスト実行前に TestCallbacks インスタンスを有効化します。

public static Test
{
    private static readonly TestRunnerApi Runner;
    private static readonly TestCallbacks Callbacks;

    static Test()
    {
        Runner = ScriptableObject.CreateInstance<TestRunnerApi>();
	Callbacks = new TestCallbacks();
	Runner.RegisterCallback(Callbacks);
    }

    public static void Run()
    {
        Callbacks.Enabled = true;
        Runner.Execute(new ExecutionSettings(new Filter { testMode = TestMode.EditMode }));
    }
}

なんですけど、相変わらず PlayMode ではこの方法はうまくいきません。TestCallbacks インスタンスは呼び出されないのです。

いろいろ調べてみて次のことがわかりました。

  • PlayMode のテストは Unity Editor が PlayMode に切り替わり、Editor Script が停止する。
  • PlayMode は .NET の AppDomain が別で、実行空間が分かれているため、静的変数ですら共有できない。

どうやって解決したのか

初期のころはテスト完了を待つコルーチンを提供していました。EditMode ではうまく動きますが、PlayMode では Editor Script が停止するのでコルーチンも停止し、どうしようもありませんでした。

次のように書けていい感じだったのですが、PlayMode では使えないのでお蔵入りしました。(どっちにしろ、別 AppDomain で変数を共有できない以上、この方法はうまくいきませんが)

var task = new UnityTaskRunner().Execute(/* ... */);
yield return task;

task.Result.ToJUnitReport().Save();

というわけで、今のコールバックを登録する方法に変えました。しかし、PlayMode テストでは専用の AppDomain が作成されるため、静的変数ですらコールバック変数を渡すことはできません。

  1. 登録したコールバックを BinaryFormatter でシリアライズして一時ファイルに保存。
  2. ICallbacks.RunStarted() で (1) をデシリアライズしてコールバックを復元。
  3. ICallbacks.RunFinsihed() で (2) を実行。

Unity Test Runner や Rider などからテストを実行した際は、コールバックをシリアライズした一時ファイルがありませんので何もしません。

最後に

Unity はほんとに、できたー! からのあれ? あれ? を繰り返すの本当に多いなあ...

Discussion