如何在 Unity 游戏引擎平台上的单元测试中实例化 MonoBehaviour 对象

Posted

技术标签:

【中文标题】如何在 Unity 游戏引擎平台上的单元测试中实例化 MonoBehaviour 对象【英文标题】:How to instantiate MonoBehaviour objects in a unit test on Unity Game Engine platform 【发布时间】:2016-01-10 17:32:18 【问题描述】:

我在 Github (game project) 上有以下开源项目。我目前正在尝试对使用 MSTest 框架编写的代码进行单元测试,但所有测试都返回相同的错误消息:“未处理的异常:System.Security.SecurityException:ECall 方法必须打包到系统模块中。”这发生在我尝试使用 NUnit 模板进行单元测试时。

我看过 ECall methods post must be packaged 找到一些答案,但我没有,因为 OP 说他的解决方案在调试器区域内但不在调试器区域内工作。就我而言,在查看帖子时,OP的问题没有得到解决。

之后,我在我的项目中导入了 UnityTestTools 框架。认为这很容易,因为它基于 NUnit 框架。事实证明,没有。测试本身是相当基础的。我有这个基类,称为 BaseCharacterClass:MonoBehavior,除其他外,它具有 BaseCharacterStats 类型的属性。在统计数据中,有一个 CharacterHealth 类型的对象,它负责照顾玩家的健康。

现在,当我在测试中尝试以下内容时,我似乎没有得到以下两个堆栈跟踪。

单元测试 (NUNIT)

    使用 new 关键字创建 MonoBehavior 对象

    [Test]
    [Category("Mock Character")]
    public void Mock_Character_With_No_Health()
    
        var mock = new MoqBaseCharacter ();
        Assert.NotNull (mock.BaseStats);
        Assert.NotNull (mock.BaseStats.Health);
        Assert.LessOrEqual (0, mock.BaseStats.Health.CurrentHealth);
    
    //This is not the full file
    //There "2" classes: 1 for holding tests and that Mock object 
    public MoqBaseCharacter()
    
        this.BaseStats = new BaseCharacterStats ();
        this.BaseStats.Health = new CharacterHealth (0);
    
    

堆栈跟踪

Mock_Character_With_No_Health (0.047s) --- System.NullReferenceException:对象引用未设置为对象的实例 --- 在 Assets.Scripts.CharactersUtil.CharacterHealth..ctor (Int32 sh) [0x0002f] 中 C:\Users\Kevin\Documents\androidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\Scripts\CharactersUtil\CharacterHealth.cs:29

在 UnityTest.MoqBaseCharacter..ctor () [0x00011] 中 C:\Users\Kevin\Documents\AndroidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\UnityTestTools\Examples\UnitTestExamples\Editor\SampleTests.cs:14

在 UnityTest.SampleTests.Mock_Character_With_No_Health () [0x00000] 中 C:\Users\Kevin\Documents\AndroidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\UnityTestTools\Examples\UnitTestExamples\Editor\SampleTests.cs:32

    使用 NSubstitute.For

    [Test]
    [Category("Mock Character")]
    public void Mock_Character_With_No_Health()
    
        var mock = NSubstitute.Substitute.For<MoqBaseCharacter> ();
        Assert.NotNull (mock.BaseStats);
        Assert.NotNull (mock.BaseStats.Health);
        Assert.LessOrEqual (0, mock.BaseStats.Health.CurrentHealth);
    
    

堆栈跟踪

Mock_Character_With_No_Health (0.137s) --- System.Reflection.TargetInvocationException :调用的目标已抛出异常。 ----> System.NullReferenceException:对象引用未设置为 对象的实例 --- 在 System.Reflection.MonoCMethod.Invoke (System.Object obj, BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] 参数,System.Globalization.CultureInfo 文化) [0x0012c] 在 /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System.Reflection/MonoMethod.cs:519

在 System.Reflection.MonoCMethod.Invoke (BindingFlags invokeAttr, System.Reflection.Binder 绑定器,System.Object[] 参数, System.Globalization.CultureInfo 文化)[0x00000] 在 /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System.Reflection/MonoMethod.cs:528

在 System.Activator.CreateInstance(System.Type 类型,BindingFlags bindingAttr, System.Reflection.Binder binder, System.Object[] args, System.Globalization.CultureInfo 文化,System.Object[] 激活属性)[0x001b8] 在 /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System/Activator.cs:338

在 System.Activator.CreateInstance(System.Type 类型,System.Object[] args, System.Object[] activationAttributes) [0x00000] in /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System/Activator.cs:268

在 System.Activator.CreateInstance(System.Type 类型,System.Object[] args) [0x00000] 在 /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System/Activator.cs:263

在 Castle.DynamicProxy.ProxyGenerator.CreateClassProxyInstance (System.Type proxyType, System.Collections.Generic.List`1 proxyArguments, System.Type classToProxy, System.Object[] constructorArguments) [0x00000] in :0

在 Castle.DynamicProxy.ProxyGenerator.CreateClassProxy (System.Type classToProxy,System.Type[] 附加InterfacesToProxy, Castle.DynamicProxy.ProxyGenerationOptions 选项,System.Object[] constructorArguments, Castle.DynamicProxy.IInterceptor[] 拦截器) [0x00000] 在 :0

在 NSubstitute.Proxies.CastleDynamicProxy.CastleDynamicProxyFactory.CreateProxyUsingCastleProxyGenerator (System.Type typeToProxy, System.Type[] 附加接口, System.Object[] constructorArguments, IInterceptor 拦截器, Castle.DynamicProxy.ProxyGenerationOptions proxyGenerationOptions) [0x00000] 在 :0

在 NSubstitute.Proxies.CastleDynamicProxy.CastleDynamicProxyFactory.GenerateProxy (ICallRouter callRouter, System.Type typeToProxy, System.Type[] AdditionalInterfaces, System.Object[] constructorArguments) [0x00000] 在:0

在 NSubstitute.Proxies.ProxyFactory.GenerateProxy (ICallRouter callRouter, System.Type typeToProxy, System.Type[] AdditionalInterfaces, System.Object[] constructorArguments) [0x00000] 在:0

在 NSubstitute.Core.SubstituteFactory.Create (System.Type[] typesToProxy, System.Object[] constructorArguments, SubstituteConfig config) [0x00000] in :0

在 NSubstitute.Core.SubstituteFactory.Create (System.Type[] typesToProxy, System.Object[] constructorArguments) [0x00000] in :0

在 NSubstitute.Substitute.For (System.Type[] typesToProxy, System.Object[] constructorArguments) [0x00000] in :0

在 NSubstitute.Substitute.For[MoqBaseCharacter] (System.Object[] constructorArguments) [0x00000] in :0

在 UnityTest.SampleTests.Mock_Character_With_No_Health () [0x00000] 中 C:\Users\Kevin\Documents\AndroidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\UnityTestTools\Examples\UnitTestExamples\Editor\SampleTests.cs:32 --NullReferenceException

在 Assets.Scripts.CharactersUtil.CharacterHealth..ctor (Int32 sh) [0x0002f] 在 C:\Users\Kevin\Documents\AndroidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\Scripts\CharactersUtil\CharacterHealth.cs:29

在 UnityTest.MoqBaseCharacter..ctor () [0x00011] 中 C:\Users\Kevin\Documents\AndroidPC_Prototype\PC_Augmented_Tactics_Demo\Assets\UnityTestTools\Examples\UnitTestExamples\Editor\SampleTests.cs:14

在 Castle.Proxies.MoqBaseCharacterProxy..ctor (ICallRouter , Castle.DynamicProxy.IInterceptor[] ) [0x00000] in :0

at(包装器托管到本机) System.Reflection.MonoCMethod:InternalInvoke (object,object[],System.Exception&)

在 System.Reflection.MonoCMethod.Invoke (System.Object obj, BindingFlags invokeAttr,System.Reflection.Binder 绑定器, System.Object[] 参数,System.Globalization.CultureInfo 文化) [0x00119] 在 /Users/builduser/buildslave/mono-runtime-and-classlibs/build/mcs/class/corlib/System.Reflection/MonoMethod.cs:513

免责声明

对NSubstitute 的快速阅读告诉我,我应该更好地为潜艇使用接口......在我的情况下,我真的不知道接口如何更适合我的代码。如果有人对此有想法而不是使用 new 关键字,我全力以赴!最后,这是 BaseCharacter、BaseStats 和 Health 的源代码

基本字符实现

using System;
using UnityEngine;
using System.Collections.Generic;
using JetBrains.Annotations;
using Random = System.Random;

namespace Assets.Scripts.CharactersUtil

    public class BaseCharacterClass : MonoBehaviour
    
        //int[] basicUDLRMovementArray = new int[4];

        public List<BaseCharacterClass> CurrentEnnemies; 
        public int StartingHealth = 500;
        public BaseCharacterStats BaseStats  get; set; 

        // Use this for initialization
        private void Start()
        
            BaseStats = new BaseCharacterStats Health = new CharacterHealth(StartingHealth); //Testing purposes
            BaseStats.ChanceForCriticalStrike = new Random().Next(0,BaseStats.CriticalStrikeCounter);
        

        // Update is called once per frame

        private void Update()
        
            //ExecuteBasicMovement();

        

        //During an attack with any kind of character
        //TODO: Make sure that people from the same team cannot attack themselves (friendly fire)
        private void OnTriggerEnter([NotNull] Collider other)
        
            if (other == null) throw new ArgumentNullException(other.tag);
            Debug.Log("I'm about to receive some damage");
            var characterStats = other.gameObject.GetComponent<BaseCharacterClass>().BaseStats;
            var heathToAddOrRemove = other.gameObject.tag == "Healer" || other.gameObject.tag == "AIHealer" ? characterStats.Power : -1 * characterStats.Power;
            characterStats.Health.TakeDamageFromCharacter((int)heathToAddOrRemove);
            Debug.Log("I should have received damage from a bastard");
            if (characterStats.Health.CurrentHealth == 500)
            
                Debug.Log("This is a mistake, I believe I'm a god! INVICIBLE");
            
        

        /*
        public void ExecuteBasicMovement()
        
            var move = new Vector3(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"), 0);
            transform.position += move * BaseStats.Speed * Time.deltaTime;
        

        //TODO: Make sure players moves correctly within the environment per cases
        public void ExecuteMovementPerCase()
        
        
        */

        public bool CanDoExtraDamage()
        
            if (BaseStats.ChanceForCriticalStrike*BaseStats.Luck < 50) return false;
            BaseStats.CriticalStrikeCounter--;
            BaseStats.ChanceForCriticalStrike = new Random().Next(0, BaseStats.CriticalStrikeCounter);
            BaseStats.AjustCriticalStrikeChances(); 
            return true;
        
    

基础数据

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using JetBrains.Annotations;

namespace Assets.Scripts.CharactersUtil

    public class BaseCharacterStats
    
        public float Power  get; set; 
        public float Defense  get; set; 
        public float Agility  get; set; 
        public float Speed  get; set;  
        public float MagicPower  get; set; 
        public float MagicResist  get; set; 
        public int ChanceForCriticalStrike;
        public int Luck  get; set; 
        public int CriticalStrikeCounter = 20;
        public int TemporaryDefenseBonusValue;
        private Random _randomValueGenerator;

        public BaseCharacterStats()
        
            _randomValueGenerator= new Random();
        

        [NotNull]
        public CharacterHealth Health
        
            get  return _health; 
            set  _health = value; 
        
        private CharacterHealth _health;

        public void AjustCriticalStrikeChances()
        
            if (CriticalStrikeCounter <= 5)
            
                CriticalStrikeCounter = 5;
            
        

        public int DetermineDefenseBonusForTurn()
        
            TemporaryDefenseBonusValue = _randomValueGenerator.Next(10,20);
            return TemporaryDefenseBonusValue;
        
    

健康

using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.UI;

namespace Assets.Scripts.CharactersUtil

    public class CharacterHealth 
        public int StartingHealth  get; set; 
        public int CurrentHealth  get; set; 
        public Slider HealthSlider  get; set; 
        public bool isDead;
        public Color MaxHealthColor = Color.green;
        public Color MinHealthColor = Color.red;
        private int _counter;
        private const int MaxHealth = 200;
        public Image Fill;


        private void Awake() 
            //HealthSlider = GameObject.GetComponent<Slider>();
            _counter = MaxHealth;            // just for testing purposes
        
        // Use this for initialization

        public CharacterHealth(int sh)
        
            StartingHealth = sh;
            CurrentHealth = StartingHealth;
            HealthSlider.wholeNumbers = true; 
            HealthSlider.minValue = 0f;
            HealthSlider.maxValue = StartingHealth;
            HealthSlider.value = CurrentHealth; 
        

        public void Start()
        
            HealthSlider.wholeNumbers = true; 
            HealthSlider.minValue = 0f;
            HealthSlider.maxValue = MaxHealth;
            HealthSlider.value = MaxHealth;  
        

        public void TakeDamageFromCharacter([NotNull] BaseCharacterClass baseCharacter)
        
            CurrentHealth -= (int)baseCharacter.BaseStats.Power;
            HealthSlider.value = CurrentHealth;
            UpdateHealthBar ();
            if (CurrentHealth <= 0)
                isDead = true;
        

        public void TakeDamageFromCharacter(int characterStrength)
        
            CurrentHealth -= characterStrength;
            HealthSlider.value = CurrentHealth;
            UpdateHealthBar ();
            if (CurrentHealth <= 0)
                isDead = true;
        

        public void RestoreHealth(BaseCharacterClass bs)
        
            CurrentHealth += (int)bs.BaseStats.Power;
            HealthSlider.value = CurrentHealth;
            UpdateHealthBar ();
        
        public void RestoreHealth(int characterStrength)
        
            CurrentHealth += characterStrength;
            HealthSlider.value = CurrentHealth;
            UpdateHealthBar ();
        
        public void UpdateHealthBar() 
            Fill.color = Color.Lerp(MinHealthColor, MaxHealthColor, (float)CurrentHealth / MaxHealth);
        
    

【问题讨论】:

我可能是错的,但问题本身很可能是由于您正在尝试创建 MonoBehavior 对象的新实例。你永远不应该自己做,因为 Unity 需要为你做这件事,因为有很多底层组件需要放在正确的位置才能让一切正常工作。所以在写单元测试的时候,不能自己测试MonoBehaviors,你必须把“核心游戏逻辑”放在各自的类中,单独测试;并且在单一行为中只有“图形游戏逻辑”。然后你就可以运行你的单元测试了:-) 另一件事是,您在 CharacterHealth 类的构造函数中引用了“HealthSlider”,这是一个统一的 UI 滑块;并且您没有在代码中的任何位置设置引用。所以那个对象是NULL。并且会抛出 NullPointerException。 我应该如何修改我写的代码? 基本上,我的 BaseCharacterClass(所有人类玩家和我的 AI 类都继承的类)应该是 MonoBehaviour 子类吗? @KarlPatrikJohansson 你的 BaseCharacterClass 不应该是 MonoBehavior,而是将所有需要成为 monobehavior 的代码放在它自己的 MonoBehavior 中,并从中引用 BaseCharacterClass。例如,当获取另一个玩家的统计数据时(在您的OnTriggerEnter 中),您将改为使用var characterStats = other.gameObject.GetComponent&lt;CharacterWrapperScript&gt;().BaseCharacterClass.BaseStats;。并将 BaseCharacterClass 的逻辑与您从 MonoBehavior 获得的交互分开。 【参考方案1】:

基本字符

用于单元测试的类

public class BaseCharacterClass 

    public BaseCharacterStats BaseStats  get; set; 
    public BaseCharacterClass(int startingHealth) 
    
        BaseStats = new BaseCharacterStats Health = new CharacterHealth(startingHealth); //Testing purposes
        BaseStats.ChanceForCriticalStrike = new Random().Next(0,BaseStats.CriticalStrikeCounter);
    

    public bool CanDoExtraDamage() 
    
        if (BaseStats.ChanceForCriticalStrike*BaseStats.Luck < 50) return false;
        BaseStats.CriticalStrikeCounter--;
        BaseStats.ChanceForCriticalStrike = new Random().Next(0, BaseStats.CriticalStrikeCounter);
        BaseStats.AjustCriticalStrikeChances(); 
        return true;
    

用于您的角色/AI/NPCS 的新 MonoBehavior 脚本

using System;
using UnityEngine;
using System.Collections.Generic;
using JetBrains.Annotations;
using Random = System.Random;

namespace Assets.Scripts.CharactersUtil

    public class BaseCharacterClassWrapper : MonoBehaviour
    
        //int[] basicUDLRMovementArray = new int[4];

        public List<BaseCharacterClass> CurrentEnnemies; 
        public int StartingHealth = 500;        

        public BaseCharacterClass CharacterClass;


        public CharacterHealthUI HealthUI;

        // Use this for initialization
        private void Start()
        
            CharacterClass = new BaseCharacterClass(StartingHealth);  
            HealthUI = this.GetComponent<CharacterHealthUI>();
            HealthUI.CharacterHealth = CharacterClass.BaseStats.Health;
        

        // Update is called once per frame

        private void Update()
        
            //ExecuteBasicMovement();
        

        //During an attack with any kind of character
        //TODO: Make sure that people from the same team cannot attack themselves (friendly fire)
        private void OnTriggerEnter([NotNull] Collider other)
        
            if (other == null) throw new ArgumentNullException(other.tag);
            Debug.Log("I'm about to receive some damage");

            var characterStats = other.gameObject.GetComponent<BaseCharacterClassWrapper>().CharacterClass.BaseStats;

            var healthToAddOrRemove = other.gameObject.tag == "Healer" || other.gameObject.tag == "AIHealer" ? characterStats.Power : -1 * characterStats.Power;

            characterStats.Health.TakeDamageFromCharacter((int)healthToAddOrRemove);

            Debug.Log("I should have received damage from a bastard");

            if (characterStats.Health.CurrentHealth == 500)
            
                Debug.Log("This is a mistake, I believe I'm a god! INVICIBLE");
            
        

        /*
        public void ExecuteBasicMovement()
        
            var move = new Vector3(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"), 0);
            transform.position += move * BaseStats.Speed * Time.deltaTime;
        

        //TODO: Make sure players moves correctly within the environment per cases
        public void ExecuteMovementPerCase()
        
        
        */



        public bool CanDoExtraDamage()
        
            return CharacterClass.CanDoExtraDamage();
        
    

健康

将此用于您的健康 UI

using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.UI;

namespace Assets.Scripts.CharactersUtil

    public class CharacterHealthUI : MonoBehavior 
      public Image Fill;
      public Color MaxHealthColor = Color.green;
      public Color MinHealthColor = Color.red;   
      public Slider HealthSlider;

      private void Start() 
          if(!HealthSlider) 
            HealthSlider = this.GetComponent<Slider>();            
          
          if(!Fill) 
            Fill = this.GetComponent<Image>();
                    
      

      private CharacterHealth _charaHealth;
      public CharacterHealth CharacterHealth  
        get  return _charaHealth; 
        set  
        if(_charaHealth!=null)
            _charaHealth.HealthChanged -= HealthChanged;
          _charaHealth = value; 
          _charaHealth.HealthChanged += HealthChanged;
        
      

      public HealthChanged(object sender, HealthChangedEventArgs hp) 
            HealthSlider.wholeNumbers = true; 
            HealthSlider.minValue = hp.MinHealth;
            HealthSlider.maxValue = hp.MaxHealth;
            HealthSlider.value = hp.CurrentHealth;  
            Fill.color = Color.Lerp(MinHealthColor, MaxHealthColor, (float)hp.CurrentHealth / hp.MaxHealth);
      

    


最后,你的健康逻辑 :-)

using JetBrains.Annotations;
using UnityEngine;
using UnityEngine.UI;

namespace Assets.Scripts.CharactersUtil
      

    public class HealthChangedEventArgs : EventArgs 
    
        public float MinHealth  get; set; 
        public float MaxHealth  get; set; 
        public float CurrentHealth  get; set;
        public HealthChangedEventArgs(float minHealth, float curHealth, float maxHealth) 
            MinHealth = minHealth;
            CurrentHealth = curHealth;
            MaxHealth = maxHealth;
        
    


    public class CharacterHealth 
        public int StartingHealth  get; set; 

        private int _currentHealth;
        public int CurrentHealth 
         
          get  return _currentHealth;  
          set  
              _currentHealth = value;
              if(HealthChanged!=null)
                HealthChanged(this, new HealthChangedEventArgs(0f, _currentHealth, MaxHealth);
            
              

        public bool isDead;

        private int _counter;
        private const int MaxHealth = 200;

        public event EventHandler<HealthChangedEventArgs> HealthChanged;

        // Use this for initialization

        public CharacterHealth(int sh)
        
            StartingHealth = sh;
            CurrentHealth = StartingHealth;
        

        public void TakeDamageFromCharacter([NotNull] BaseCharacterClass baseCharacter)
        
            CurrentHealth -= (int)baseCharacter.BaseStats.Power;        
            if (CurrentHealth <= 0)
                isDead = true;
        

        public void TakeDamageFromCharacter(int characterStrength)
        
            CurrentHealth -= characterStrength;
            if (CurrentHealth <= 0)
                isDead = true;
        

        public void RestoreHealth(BaseCharacterClass bs)
        
            CurrentHealth += (int)bs.BaseStats.Power;
        
        public void RestoreHealth(int characterStrength)
        
            CurrentHealth += characterStrength;
        
    

这应该使您可以对游戏逻辑进行单元测试:-)

虽然我还没有测试过,所以我不能肯定它会起作用。但从逻辑上讲(至少在我的脑海中)它应该。

最大的不同是你想在你的游戏对象上使用BaseCharacterClassWrapperCharacterHealthUI 来实现想要的行为。然后单元测试继续BaseCharacterClassCharacterHealth

我希望这会有所帮助!

【讨论】:

所以,如果我做对了,我的单元测试,在这种情况下,我所要做的就是定位 BaseCharacter 和 CharacterHealth 但是当我将脚本提供给 GameObjects 时,我提供了 Wrapper 和 UI脚本^ 从 CharacterHealth 中,您删除了 Image 和 Slider 字段以及健康栏将如何更新为当前健康状况。我该怎么办? 我添加了一个名为 HealthChanged 的​​事件,它会在每次更改健康状况时触发,这也会为您更新 Healthbar(您的滑块)。所以你不必在你的健康逻辑中考虑那部分。隔离不同区域,使逻辑本身可测试:-)【参考方案2】:

还有另一个选项可以在不调用构造函数的情况下对 MonoBehaviours 进行单元测试(使用 FormatterServices)。这是一个创建可测试的 MonoBehaviours 的小助手类:

public static class TestableObjectFactory 
    public static T Create<T>() 
        return FormatterServices.GetUninitializedObject(typeof(T)).CastTo<T>();
    

用法:

var testableObject = TestableObjectFactory.Create<MyMonoBehaviour>();
testableObject.Test();

【讨论】:

以上是关于如何在 Unity 游戏引擎平台上的单元测试中实例化 MonoBehaviour 对象的主要内容,如果未能解决你的问题,请参考以下文章

unity安卓开发尺寸

学Unity3D游戏开发需要了解哪些内容

unity3d 怎样发布android程序

如何使用Unity做游戏中的寻路导航

Unity游戏引擎都有哪些优势

unity学习啥最重要?