HoloLensのジェスチャー操作をテストする

投稿者: | 2018-04-02

前回はUnityのPlayModeテストにて、MonoBehaviourTestを使ってコンポーネントをテストする方法をまとめました。

今回はその続きとして、HoloLensアプリにおけるAirTapやマニピュレーションといったジェスチャーを操作の起点とするような処理をテストする方法についてまとめたいと思います。

なお、ジェスチャー操作を実装するためにMRTKを使う前提です。

AirTapのテスト

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

using HoloToolkit.Unity.InputModule;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace HoloAppTestSample
{
    public class TapToMoveDown : MonoBehaviour, IInputClickHandler
    {
        private bool _isTapped = false;

        public void OnInputClicked(InputClickedEventData eventData)
        {
            _isTapped = true;
        }

        private void Start()
        {
            // AirTapされたらオブジェクトが下に移動する
            this.UpdateAsObservable()
                .Where(_ => _isTapped)
                .Subscribe(_ => transform.position += Vector3.down * 0.01f);
        }
        
        // 以下のコードと等価
        /*
        private void Update()
        {
            if (!_isTapped) return;

            transform.position += Vector3.down * 0.01f;
        }
        */
    }
}

コメントにも書いてある通り、コンポーネントをアタッチしたオブジェクトをAirTapすると下方向へ移動を開始する、といったシンプルなものです。

続いてこのコンポーネントをテストするコードを、前回説明したMonoBehaviourTestを使って書いてみます。

using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using HoloToolkit.Unity.InputModule;
using UnityEngine;
using UnityEngine.EventSystems;

namespace HoloAppTestSample.Test
{

    public class TapToMoveDownTest
    {
        [UnityTest]
        public IEnumerator TappedObject_MoveDown()
        {
            yield return new MonoBehaviourTest<TappedObject_MoveDown_TestScenario>();
        }

        private class TappedObject_MoveDown_TestScenario : TapToMoveDown, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            private bool _isTestStarted = false;

            private void Update()
            {
                if(_isTestStarted) return;

                StartCoroutine(TestScenario());
                _isTestStarted = true;
            }

            private IEnumerator TestScenario()
            {
                yield return null;
                
                // 初期位置を確認する
                Assert.That(transform.position, Is.EqualTo(Vector3.zero));

                yield return null;

                // AirTapイベントハンドラに渡すダミーデータを作る
                var dummyData = new InputClickedEventData(EventSystem.current);
                dummyData.Initialize(
                    inputSource: null,
                    sourceId: 0,
                    tag: null,
                    pressType: InteractionSourcePressInfo.None,
                    tapCount: 1
                    );

                // ダミーデータをハンドラに渡し、疑似的にジェスチャーを再現する
                OnInputClicked(dummyData);

                yield return new WaitForSeconds(1f);

                // 下方向へ移動したことを確認する
                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;

                gameObject.SetActive(false);
            }
        }
    }
}

基本的には前回説明したMonoBehaviourTestを使ったテストと同じ書き方をしています。HoloLensアプリのジェスチャー操作特有の部分としては、ジャスチャー操作のイベントハンドラに渡すイベントデータのダミーを作っているところでしょうか。

MRTKでは、ジェスチャー操作用のインタフェースを実装するだけで、InputManagerがインタフェースに定義されているイベントハンドラをコールしてくれます。

(MRTKによるジェスチャー操作の実装方法については、過去の記事を参照してください。)

その際に、InputMangerからイベントデータ(AirTapの場合だとInputClickedEventData)が渡されるので、テストコードでも同等のデータを用意してやる必要があります。MRTKのジェスチャー関連のイベントデータクラスには、初期化用のInitializeメソッドが用意されているので、そちらを使うのがよいかと思います。データの中身についてはイベントハンドラ内でイベントデータを使わないのならば、nullや0といったデフォルト値など適当なものを使っても問題ありません。

Manipulationのテスト

続いてモックを使ったり、結合テストチックなテストといった、もう少し複雑な例をマニピュレーション操作で試してみましょう。

テスト対象のコード

まずはマニピュレーション操作でオブジェクトを移動するコードを作ります。

using HoloToolkit.Unity.InputModule;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace HoloAppTestSample
{

    public class ManipulateObject : MonoBehaviour, IManipulationHandler
    {
        private bool _isManipulating;
        [SerializeField] [Range(1.0f, 10.0f)] private float _multipler = 5.0f;

        // Use this for initialization
        private void Start()
        {
            this.UpdateAsObservable()
                .Where(_ => _isManipulating)
                .Subscribe(_ => transform.position += _smoothVelocity * _multipler);
        }

        // 以下のコードと透過
        /*
        private void Update()
        {
            if(!_isManipulating) return;

            transform.position += _smoothVelocity * _multipler;
        }
        */

        private Vector3 _lastNavigatePos = Vector3.zero;
        private Vector3 _navigateVelocity = Vector3.zero;

        // 前フレームからの手の位置の移動ベクトル
        private Vector3 _smoothVelocity = Vector3.zero;

        public void OnManipulationStarted(ManipulationEventData eventData)
        {
            _isManipulating = true;
            InputManager.Instance.PushModalInputHandler(gameObject);
        }

        public void OnManipulationUpdated(ManipulationEventData eventData)
        {
            var eventPos = eventData.CumulativeDelta;

            _navigateVelocity = eventPos - _lastNavigatePos;
            _lastNavigatePos = eventPos;
            _smoothVelocity = Vector3.Lerp(_smoothVelocity, _navigateVelocity, 0.5f);
        }

        public void OnManipulationCompleted(ManipulationEventData eventData)
        {
            _isManipulating = false;
            ResetVectors();
            InputManager.Instance.PopModalInputHandler();
        }

        public void OnManipulationCanceled(ManipulationEventData eventData)
        {
            _isManipulating = false;
            ResetVectors();
            InputManager.Instance.PopModalInputHandler();
        }

        private void ResetVectors()
        {
            _lastNavigatePos = Vector3.zero;
            _navigateVelocity = Vector3.zero;
            _smoothVelocity = Vector3.zero;
        }
    }
}

何度か解説しているので詳細は避けますが、毎フレーム手の移動量を計算して、移動量に応じてオブジェクトを移動させる、といったコンポーネントです。

それにしてもこのコンポーネント、「手の移動量を計算する」と「オブジェクトを移動させる」といった二つの役割を持っているので、これを次のように分割したいと思います。

ManipulationDataProviderがマニピュレーションによる手の移動量を計算するコンポーネントです。MoveByManipulateはManipulateDataProviderが計算した手の移動量を使ってオブジェクトを移動させます。このときMoveByManipulateはManipulateDataProviderを直接参照するのではなく、手の移動量についてのプロパティを持つIManipulationDataProviderを経由して参照するようにしています。

具体的なコードは以下の通りです。

IManipulationDataProvider.cs

using UnityEngine;

namespace HoloAppTestSample
{
    public interface IManipulationDataProvider
    {
        bool IsManipulating { get; }
        Vector3 SmoothVelocity { get; }
    }
}

ManipulationDataProvider.cs

using HoloToolkit.Unity.InputModule;
using UnityEngine;

namespace HoloAppTestSample
{
    /// <summary>
    /// マニピュレーション中の指の移動ベクトルを提供するコンポーネント
    /// </summary>
    public class ManipulationDataProvider : MonoBehaviour, IManipulationHandler, IManipulationDataProvider
    {
        public bool IsManipulating { get; private set; } = false;
        public Vector3 SmoothVelocity { get; private set; } = Vector3.zero;

        private Vector3 _lastNavigatePos = Vector3.zero;
        private Vector3 _navigateVelocity = Vector3.zero;

        public void OnManipulationStarted(ManipulationEventData eventData)
        {
            IsManipulating = true;
            InputManager.Instance.PushModalInputHandler(gameObject);
        }

        public void OnManipulationUpdated(ManipulationEventData eventData)
        {
            var eventPos = eventData.CumulativeDelta;

            _navigateVelocity = eventPos - _lastNavigatePos;
            _lastNavigatePos = eventPos;
            SmoothVelocity = Vector3.Lerp(SmoothVelocity, _navigateVelocity, 0.5f);
        }

        public void OnManipulationCompleted(ManipulationEventData eventData)
        {
            IsManipulating = false;
            ResetVectors();
            InputManager.Instance.PopModalInputHandler();
        }

        public void OnManipulationCanceled(ManipulationEventData eventData)
        {
            IsManipulating = false;
            ResetVectors();
            InputManager.Instance.PopModalInputHandler();
        }

        private void ResetVectors()
        {
            _lastNavigatePos = Vector3.zero;
            _navigateVelocity = Vector3.zero;
            SmoothVelocity = Vector3.zero;
        }
    }
}

MoveByManipulate.cs

using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace HoloAppTestSample
{
    /// <summary>
    /// マニピュレーションデータを受け取ってオブジェクトを移動させるコンポーネント
    /// </summary>
    public class MoveByManipulate : MonoBehaviour
    {
        private IManipulationDataProvider _dataProvider;

        [SerializeField] [Range(1.0f, 10.0f)] private float _multipler = 5.0f;

        private void Start()
        {
            _dataProvider = GetComponent<IManipulationDataProvider>();

            this.UpdateAsObservable()
                .Where(_ => _dataProvider.IsManipulating)
                .Subscribe(_ => transform.position += _dataProvider.SmoothVelocity * 5f);
        }

        // 以下のコードと等価
        /*
        private void Update()
        {
            if(!_dataProvider.IsManipulating) return;

            transform.position += _dataProvider.SmoothVelocity * 5f;
        }
        */
    }
}

それでは各コンポーネントをテストしていきましょう。

MoveByManipulateのテスト

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

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

namespace HoloAppTestSample.Test
{
    public class MoveByManipulateTest
    {

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

        /// <summary>
        /// ManipulationDataProviderのモックコンポーネント
        /// </summary>
        private class ManipulationDataProviderMock : MonoBehaviour, IManipulationDataProvider
        {

            public bool IsManipulating { get; set; }
            public Vector3 SmoothVelocity { get; set; }
        }

        private class Move_WhenManipulationDataProvide_TestScenario : MoveByManipulate, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            private bool _isTestStarted = false;

            private void Awake()
            {
                // モックコンポーネントをテスト対象のGameObjectにアタッチする
                gameObject.AddComponent<ManipulationDataProviderMock>();
            }

            private void Update()
            {
                if (_isTestStarted) return;

                StartCoroutine(TestScenario());
                _isTestStarted = true;
            }

            private IEnumerator TestScenario()
            {
                // テスト対象からモックコンポーネントを取得する
                var dataProvider = gameObject.GetComponent<ManipulationDataProviderMock>();
                Assert.That(dataProvider, Is.Not.Null);

                yield return null;

                // モックコンポーネントを操作し、疑似的にマニピュレーションデータを設定
                dataProvider.IsManipulating = true;
                dataProvider.SmoothVelocity = new Vector3(0, 0, 1f);

                yield return null;

                // オブジェクトが移動していることを確認する
                Assert.That(transform.position.x, Is.EqualTo(0));
                Assert.That(transform.position.y, Is.EqualTo(0));
                Assert.That(transform.position.z, Is.Not.EqualTo(0));

                IsTestFinished = true;

                gameObject.SetActive(false);
            }
        }
    }
}

個別に解説します。

まず、MoveByManipulateコンポーネントはIManipulationDataProviderに依存しているので、テストコードでもこの依存関係を解決してあげる必要があります。IManipulationDataProviderはインタフェースですので、今回はこれを実装したモックコンポーネントを用意します。

        /// <summary>
        /// ManipulationDataProviderのモックコンポーネント
        /// </summary>
        private class ManipulationDataProviderMock : MonoBehaviour, IManipulationDataProvider
        {

            public bool IsManipulating { get; set; }
            public Vector3 SmoothVelocity { get; set; }
        }

インタフェースに定義されているプロパティに、setterも追加しただけのコンポーネントです。

(前節で説明したように、コンポーネントを分割する際にインタフェースを間に挟んでおくと、ポリモーフィズムを活かして自由にモックを作ることができるのでオススメです)

次にMonoBehaviourTestでテストシナリオを作っていきます。

        private class Move_WhenManipulationDataProvide_TestScenario : MoveByManipulate, IMonoBehaviourTest
        {
            public bool IsTestFinished { get; private set; }

            private bool _isTestStarted = false;

            private void Awake()
            {
                // モックコンポーネントをテスト対象のGameObjectにアタッチする
                gameObject.AddComponent<ManipulationDataProviderMock>();
            }

前回説した通り、MonoBehaviourTestではテスト対象を継承したコンポーネント(以下、テストコンポーネント)だけをアタッチした状態でシーンが再生されます。そこでテスト対象のStartメソッドが動く前に依存関係を解決するため、テストコンポーネントのAwakeメソッドにてモックコンポーネントをアタッチしています。するとテスト実行時でも、テスト対象のStartメソッド内のGetComponentによって依存関係が解決されるようになります。

MoveByManipulate.cs

    public class MoveByManipulate : MonoBehaviour
    {
        private IManipulationDataProvider _dataProvider;

        [SerializeField] [Range(1.0f, 10.0f)] private float _multipler = 5.0f;

        private void Start()
        {
            // ここで依存関係を解決
            _dataProvider = GetComponent<IManipulationDataProvider>();

            this.UpdateAsObservable()
                .Where(_ => _dataProvider.IsManipulating)
                .Subscribe(_ => transform.position += _dataProvider.SmoothVelocity * 5f);
        }

続いてテストシナリオのコルーチンです。

            private IEnumerator TestScenario()
            {
                // テスト対象からモックコンポーネントを取得する
                var dataProvider = gameObject.GetComponent<ManipulationDataProviderMock>();
                Assert.That(dataProvider, Is.Not.Null);

                yield return null;

                // モックコンポーネントを操作し、疑似的にマニピュレーションデータを設定
                dataProvider.IsManipulating = true;
                dataProvider.SmoothVelocity = new Vector3(0, 0, 1f);

                yield return null;

                // オブジェクトが移動していることを確認する
                Assert.That(transform.position.x, Is.EqualTo(0));
                Assert.That(transform.position.y, Is.EqualTo(0));
                Assert.That(transform.position.z, Is.Not.EqualTo(0));

                IsTestFinished = true;

                gameObject.SetActive(false);
            }

コメントに書いてある通り、テストシナリオにてモックコンポーネントを操作するため、テスト対象からGetCompoonentにてモックを取得しています。その後はモックコンポーネントのプロパティを操作して、疑似的にManipulateDataProviderからマニピュレーション中かどうかのフラグと移動量を取得したかのようにモックを振る舞わせて、その後テスト対象のコンポーネントによってオブジェクトが移動したかどうかをテストしています。

このようにMonoBehaviourTestではモックを使ったテストを実装することも可能です。

なお、ManipulationDataProviderについてはAirTapのテストコードと大して変わりないので割愛します。

マニピュレーション操作機能全体のテスト

それぞれのコンポーネントのテストが終わったら、各コンポーネントが連携してちゃんと意図した動作をするのかもテストしたくなりますよね。

また、PlayModeテストでは再生するシーンを指定してテストコードを動かすということも可能です。

というわけでシーン内に配置したオブジェクトがマニピュレーション操作によって移動するかをテストするコードを書いて機能が正しく実装されるかテストしてみました。

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using HoloToolkit.Unity.InputModule;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;

namespace HoloAppTestSample.Test
{

    public class ManipulateObjectFuntionTest
    {
        // テストの最初にテスト用に用意したシーンを用意する
        [SetUp]
        public void LoadTestScene()
        {
            SceneManager.LoadScene("manipulationTest");
        }

        [UnityTest]
        public IEnumerator ManipulateObject()
        {
            yield return null;

            var targetObject = GameObject.Find("TestTarget");
            var manipulationDataProvider = targetObject.GetComponent<ManipulationDataProvider>();
            var moveByManipulate = targetObject.GetComponent<MoveByManipulate>();

            Assert.That(targetObject, Is.Not.Null);
            Assert.That(manipulationDataProvider, Is.Not.Null);
            Assert.That(moveByManipulate, Is.Not.Null);

            yield return null;

            // マニピュレーションの開始
            manipulationDataProvider.OnManipulationStarted(null);

            yield return null;

            // マニピュレーションのダミーデータ
            var dummyData = new ManipulationEventData(EventSystem.current);
            dummyData.Initialize(
                inputSource: null,
                sourceId: 0,
                tag: null,
                cumulativeDelta: Vector3.one * 0.1f);

            // マニピュレーションのアップデート
            manipulationDataProvider.OnManipulationUpdated(dummyData);

            yield return null;
            
            // オブジェクトが移動したことを確認する
            Assert.That(targetObject.transform.position, Is.Not.EqualTo(Vector3.zero));

            yield return null;
            
            // マニピュレーションの終了
            manipulationDataProvider.OnManipulationCompleted(null);

            // マニピュレーション終了後のオブジェクトの位置
            var completedPos = targetObject.transform.position;

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

            // マニピュレーション終了後はオブジェクトの位置が変化しないことを確認する
            Assert.That(targetObject.transform.position, Is.EqualTo(completedPos));
        }
    }
}

こちらも個別に解説します。

まず、PlayModeテストではテスト実行時にSetUp属性が付与されたメソッドが最初にコールされます。ここではSceneManagerを使って、テスト用に作成したシーンをロードしています。

        // テストの最初にテスト用に用意したシーンを用意する
        [SetUp]
        public void LoadTestScene()
        {
            SceneManager.LoadScene("manipulationTest");
        }

テスト用シーンにはHoloLensカメラやInputManagerといったMRTKのいつものprefabを配置し、Cubeを今回作ったコンポーネントをアタッチした状態で配置しています。

続いてテストコードですが、今回はコンポーネントのテストではないのでMonoBehaviourTestを使わず、コルーチンにそのままシナリオを実装しています。

using UnityEngine;
using UnityEngine.TestTools;
using NUnit.Framework;
using System.Collections;
using HoloToolkit.Unity.InputModule;
using UnityEngine.EventSystems;
using UnityEngine.SceneManagement;

namespace HoloAppTestSample.Test
{

    public class ManipulateObjectFuntionTest
    {
        // テストの最初にテスト用に用意したシーンを用意する
        [SetUp]
        public void LoadTestScene()
        {
            SceneManager.LoadScene("manipulationTest");
        }

        [UnityTest]
        public IEnumerator ManipulateObject()
        {
            yield return null;

            var targetObject = GameObject.Find("TestTarget");
            var manipulationDataProvider = targetObject.GetComponent<ManipulationDataProvider>();
            var moveByManipulate = targetObject.GetComponent<MoveByManipulate>();

            Assert.That(targetObject, Is.Not.Null);
            Assert.That(manipulationDataProvider, Is.Not.Null);
            Assert.That(moveByManipulate, Is.Not.Null);

            yield return null;

            // マニピュレーションの開始
            manipulationDataProvider.OnManipulationStarted(null);

            yield return null;

            // マニピュレーションのダミーデータ
            var dummyData = new ManipulationEventData(EventSystem.current);
            dummyData.Initialize(
                inputSource: null,
                sourceId: 0,
                tag: null,
                cumulativeDelta: Vector3.one * 0.1f);

            // マニピュレーションのアップデート
            manipulationDataProvider.OnManipulationUpdated(dummyData);

            yield return null;
            
            // オブジェクトが移動したことを確認する
            Assert.That(targetObject.transform.position, Is.Not.EqualTo(Vector3.zero));

            yield return null;
            
            // マニピュレーションの終了
            manipulationDataProvider.OnManipulationCompleted(null);

            // マニピュレーション終了後のオブジェクトの位置
            var completedPos = targetObject.transform.position;

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

            // マニピュレーション終了後はオブジェクトの位置が変化しないことを確認する
            Assert.That(targetObject.transform.position, Is.EqualTo(completedPos));
        }
    }
}

今回作成した機能は、MRTKのInputManagerからのイベントハンドラのコールを起点に動作するものですので、AirTapのときと同様にイベントデータのダミーを渡して機能を動かし、オブジェクトの位置を確認することによりテストしています。

まとめ

HoloLensアプリにてユニットテストや機能テストを行う方法についてまとめました。テストケース毎にクラスを定義しなくてはならないなど、一般的なテストフレームワークに比べるとコストはかかりますが、全くテストを書かないで開発するよりはよっぽど良いと思います。

また、自作したコンポーネントだけでなくMRTKも含めた結合テストについても興味はありますが、テストコードが複雑化しそうなので、手動テストで代替するor組み合わせるといったことも考慮してテスト設計について考えたいと思います。

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