最近在我忙于我的最新项目时,我一直在思考,我如何能单元测试代码。我知道如果我先把它搁一边,在编写一大段游戏代码后,我可能再也不会回头来写测试了。
编写单元测试对我有两个挑战,首先,游戏不同于其他类型的软件,没有好的代码分段来处理好输入,以及图形/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写一个测试来修复它。这样你就不用回溯你的代码了。