在Unity中编写单元测试

最近在我忙于我的最新项目时,我一直在思考,我如何能单元测试代码。我知道如果我先把它搁一边,在编写一大段游戏代码后,我可能再也不会回头来写测试了。

编写单元测试对我有两个挑战,首先,游戏不同于其他类型的软件,没有好的代码分段来处理好输入,以及图形/UI。这是两块众所周知的“不好单元测试”的系统部分。

举个例子,你如何编写出测试代码来检测你游戏中的手雷爆炸效果是否正确?(请注意有其他的测试能处理好这种情况,至少能不去回溯,但它们并不是单元测试)

第二个挑战是,在Unity中做单元测试。在这篇文章中我将分享一些我对用Unity引擎做单元测试的实验和想法。这并不是这个主题的最终解,我相信我所写的有所不足。我只是单纯地想展开对这一主题的讨论。

单元测试框架

一开始我想找出一个框架让我用在MonoDev和Unity中。实际上有许多免费的解决方案如NUnitLite、UUnit以及Sharp Unit。也有一些商用的产品如Test Star,它有更多的功能。

所列出的免费方案已经很过时并有些难用,而我又同时觉得预算很紧(反正是我的借口而已),我决定自己把这些综合起来。这里我不打算讲的太细,但它基本上和NUnit差不多。

首先定义一些诸如[TestFixture]和[SetUp]这样的属性,在你的测试管理器中用映射找出所有测试的类和它们的的测试方法,调用并捕获任何错误并将它们保存在一条列表中。最后用一个UI扩展来显示这个列表。我在这里给出源码的链接地址(注:我用的是C#)

Unity的编辑器扩展

这里我真要大呼爽快,因为Unity的开发团队把其做得十分易于扩展UI!我兴奋地发现我可以很快地就把测试UI搞定到编辑器的菜单和窗口中,并且只要简单地执行两个方法,而这还是免费版的Unity。

我已在考虑把所有能添加的工具都加上,包括数据编辑器。这里我遇到一个问题,你不能在一个工作线程上运行MonoBehaviour代码,而调用Repaint并不能立即重画UI。同样地,测试的结果只能在所有测试都被运行后才可显示出来。

那么我能实际地测试些什么呢?

这是一个数百万美元的问题。基本上,它不能测试每个东西。而之前也提过,与图形相关的代码并不太适合做单元测试。这里有一些我目前获得的结果,或许对你用这个有所帮助。

独立于图形的逻辑

更具体的说,就是保持纯粹的类不属于MonoBehaviour,最好不处理和GameObjects以及其他特殊场景的结构。你的数据访问类就是一个例子,或者是与AI相关的类。

也有许多更为详细的例子,你可以通过它们来观察一个MonoBehaviour并决定是否重构方法里的一些逻辑,比如更新到一个帮助的类里让其更容易测试。请注意,这里并不是说你无法在这些类中使用任何Unity类型,如果它们从属于场景、其他对象、组建以及它们的状态,那么事情就变得有些诡异了。

讲到数据访问,如果你考虑为你的游戏建立个数据存储,那么sqlite是个可行的选择。请谨记它支持内存内模式,这极易测试。简单地在测试中运行“:memory:”来断开连接符,你就可以绕过所有的文件处理问题,来加速你的测试。

测试MonoBehavious

当然在MoniBehavious中总有你不能单独拿出来测试的东西。如果你试着简单地在你的脚本中实例化一个MonoBehaviour,你会在控制台里看到这样的错误:

你正用关键词‘new‘来创建MonoBehaviour。这是不被允许的。MonoBehaviours仅能通过使用AddComponent()来添加。另外,你的脚本可以继承于ScipatableObject或者根本不需要基于什么类。

MonoBehviour仅能存在于它的父对象中的上下文。如果你不修改该对象,那么就不需要一个MonoBehaviour。为了避开这个,我把一个简单的实用类合在一起,看起来像这样:

public class ScriptInstantiator

{

  private List GameObjects { get; set; }

  public ScriptInstantiator()

  {

    GameObjects = new List();

  }

  public T InstantiateScript<T>() where T : MonoBehaviour

  {

    GameObject gameObject;

    object prefab = Resources.Load("Prefabs/" + typeof(T).Name);

    // If there is no prefab with the same name, just use an empty object

    //

    if (prefab == null)

    {

      gameObject = new GameObject();

    }

    else

    {

      gameObject = GameObject.Instantiate(Resources.Load("Prefabs/"

         + typeof(T).Name)) as GameObject;

    }

    gameObject.name = typeof(T).Name + " (Test)";

    // Prefabs should already have the component

    T inst = gameObject.GetComponent<T>();

    if (inst == null)

    {

      inst = gameObject.AddComponent<T>();

    }

    // Call the start method to initialize the object

    //

    MethodInfo startMethod = typeof(T).GetMethod("Start");

    if (startMethod != null)

    {

      startMethod.Invoke(inst, null);

    }

    GameObjects.Add(gameObject);

    return inst;

  }

  public void CleanUp()

  {

    foreach (GameObject gameObject in GameObjects)

    {

      // Destroy() does not work in edit mode

      GameObject.DestroyImmediate(gameObject);

    }

    GameObjects.Clear();

  }

}

 

InstantiateScript()方法为脚本建立一个适当的prefab对象,或如果一个不能用的话仅创建一个空对象,然后相关联的脚本为其实例化。Start()方法在可行时被调用。如果你用其他比如Awake()的方法,它同样也需要被调用。Awake/Start/Update方法需要在这个例子里声明为public,这样你才能在你的测试中调用到它们。

我需要承认这些不稳定,因为要初始化一个MonoBehaviour可能更加复杂,而以上代码有些情况下是不完整的。但于简单的测试上,这些可用。

另一个要提的是,这里我是从资源文件夹里载入prefabs的,它们的命名同脚本中的一致。在更复杂的项目里,同样的脚本可能用于各种不同的prefabs组件,你需要仔细输对prefab的名称。

另外可能你会想只创建一个简单的prefab来测试。那么这样的话,你应该把测试的prefab放在资源文件夹外面(例如:Assets/TestPrefabs),确保它在出产编译时被移除。

CleanUp方法会在你的TearDown方法内被调用,来确保对象被清除。

这里是一个测试的例子:

[Test]

public void MovingEntitiesUpdatesConnector()

{

  var source = ScriptInstantiator.InstantiateScript<Entity>();

  var target = ScriptInstantiator.InstantiateScript<Entity>();

  var connector = ScriptInstantiator.InstantiateScript<Connector>();

  connector.SetSourceEntity(source);

  connector.SetTargetEntity(target, true);

  source.transform.position = new Vector3(-10.0f, 0.0f, 0.0f);

  target.transform.position = new Vector3(0.0f, 10.0f, 0.0f);

  connector.Update();

  Assert.IsTrue(Vector3.Distance(connector.transform.position,

     source.transform.position) < 0.01f)

  Assert.IsTrue(Vector3.Distance(connector.EndPoint,

     target.transform.position) < 0.01f);

}

没有完美的测试

在我们的讨论中,Richard提出一个问题,那就是游戏开发本来就是一个不停调试错误的过程。大部分种类的软件易于改变设计,但我不认为它们能像游戏那样从前到后的需要调整。所以能写出一大堆测试来频繁的修改,这会成为负担的。

当然,对于此还有许多苛刻的条件。基于经验和能用主义,我们需要找出那块代码能被测试,并期望它们可以不要被频繁地修改,也要找出一些特别不稳定的代码。但我们要记住,所有的代码能写出来就不要怕写测试代码,因为我们或许需要修改它们。

同样也要知道,单元测试会在之后的开发周期里非常有用,这样修改起来就不怎么频繁了。例如当bug在测试语句中报告出来,你可以为bug写一个测试来修复它。这样你就不用回溯你的代码了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值