MonoBehaviourTestでコンポーネントをテストする

投稿者: | 2018-04-02

皆さんHoloLensアプリを開発するときテストコードって書いてますか?

私はつい最近まで書いていませんでした…。基本的に動作確認はすべて実機で、バグを見つけた場合は画面上にテキストやデータを表示してデバッグするという、別業界の人からしたらドン引きなことをやっていました。アプリの規模が小さいうちはこれでも何とかなるかもしれませんが、規模が大きくなったりチーム開発となると厳しいと感じるのが正直なところです。

Unityではバージョン5.3からNUnitによるテストランナーが標準搭載され、バージョン5.6ではシーンを再生した状態でテストが実行できるPlayMode機能が追加されました。

調べていくうちに、PlayModeのMonoBehaviourTestがコンポーネントのテストに使えそうな感じでしたので、今回はこちらについてまとめたいと思います。

NUnitやTestRunnerの基本については公式マニュアルをご参照ください。

PlayModeテストの準備

PlayModeは通常ですと有効化されていませんので、Window ⇒ Test Runner ⇒ PlayMode ⇒ Enable playmode testsと選択し有効化する必要があります。

テストコードを作成するには、プロジェクトビューにて右クリックして、Create ⇒ Testing ⇒ PlayMode Test C# Scriptと選択します。

なお、PlayModeテストはアプリをビルドした上で実行されるため、EditModeテストとは違い、Editorフォルダ以外に配置する必要があります
生成されたコードは以下の通りです。

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;

public class NewPlayModeTest {

	[Test]
	public void NewPlayModeTestSimplePasses() {
		// Use the Assert class to test conditions.
	}

	// A UnityTest behaves like a coroutine in PlayMode
	// and allows you to yield null to skip a frame in EditMode
	[UnityTest]
	public IEnumerator NewPlayModeTestWithEnumeratorPasses() {
		// Use the Assert class to test conditions.
		// yield to skip a frame
		yield return null;
	}
}

Test属性が付与されたメソッドではEditModeのときと同じような挙動でテストランナーが動きますので、ユニットテストを書くのがよいと思います。

PlayModeテストを実行するにはUnityTest属性が付与されたメソッド内にテストコードを書いていきます。

PlayModeテストの挙動

テストランナーにてPlayModeテストを走らせるとテスト用に一時シーンが配置され、シーンが再生されます。

シーンが再生されると、UnityTest属性が付与されたメソッドがコルーチンのように動作します。したがってyield returnを使ってフレームを進めながらテストコードを書いてシナリオを表現することができます。アサーションもこのメソッドの中に書くことによって、テストランナー側でチェックしてくれます。そしてコルーチンが終了した時点でテスト成功と判定されます。

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;

public class NewPlayModeTest {

	[Test]
	public void NewPlayModeTestSimplePasses() {
		// Use the Assert class to test conditions.
	}

	// A UnityTest behaves like a coroutine in PlayMode
	// and allows you to yield null to skip a frame in EditMode
	[UnityTest]
	public IEnumerator NewPlayModeTestWithEnumeratorPasses()
	{
	    var hoge = false;

	    // 1フレーム待機
		yield return null;

        // パス
        Assert.That(hoge, Is.False);

        // 1秒待機
        yield return new WaitForSeconds(1f);

        // ここで失敗する
        Assert.That(hoge, Is.True);
	}
}

MonoBehaviourTest

PlayModeテストにはMonoBehaviourの振る舞いをテストするための仕組みであるMonoBehaviourTestが用意されています。

using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;

public class NewPlayModeTest
{
    /// <summary>
    /// テストしたいコンポーネントを継承し、IMonoBehaviourTestを実装したコンポーネントを用意する
    /// </summary>
    private class MonoBehaviour_TestScenario : MonoBehaviour, IMonoBehaviourTest
    {
        public bool IsTestFinished { get; private set; }

        private void Start()
        {

        }

        private void Update()
        {
            // テストシナリオを書く
             
            // テストが終了したらIsTestFinishedをtrueにする
            IsTestFinished = true;
            
            // PlayModeテスト内のyield return(下記)が終了し、テスト成功と判定される
        }
    }

    [UnityTest]
    public IEnumerator NewPlayModeTestWithEnumeratorPasses()
    {
        yield return new MonoBehaviourTest<MonoBehaviour_TestScenario>();
    }
}

このテストコードにてPlayModeテストを実行すると、上記のコードでいうところのMonoBehaviour_TestScenarioがアタッチされたGameObjectが一つシーンに存在する状態でPlayModeテストが実行されます。MonoBehaviour_TestScenarioはMonoBehaviourを継承しておりますので、通常のコンポーネントと同じようにStartメソッドやUpdateメソッドといったライフサイクル系のメソッドが動作します。

ここで重要なのがIMonoBehaviourTestインタフェースです。こちらはbool型のgetプロパティが定義されているだけのインタフェースですが、MonoBehaviourTestの型パラメータにこのインタフェースが実装されたコンポーネントを渡してyield returnしてやると、IsTestFinishedがtrueになるまでテストランナーがシーンを動かし続ける、すなわちMonoBehaviour_TestScenarioを動かし続けてくれます。

したがってStartメソッドやUpdateメソッド内にテストシナリオのコードやアサーションを書くことによって、テスト対象のコンポーネントを動かしつつテストをすることができます。

実践

それでは実際にMonoBehaviourTestを使ったテストコードを書いてみましょう。テスト対象として、指定した方向に毎フレーム移動する、といったコンポーネントを用意しました。

テスト対象のコンポーネント

テスト対象のコードは以下の通りです。

using UnityEngine;

namespace HoloAppTestSample
{
    /// <summary>
    /// プロパティで示される方向に応じてオブジェクトを移動させるコンポーネント
    /// </summary>
    public class MoveSpecifiedDirection : MonoBehaviour
    {
        public enum DirectionType
        {
            None = 0,
            Up,
            Right,
            Down,
            Left
        }

        public DirectionType Direction { get; set; } = DirectionType.None;

        private void Update()
        {
            var moveAmount = Vector3.zero;
            switch (Direction)
            {
                case DirectionType.Up:
                    moveAmount = Vector3.up * 0.01f;
                    break;
                case DirectionType.Right:
                    moveAmount = Vector3.right * 0.01f;
                    break;
                case DirectionType.Down:
                    moveAmount = Vector3.down * 0.01f;
                    break;
                case DirectionType.Left:
                    moveAmount = Vector3.left * 0.01f;
                    break;
                case DirectionType.None:
                    break;
            }
            transform.position += moveAmount;
        }
    }
}

移動方向を示すDirectionプロパティはpublic setとなっていますので、キーボードやコントローラの入力に応じて外部から変更していくイメージです。

テストコード

テストコードは以下の通りです。

using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;

namespace HoloAppTestSample.Test
{
    public class MoveSpecifiedDirectionTest
    {
        class MoveUpDirection_TestScenario : MoveSpecifiedDirection, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            private void Start()
            {
                StartCoroutine(TestScenario());
            }

            private IEnumerator TestScenario()
            {
                // 移動方向を「上」に指定
                Direction = DirectionType.Up;
                // 1フレーム待機
                yield return null;
                
                // アサーション Y軸方向に動いていることを確認する
                Assert.That(transform.position.x, Is.EqualTo(0));
                Assert.That(transform.position.y, Is.Not.EqualTo(0));
                Assert.That(transform.position.z, Is.EqualTo(0));

                // ここまで到達したらテストは成功
                IsTestFinished = true;

                // 他のテストを実行中にもこのMonoBehaviourが動いてしまうので止める
                gameObject.SetActive(false);
            }
        }

        [UnityTest]
        public IEnumerator ObjectMoveUpDirection()
        {
            yield return new MonoBehaviourTest<MoveUpDirection_TestScenario>();
        }
    }
}

テスト対象であるMoveSpecifiedDirectionクラスを継承した用意しています。テストシナリオの書き方についてはMonoBehaviourの振る舞いに従う限り自由なのですが、今回はコルーチンとして表現しています。

こちらをPlayModeテストを実行すると、先ほど説明した通り、MoveUpDirection_TestScenarioがアタッチされたGameObjectが生成された状態で、テスト用の一時シーンが再生されます。

MoveUpDirection_TestScenarioはMonoBehaviourを継承するMoveSpecifiedDirectionクラスの派生ですので、もちろんStartメソッド内のコルーチンは動作します。このコルーチン内で、移動方向を指定といったコンポーネントに対する作用を与えて、そのときの振る舞いが正しいかどうかをアサーションを使ってテストしています。

このサンプルの問題点

今回のテスト対象のコンポーネントはUpdateメソッドしか使っておりませんので上記のようなテストコードでも問題なく動作しました。しかしコンポーネントを作る際にはStartメソッド内に初期化処理等をさせることが多いと思います。

using UnityEngine;

namespace HoloAppTestSample
{
    /// <summary>
    /// プロパティで示される方向に応じてオブジェクトを移動させるコンポーネント
    /// </summary>
    public class MoveSpecifiedDirection : MonoBehaviour
    {
        public enum DirectionType
        {
            None = 0,
            Up,
            Right,
            Down,
            Left
        }

        public DirectionType Direction { get; set; } = DirectionType.None;

        private void Start()
        {
            // 何らかの初期化処理
        }

        private void Update()
        {
            var moveAmount = Vector3.zero;
            switch (Direction)
            {
                case DirectionType.Up:
                    moveAmount = Vector3.up * 0.01f;
                    break;
                case DirectionType.Right:
                    moveAmount = Vector3.right * 0.01f;
                    break;
                case DirectionType.Down:
                    moveAmount = Vector3.down * 0.01f;
                    break;
                case DirectionType.Left:
                    moveAmount = Vector3.left * 0.01f;
                    break;
                case DirectionType.None:
                    break;
            }
            transform.position += moveAmount;
        }
    }
}

この状態で上記のテストコードを実行すると、テスト対象のStartメソッドはポリモーフィズムにより実行されません。このような事態を避けるためにはいくつか方法があるかと思います。

1. テスト対象のライフサイクルメソッドにprotected virtual修飾子をつける

~中略~
        protected virtual void Start()
        {
            // 何らかの初期化処理
        }

テストコードではStartメソッドをオーバーライドし、基底クラス、すなわちテスト対象コードのStartメソッドをコールするようにします。

namespace HoloAppTestSample.Test
{
    public class MoveSpecifiedDirectionTest
    {
        class MoveUpDirection_TestScenario : MoveSpecifiedDirection, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            protected override void Start()
            {
                StartCoroutine(TestScenario());
            }

            private IEnumerator TestScenario()
            {
                // 移動方向を「上」に指定
                Direction = DirectionType.Up;
                // 1フレーム待機
                yield return new WaitForSeconds(10f);
                
                // アサーション
                Assert.That(transform.position.x, Is.EqualTo(0));
                Assert.That(transform.position.y, Is.Not.EqualTo(0));
                Assert.That(transform.position.z, Is.EqualTo(0));

                // ここまで到達したらテストは成功
                IsTestFinished = true;

                // 他のテストを実行中にもこのMonoBehaviourが動いてしまうので止める
                gameObject.SetActive(false);
            }
        }

これで一応動作はするのですが、テストのためにプロダクションコードのアクセスレベルを変更しなくてはいけないというのがすごく嫌ですね。

2. テストコードでは対象コンポーネントで使用していないライフサイクル系メソッドを使用する
今回のサンプルの場合ですと、FixedUpdateやLastUpdateを使ってテストコードを書くということです。こちらも動作はしますが、テスト対象によってテストコードの書き方が変わってくるのでこれもちょっと嫌ですね。

3. UniRxを使ってプロダクションコードはUpdateをストリーム化する
プロダクションコードではUpdateを使わず、UniRxのUpdateAsObservableやObservable.EveryUpdateを使ってUpdate部分をストリーム化し、テストコードでUpdateを使うよう統一するというものです。
その場合、今回のテスト対象コードは以下の通りとなります。

using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace HoloAppTestSample
{
    public class MoveSpecifiedDirectionUniRx : MonoBehaviour
    {
        public enum DirectionType
        {
            None = 0,
            Up,
            Right,
            Down,
            Left
        }

        public DirectionType Direction { get; set; } = DirectionType.None;

        private void Start()
        {
            this.UpdateAsObservable()
                .Subscribe(_ =>
                {
                    var moveAmount = Vector3.zero;
                    switch (Direction)
                    {
                        case DirectionType.Up:
                            moveAmount = Vector3.up * 0.01f;
                            break;
                        case DirectionType.Right:
                            moveAmount = Vector3.right * 0.01f;
                            break;
                        case DirectionType.Down:
                            moveAmount = Vector3.down * 0.01f;
                            break;
                        case DirectionType.Left:
                            moveAmount = Vector3.left * 0.01f;
                            break;
                        case DirectionType.None:
                            break;
                    }
                    transform.position += moveAmount;
                })
                .AddTo(gameObject);
        }
    }
}

テストコードは以下の通りとなります。

using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;

namespace HoloAppTestSample.Test
{
    public class MoveSpecifiedDirectionUniRxTest
    {
        class MoveUpDirectionUniRx_TestScenario : MoveSpecifiedDirectionUniRx, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            private bool _isTestStarted = false;

            private void Update()
            {
                if(_isTestStarted) return;
                
                StartCoroutine(TestScenario());
                _isTestStarted = true;
            }

            private IEnumerator TestScenario()
            {
                // 移動方向を「上」に指定
                Direction = DirectionType.Up;
                // 1フレーム待機
                yield return null;
                
                // アサーション
                Assert.That(transform.position.x, Is.EqualTo(0));
                Assert.That(transform.position.y, Is.Not.EqualTo(0));
                Assert.That(transform.position.z, Is.EqualTo(0));

                // ここまで到達したらテストは成功
                IsTestFinished = true;

                // 他のテストを実行中にもこのMonoBehaviourが動いてしまうので止める
                gameObject.SetActive(false);
            }
        }

        [UnityTest]
        public IEnumerator ObjectMoveUpDirection()
        {
            yield return new MonoBehaviourTest<MoveUpDirectionUniRx_TestScenario>();
        }
    }
}

今のところ一番お気に入りの書き方です。すこしまどろっこしくなってはいますが、スニペットを使えば時間もかからないでしょうし、プロダクションコードのUpdateに該当する部分も可読性をあげやすいという副次効果?もあります。ある程度UniRxを習得しなくてはいけませんが、テストシナリオをコルーチンで書くというPlayModeの性質上、コルーチンとの連携が強力なUniRxを使えるようになれば柔軟なテストシナリオを書くこともできるようになるかと思いますので、学習コスト以上の効果は狙えると思います。

まとめ

PlayModeテストを使って、コンポーネントをテストする方法をまとめました。ドキュメントがほとんどありませんので、これが正しい使いかたなのか、ベストプラクティスは他にないのかとは思いますが、今はこのような使いかたをしています。

次回はMonoBehaviourTestを使ってHoloLensアプリのテストをする方法について書きたいと思います。特にAirTapやManipulationといったジェスチャーを伴う操作について、テストコードをどう書くのか説明できればいいなと思います。

今回のサンプルコードはGitHub上に公開しておりますので、興味のある方はご参照ください。