やらなイカ?

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

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:冷静に考えると嫌味っぽいですが、検証したとき正直感動したのです…