游戏邦在:
杂志专栏:
gamerboom.com订阅到鲜果订阅到抓虾google reader订阅到有道订阅到QQ邮箱订阅到帮看

分享以Unity进行游戏代码单元测试的方法

发布时间:2012-03-13 14:42:39 Tags:,

作者:Poya Manouchehri

当我最近开始致力于新的游戏项目开发时,我仔细思考了如何进行代码的单元测试。我知道,如果我将这一工作留到以后并紧接着使用相同的游戏代码创建其它内容,我可能不会意再回到这里花时间进行测试。

对我来说,编写单元测试需要面对两大挑战。首先也是最重要的是,游戏与其它类型的软件并不相同,它有一大部分专门处理输入,视觉效果/图像/用户界面(UI)的代码;而这两方面内容更是系统中最典型的“难以进行单元测试”的部分。

例如,你应该编写何种测试以检查你的手榴弹爆炸设置是否合理?(需要注意的是还有其它类型的测试能够适应于这种场景,或者至少不会让你的游戏逆行发展,不过它们并不属于单元测试)

而第二大挑战便是在Unity平台上进行单元测试。这便是我在本篇文章中将重点强调的内容。

Unit Tests(from planetgeek.ch)

Unit Tests(from planetgeek.ch)

单元测试框架

我一直在寻找适用于MonoDev和Unity的框架。并且我也发现了一些免费的解决工具,如NUnitLite,UUnit以及Sharp Unit。同时还有一些商用产品,如Test Star(具有更多功能)。

考虑到免费解决工具大多都是过时产品,并且具有一定的漏洞,同时也因为我当时的预算较为有限,我便决定自己创建合适的测试工具。我不打算在此详细说明这一工具,不过它的基本原理与NUnit差不多。

定义TestFixture以及SetUp等属性,并基于你的测试管理者的反应去寻找所有的测试类以及它们的测试方法,调用并寻找任何问题,并将其保存在一个清单列表中。最后在UI扩展中呈现这一清单中的内容。

扩展单元编辑器

在此我想对所有使用Unity的团队强调,这是一个能够帮助你轻松扩展UI的工具!我很惊讶自己竟然只执行了两种方法便快速将测试UI整合到编辑器菜单和窗口中,并且这也只是基于Unity的免费版本。

同时我也思考了其它能够用于添加的工具,包括数据编辑器。但是我遇到的一个问题是,我们不能在工作线程中运行MonoBehaviour代码,并且Repaint调用也不能帮助我们快速重新绘制UI。如此看来,我们也只能在运行了所有测试后才能更新测试结果。

所以我真正能够测试的是哪些内容?

这是个很难回答的问题。不过最根本的是,我们不可能测试所有问题。就像我之前提到的,涉及图像和视觉效果的代码并不适合进行单元测试。以下是我做出的一些结论,将帮助我们进行更深入的研究。

分离视觉元素的逻辑

具体地来说,纯粹的类并不是源于MonoBehaviour,并且也不是用于处理GameObjects以及其它特定的场景架构。就像是你的数据存取类;或者与你的AI相关联的类。

除此之外MonoBehaviour中还有更多微妙的类可助你将Update等方法中的逻辑重构成一个更易测试的Helper类。但是你也需要注意,这并不是意味着你不能在这些类中使用任何Unity类型,但如果它们依赖于场景,其它对象,组件以及状态,那么事情就变得有点棘手了。

说到数据存取,如果你曾经考虑过为游戏创建一个数据存取或数据库,你就需要保证它能够支持内存模式(因为对于测试来说这是最理想的模式)。你可以调用“:memory:”在运行测试期间断开连接字符串,绕开所有与文件处理有关的问题并加速测试。

测试MonoBehaviours

当然了,在MonoBehaviours中也存在你难以忽视的逻辑,你需要对其进行单独测试。现在如果你在自己的脚本中简单地实例化MonoBehaviour,你将会在控制台中看到如下问题:

你正尝试使用“新的”关键词创建MonoBehaviour。但这并不是可行做法,你只能够使用AddComponent()添加MonoBehaviours。你的脚本只能源自ScriptableObject,或者不从属于任何一个基本类。

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()方法为脚本创造了一个合适的预制对象,如果没有对应的内容它就会创建一个空白对象以及相关联的脚本类。然后便是有效地调用Start() 方法。如果你还使用了其它方法,如Awake(),那么你也需要调用它们。在这种情况下我们需要公开Awake/Start/Update方法,以便你能够在测试中进行调用。

这并非一桩易事,因为MonoBehaviour的初始化将会更加复杂,并且它所拥有的代码可能并不完整。但一般情况来看,这并不会有太大问题。

另外需注意的是,我将从资源文件夹中加载预制件,并且确保它们的名字始终与脚本相一致。而如果你面对的是一款较复杂的游戏,即在不同的预制件中使用相同的脚本作为一个组件,你就需要明确标注每一个预制件的名称。

除此之外,有时候你可能会只是出于测试目的而创建一个简单的预制件。这时候你就不应该将测试预制件保存在资源文件夹中(例如Assets/TestPrefabs文件夹),确保将它们不影响项目开发。

然后你需要在TearDown方法中调用CleanUp方法,以下是测试例子:

[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曾提到关于游戏开发的反复试验问题。尽管大多数软件都易于进行设计更改,但我认为它们远不及游戏所具有的灵活且容易改变的功能。因此,游戏开发更容易导致我们编写出大量频繁变化的测试,这也会成为一种累赘。

当然了,这里也存在着许多硬性规则。我们需要根据经验和实践,明确哪些代码是可以测试并且不会频繁改变,以及哪些内容不够稳定等。同时我们也需要牢记,所有的代码都必须经得起改变和测试,并且不要因为害怕变动而恐于编写测试。

最后,我们还需要知道,有些单元测试在开发后期阶段执行才会更有效,这个时候的改变也不会如此频繁。例如,当你在测试阶段遇到一个漏洞,你便只需要编写一个测试代码先运行漏洞再编写一个修改代码即可。这可以让你的代码摆脱逆行的结果。

本文为游戏邦/gamerboom.com编译,拒绝任何不保留版权的转载,如需转载请联系:游戏邦

In-depth: Unit testing in Unity

by Poya Manouchehri

When I started working on my new project recently, I was thinking a lot about how I can unit test the code. I knew that if I left it for later and built some momentum with the game code, I would probably never take the time to come back and write tests.

The challenge of writing unit tests for me is twofold. First and foremost, a game is different to many other types of software in that a good portion of the code is handling input, and visuals/graphics/UI. These are two notoriously “un-unit-testable” parts of a system.

How do you, for example, write a test that checks that your grenade explosion looks right? (Do note that there are other kinds of tests that can be appropriate for such scenarios and at least stop regression, but not unit tests)

The second challenge, is creating unit tests within Unity. In this post I’ll share some of my experiments and thoughts about unit testing with the Unity engine. This is certainly not a definitive guide on the subject, and I’m sure I’ve made mistakes. I simply want to open up the discussion on the subject. I also want to thank Richard Fine with his help and feedback on this!

A unit testing framework

I started looking around for a framework that I could use in MonoDev and Unity. There are in fact a couple of free solutions around like NUnitLite, UUnit, and Sharp Unit. There are also commercial products like Test Star that have more features.

Given the free solutions are fairly dated and have certain quirks, and I am budget conscious at the moment (at least those were my excuses), I decided to put something together myself. I won’t get into the details of it here, but it basically looks something like NUnit.

Define a few attributes like [TestFixture] and [SetUp], use reflection in your test manager to find all the test classes and their test methods, call and capture any errors and store them in a list. Finally a UI extension displays that list. I have linked to the source code here (note: I work with C#).

Unity editor extension

I really needed to give a shout-out here to the Unity team for making it so easy extend the UI! I was pleasantly surprised by how quickly I could bake the test UI right into the editor menus and windows with simply implementing two methods, and this is the free version of Unity (check out this article for a more thorough description. Thanks Richard!).

I am already thinking of all the other tools that I could add, including a data editor. One issue I did run into is that you cannot run MonoBehaviour code on a worker thread, and the calls to Repaint do not seem to repaint the UI immediately. As such, at the moment the test results only refresh after all tests have been run.

So what can I actually test?

The million dollar question. The bottom line is, it’s impossible to test everything. And as mentioned earlier, graphics and visuals related code are not really well suited for unit testing. Here are a few conclusions I have arrived at so far which might be helpful when going throught this.

Separating logic from visuals

More specifically, having pure classes that do not derive from MonoBehaviour and ideally do not deal with GameObjects and other scene specific constructs. Your data access class(es) are an example of this. Or your AI related classes.

There are more subtle cases where you can critically look at a MonoBehaviour and decide to refactor some of the logic inside the methods such as Update into a helper class that is more easily testable. Note that this doesn’t mean you cannot use any Unity types in such classes, however if they have dependency on the scene, other objects, components and their state, then things get a little tricky.

Speaking of data access, if you have been thinking about a data store for your game and sqlite is a viable option, do remember that that it supports an in-memory mode which is ideal for testing. Simply switch out the connection string during the test run with “:memory:” and you can bypass all the issues of dealing with files and speed up your tests.

Testing MonoBehaviours

Of course there is always logic in MonoBehaviours that you simply cannot take out and test in isolation. Now if you try to simply instantiate a MonoBehaviour in your script you will see this error in the console:

You are trying to create a MonoBehaviour using the ‘new’ keyword. This is not allowed. MonoBehaviours can only be added using AddComponent(). Alternatively, your script can inherit from ScriptableObject or no base class at all

A fair point. A MonoBehviour only really exists in the context of its parent object. If it’s not modifying anything about that object then it probably doesn’t need to be a MonoBehaviour. In order to get around this I put a simple utility class together, which looks something like this:

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();
}
}

The InstantiateScript() method creates an appropriate prefab object for the script, or if one is not available just creates an empty object and the associated script instance. The Start() method is then called where available. If you are using other methods like Awake() that also needs to be called. Awake/Start/Update methods need to be public in this case so you can call them from your tests.

I have to admit these are shaky waters because initialization of a MonoBehaviour is probably more complex and there may be situations where the code above is incomplete. But for the basic scenarios, this is fine.

Another thing to note here is that I am loading the prefabs from the resources folder, and their name always matches that of the script. In a more complex project where the same script is used as a component of different prefabs, you may want to explicitly pass in the name of the prefab.

Additionally there may be situations where you want to create a simplified prefab only for the purposes of testing. In those cases you should keep the test prefab in a folder outside of resources (e.g. Assets/TestPrefabs) to make sure it is removed from the production build.

The CleanUp method is called in you TearDown method to make sure the objects don’t stay around.

Here is an example test:

[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);
}

Not every test is a good test

One issue that Richard brought up during our discussion, was the trial-and-error nature of game development. Whilst most types of software are prone to design changes, I don’t think any of them involve the same level of going back and forth and tweaking features that games do. For that reason it is possible to write a bunch of tests that change very frequently and, in a way, become a burden.

Of course, there are many hard rules around this. Based on experience and just doing, we need to figure out what aspects of a piece of code can be tested and be expected not to change frequently, and what aspects are just too volatile. But we have to remember that all code can be subject to change and not be afraid of writing tests because we may need to change them.

It’s also good to remember that some unit tests are more useful later in the development cycle, at which point changes are less frequent. For example when a bug report comes in during the beta phase, you can write a test that exercises the bug and then write a fix. This way you are protecting your code against regression.(source:GAMASUTRA)


上一篇:

下一篇: