Unity Test Framework および NUnit では、テストコードに付与できるさまざまな属性(attribute)が提供されています。 また拡張ポイントであるインターフェイスを実装することで、プロジェクト独自のカスタム属性を定義してテストコードで使うこともできます。
本記事では、オープンソースの Test Helper パッケージにいくつかの汎用的なカスタム属性を実装するにあたって得た知見を紹介します。
本記事で紹介するインターフェイスは次の4つです。 先頭から3つが NUnit 由来のもの、最後のひとつが Unity Test Framework 独自のものです。
- IApplyToTest
- IApplyToContext
- ICommandWrapper(IWrapSetUpTearDown, IWrapTestMethod)
- IOuterUnityTestAction
検証環境は次のとおりです。
カスタム属性実装の基本
カスタム属性は次のように定義します。
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
よりも前です *6IWrapTestMethod
の実行順は、SetUp
の後、テストメソッドの前です- 引数で渡される
TestCommand
から取得できるテストコンテキストは、テストケースではなくテストフィクスチャ(テストクラス)のものです
Unity Test Framework の MaxTime
属性、Repeat
属性、Retry
属性は IWrapSetUpTearDown
を実装しています。つまり上記の問題があるということです。
詳しくは次の記事を参照してください。
IOuterUnityTestAction
IOuterUnityTestAction は、NUnit由来のものでなくUnity独自のインターフェイスです。
このインターフェイスを実装した属性は、付与されたテストの実行前に BeforeTest
メソッド、実行後に AfterTest
メソッドが呼ばれます。
BeforeTest
が UnitySetUp
より前に呼ばれる、例外を投げても無限ループしないでテストが失敗してくれるなど、とても高品質なインターフェイスです*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
内でテストの成否を判定するには、上例のようにテストコンテキストのPassCount
かFailCount
を参照します
カスタム属性を実装するにあたっての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完全攻略ガイド』を参照してください。
*1:テストは 2019 LTS, 2020 LTS, 2021 LTS, および 2023.1.16f1 でも実行しています
*2:正常系は最新のv1.3.9でも動作しますが、異常系の振る舞いが異なります
*4:Test Frameworkのバグですが修正されないでしょう
*5:ただし IOuterUnityTestAction は IWrapSetUpTearDown の代替になりますが、IWrapTestMethod に代わるインターフェイスはありません
*6:UnitySetUp はほかにもドメインリロードで再実行されないなどの癖があります。どうして…
*7:冷静に考えると嫌味っぽいですが、検証したとき正直感動したのです…