前書き
アドカレ4日目です。
UnityTestRunnerについてちょっと書いてみます。
テスト、いいですよね。たくさんテストを書くと仕事が進んでいるような気がしてきます。
僕はそんなにテスト書かない人間ですけど、テストを書くのが嫌いでは無いです。
最近、UnityTestRunnerとその裏側であるNUnitに触れていたら、TestAttribute
の仕組みが気になって、自作のAttributeとかを噛ませてみたくなったので、試してみました。
シンプルな自作TestAttribute
まずはじめに [SimpleTest] public void テストメソッド() { }
こんな感じでTestRunnerに認識されるAttributeを自作してみます。
実装と使用方法
TestAttribute定義
using System;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class SimpleTestAttribute : NUnitAttribute, ISimpleTestBuilder, IImplyFixture
{
public TestMethod BuildFrom(IMethodInfo method, Test suite)
=> new TestMethod(method, suite);
}
使用方法
using NUnit.Framework;
public class SimpleTestSample
{
[SimpleTest] public void Test() { }
[SimpleTest] public void TestFail() => Assert.Fail();
}
上記のテストケースであれば、UnityTestRunner上では次のスクリーンショットのように表示されます。
仕組み
UnityTestRunnerに認識させるためにやったことはAttributeの定義(とテストメソッドへの付与)だけです。
継承元について
NUnit.Framework.NUnitAttribute
を継承していますが、これは System.Attribute
を直接継承してもUnityTestRunner上では動作に違いはありませんでしたが、その場の雰囲気でNUnitAttributeを採用しました。
他にもNUnit標準のAttributeはいくつかあるのでNUnit内のAttributeを眺めるのもいいかもしれません。
例えば、UnityTestAttribute
なんかはCombiningStrategyAttribute
を継承していたりします。
ISimpleTestBuilderについて
NUnit.Framework.Interfaces.ISimpleTestBuilder
は次のように定義されています。
public interface ISimpleTestBuilder
{
TestMethod BuildFrom(IMethodInfo method, Test suite);
}
今回はTestMethodのコンストラクタ TestMethod(IMethodInfo method, Test suite)
にそのまま流し込んで完了としました。
もう少しイロイロしてくれる便利クラスとして NUnit.Framework.Internal.Builders.NUnitTestCaseBuilder
というものも存在していて、標準の TestAttribute
などはこのビルダーを使っているようです。
IImplyFixtureについて
NUnit.Framework.Interfaces.IImplyFixture
自体は何のメソッドも持たないマーカーインターフェースですが、最低限これだけ付いていればテストとして認識させることができます。ただし前述の ISimpleTestBuilder.BuildFrom
のようなTestMethodを提供するインターフェースが存在しないと、何のテストも実行されません。
シーンを指定してTestを実行する属性の実装(その1)
テストメソッドを実行する直前に任意の処理を挟み込むAttributeを実装してみます。
今回の例ではテスト実行前に EditorSceneManager.OpenScene("hoge.unity");
を叩いてみます。
実装と使用方法
TestAttribute定義
using System;
using System.Reflection;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using UnityEditor.SceneManagement;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class UnityTestSceneAttribute : NUnitAttribute, ISimpleTestBuilder, IImplyFixture
{
public readonly string scenePath;
public UnityTestSceneAttribute(string scenePath) => this.scenePath = scenePath;
public TestMethod BuildFrom(IMethodInfo method, Test suite)
=> new TestMethod(new MethodProxy(method, () => EditorSceneManager.OpenScene(scenePath)), suite);
}
// NUnit.Framework.Internal.MethodWrapperをさらに包むもの
class MethodProxy : IMethodInfo
{
private readonly IMethodInfo _methodInfo;
private readonly Action _beforeTest;
public MethodProxy(IMethodInfo methodInfo, Action beforeTest)
{
_methodInfo = methodInfo;
_beforeTest = beforeTest;
}
public object Invoke(object fixture, params object[] args)
{
_beforeTest.Invoke();
return _methodInfo.Invoke(fixture, args);
}
public ITypeInfo TypeInfo => _methodInfo.TypeInfo;
public MethodInfo MethodInfo => _methodInfo.MethodInfo;
public string Name => _methodInfo.Name;
public bool IsAbstract => _methodInfo.IsAbstract;
public bool IsPublic => _methodInfo.IsPublic;
public bool ContainsGenericParameters => _methodInfo.ContainsGenericParameters;
public bool IsGenericMethod => _methodInfo.IsGenericMethod;
public bool IsGenericMethodDefinition => _methodInfo.IsGenericMethodDefinition;
public ITypeInfo ReturnType => _methodInfo.ReturnType;
public T[] GetCustomAttributes<T>(bool inherit) where T : class => _methodInfo.GetCustomAttributes<T>(inherit);
public bool IsDefined<T>(bool inherit) => _methodInfo.IsDefined<T>(inherit);
public IParameterInfo[] GetParameters() => _methodInfo.GetParameters();
public Type[] GetGenericArguments() => _methodInfo.GetGenericArguments();
public IMethodInfo MakeGenericMethod(params Type[] typeArguments) => _methodInfo.MakeGenericMethod();
}
使用方法
using NUnit.Framework;
using UnityEngine;
public class UnityTestSceneTest
{
[UnityTestScene("Assets/Scenes/CameraAru.unity")]
public void AruScene() => Assert.IsNotNull(Object.FindObjectOfType<Camera>());
[UnityTestScene("Assets/Scenes/CameraNai.unity")]
public void NaiScene() => Assert.IsNull(Object.FindObjectOfType<Camera>());
}
次のスクリーンショットは、上記のテストコードをUnityTestRunnerで実行実行した結果です。
仕組み
NUnitのIMethodInfoを実装してMethodProxyクラスを定義し、BuildFromに渡ってくるIMethodInfoのプロキシとしてInvokeに割り込んでいます。
なんとなくActionを渡していますが、直接Invokeメソッド内に書いちゃっても問題ないですね。
なお、今回のBuildFromメソッド実装では、色々実装が足りないのでValuesやRange属性と組み合わせることができませんが、そこはご愛嬌ということで。
シーンを指定してTestを実行する属性の実装(その2)
前述した「シーンを指定してTestを実行する属性の実装(その1)」の場合、色々な属性と組み合わせるとボロが出始めました。
そこで、もう少しボロが出にくいUnityTestRunnerライクな実装をしてみます。
使い方は一緒なので割愛します。
実装
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using UnityEditor.SceneManagement;
using UnityEngine.TestTools;
[System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = true)]
public class UnityTestSceneAttribute : NUnitAttribute, ISimpleTestBuilder, IImplyFixture, IOuterUnityTestAction
{
public readonly string scenePath;
public UnityTestSceneAttribute(string scenePath) => this.scenePath = scenePath;
public TestMethod BuildFrom(IMethodInfo method, Test suite) => new TestMethod(method, suite);
public System.Collections.IEnumerator BeforeTest(ITest test)
{
EditorSceneManager.OpenScene(scenePath);
// 1frameくらい間を置いた方が安心
yield return null;
}
public System.Collections.IEnumerator AfterTest(ITest test)
{
yield break;
}
}
仕組み
都合よくUnityTestRunnerのテストの前後に処理を挟み込める IOuterUnityTestAction
というインターフェースが用意されているので、これを自作Attributeに実装してあげるだけです。
テスト属性に密接に関わる処理は UnitySetUp
や UnityTearDown
よりこちらのインターフェースを使った方が簡単なケースもあるかもしれません。
TestCaseSourceのUnityTest版
NUnitには TestCaseSourceAttribute
というものがいて、このAttributeにイテレータを返すメンバー名を渡してあげると1つのテストから複数のテストケースを量産することができます。
TestAttribute
に対するUnityTestRunnerの UnityTestAttribute
のように、 TestCaseSourceAttribute
に対して UnityTestCaseAttribute
があるかなーっと思ったのですが、現状見つからなかったので自作してみました。
以下に実装を残します。
実装と使用方法
TestAttribute定義
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using NUnit.Framework.Internal;
using NUnit.Framework.Internal.Builders;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class UnityTestCaseSourceAttribute : TestCaseSourceAttribute, ITestBuilder
{
private readonly NUnitTestCaseBuilder _builder = new NUnitTestCaseBuilder();
// コンストラクタ
public UnityTestCaseSourceAttribute(string sourceName) : base(sourceName) { }
public UnityTestCaseSourceAttribute(Type sourceType, string sourceName) : base(sourceType, sourceName) { }
public UnityTestCaseSourceAttribute(Type sourceType) : base(sourceType) { }
public UnityTestCaseSourceAttribute(Type sourceType, string sourceName, object[] methodParams) : base(sourceType, sourceName, methodParams) { }
// ITestBuilder
public new IEnumerable<TestMethod> BuildFrom(IMethodInfo method, Test suite)
{
var cases = (IEnumerable<ITestCaseData>) typeof(TestCaseSourceAttribute).InvokeMember("GetTestCasesFor",
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.InvokeMethod,
null, this, new object[] {method});
return cases.OfType<TestCaseParameters>().Select(p => BuildFromImpl(method, suite, p));
}
private TestMethod BuildFromImpl(IMethodInfo method, Test suite, TestCaseParameters caseParam)
{
caseParam.ExpectedResult = new object();
caseParam.HasExpectedResult = true;
var t = _builder.BuildTestMethod(method, suite, caseParam);
if (t.parms != null) t.parms.HasExpectedResult = false;
return t;
}
}
使用法
using System.Collections;
using NUnit.Framework;
using UnityEngine.Networking;
public class SimpleTestEx
{
public static object[][] urls =
{
new object[] {"https://google.co.jp", 200},
new object[] {"https://yahoo.co.jp", 200},
};
[UnityTestCaseSource("urls")]
public IEnumerator UnityTestExのテスト(string url, int responseCode)
{
using (var req = UnityWebRequest.Get(url))
{
var ope = req.SendWebRequest();
while (ope.isDone == false) yield return null;
Assert.AreEqual(responseCode, req.responseCode);
}
}
}
次のスクリーンショットは、上記のテストコードをUnityTestRunnerで実行実行した結果です。
仕組み
TestCaseSourceAttribute
を継承して、ITestBuilder
を実装し直してみました。
TestCaseSourceAttribute
にはvirtualなメソッドなんて存在しないので、BuildFromメソッドをnewして隠蔽し、GetTestCasesFor
メソッドをリフレクションでこじ開けるという、気合いと根性に満ちた実装になっています。
TestCaseSourceAttribute
を写経するのも選択肢としてはありですが、少々複雑だったので横着してみました。
ぱっと見はそこそこ素直な実装に見えるんじゃないでしょうか。
アスキーアートだって
結構なんでもできるので、
[AATest]
public void AA表示したい() { }
こんな感じでアスキーアートを出すことだって自由です。
それではみなさん楽しいテストライフを!