10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

QualiArtsAdvent Calendar 2019

Day 4

UnityTestRunner向けTest属性を自作してみる

Last updated at Posted at 2019-12-03

前書き

アドカレ4日目です。
UnityTestRunnerについてちょっと書いてみます。

テスト、いいですよね。たくさんテストを書くと仕事が進んでいるような気がしてきます。
僕はそんなにテスト書かない人間ですけど、テストを書くのが嫌いでは無いです。
最近、UnityTestRunnerとその裏側であるNUnitに触れていたら、TestAttributeの仕組みが気になって、自作のAttributeとかを噛ませてみたくなったので、試してみました。

シンプルな自作TestAttribute

まずはじめに [SimpleTest] public void テストメソッド() { } こんな感じでTestRunnerに認識されるAttributeを自作してみます。

実装と使用方法

TestAttribute定義

SimpleTestAttribute.cs
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);
}

使用方法

SimpleTestSample.cs
using NUnit.Framework;

public class SimpleTestSample
{
    [SimpleTest] public void Test() { }
    [SimpleTest] public void TestFail() => Assert.Fail();
}

上記のテストケースであれば、UnityTestRunner上では次のスクリーンショットのように表示されます。
スクリーンショット 2019-11-25 午後4.49.33.png

仕組み

UnityTestRunnerに認識させるためにやったことはAttributeの定義(とテストメソッドへの付与)だけです。

継承元について

NUnit.Framework.NUnitAttributeを継承していますが、これは System.Attribute を直接継承してもUnityTestRunner上では動作に違いはありませんでしたが、その場の雰囲気でNUnitAttributeを採用しました。
他にもNUnit標準のAttributeはいくつかあるのでNUnit内のAttributeを眺めるのもいいかもしれません。
例えば、UnityTestAttributeなんかはCombiningStrategyAttributeを継承していたりします。

ISimpleTestBuilderについて

NUnit.Framework.Interfaces.ISimpleTestBuilder は次のように定義されています。

ISimpleTestBuilder.cs
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定義

UnityTestSceneAttribute.cs
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で実行実行した結果です。
スクリーンショット 2019-11-25 午後4.56.49.png

仕組み

NUnitのIMethodInfoを実装してMethodProxyクラスを定義し、BuildFromに渡ってくるIMethodInfoのプロキシとしてInvokeに割り込んでいます。
なんとなくActionを渡していますが、直接Invokeメソッド内に書いちゃっても問題ないですね。

なお、今回のBuildFromメソッド実装では、色々実装が足りないのでValuesやRange属性と組み合わせることができませんが、そこはご愛嬌ということで。


シーンを指定してTestを実行する属性の実装(その2)

前述した「シーンを指定してTestを実行する属性の実装(その1)」の場合、色々な属性と組み合わせるとボロが出始めました。
そこで、もう少しボロが出にくいUnityTestRunnerライクな実装をしてみます。
使い方は一緒なので割愛します。

実装

UnityTestSceneAttribute.cs
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に実装してあげるだけです。
テスト属性に密接に関わる処理は UnitySetUpUnityTearDown よりこちらのインターフェースを使った方が簡単なケースもあるかもしれません。


TestCaseSourceのUnityTest版

NUnitには TestCaseSourceAttribute というものがいて、このAttributeにイテレータを返すメンバー名を渡してあげると1つのテストから複数のテストケースを量産することができます。
TestAttribute に対するUnityTestRunnerの UnityTestAttribute のように、 TestCaseSourceAttribute に対して UnityTestCaseAttribute があるかなーっと思ったのですが、現状見つからなかったので自作してみました。
以下に実装を残します。

実装と使用方法

TestAttribute定義

UnityTestCaseSourceAttribute.cs
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;
    }
}

使用法

SimpleTestEx.cs
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で実行実行した結果です。
UnityTestCaseSourceAttribute

仕組み

TestCaseSourceAttributeを継承して、ITestBuilderを実装し直してみました。
TestCaseSourceAttributeにはvirtualなメソッドなんて存在しないので、BuildFromメソッドをnewして隠蔽し、GetTestCasesForメソッドをリフレクションでこじ開けるという、気合いと根性に満ちた実装になっています。
TestCaseSourceAttributeを写経するのも選択肢としてはありですが、少々複雑だったので横着してみました。
ぱっと見はそこそこ素直な実装に見えるんじゃないでしょうか。


アスキーアートだって

結構なんでもできるので、

[AATest]
public void AA表示したい() { }

たったこれだけのテストから
スクリーンショット 2019-12-02 午後6.32.54.png

こんな感じでアスキーアートを出すことだって自由です。

それではみなさん楽しいテストライフを!

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?