关于TDD测试驱动开发的文章已经有很多了但是在游戏开发尤其是使用Unity3D开发游戏时却听不到特别多关于TDD的声音。那么本文就来简单聊一聊TDD如何在U3D项目中使用以及如何使用U3D 5.3.X之后版本已经集成的单元测试模块Editor Test Runner。0x01 你好TDDTDD测试驱动开发改变了我们常见的工作流程不要求先写逻辑代码反而要求先完成测试代码。待测试代码完成之后我们再将目光转移到逻辑代码根据测试的要求完成逻辑代码使之能够通过经过拆分后粒度已经很小的测试。这样做有什么好处呢要将任务拆分成可测试的各个测试用例这就要求我们在完成逻辑代码时要将代码的功能尽可能细分换句话说就是让一个类/方法只负责单一责任当这个类/方法需要承担其他类型/方法的责任的时候就需要分解这个类/方法。这就迫使我们要把程序设计成易于调用和可测试的即迫使我们解除软件中的耦合。更加适合应对需求的经常性变更。身处游戏开发行业的从业人员都不能否认的一点便是游戏开发中需求变更是一件不可避免甚至是必不可少的事情而基于测试驱动开发的另一个好处便是一旦因为需求变更而出现bug能够很快的发现进而解决问题。单元测试是一种无价的文档它是展示方法或类如何使用的最佳文档。这份文档是可编译、可运行的并且它保持最新永远与代码同步。0x02 流程驱动为了进行TDD测试驱动开发我们需要了解TDD的流程或者说技巧大体上可以将其步骤简单的归纳为红灯-绿灯-重构。但是测试是什么测试是谁执行的测试又是如何驱动开发的呢下面我们就通过一个小例子来聊一聊这个问题。程序是什么简单的说就是一段有预期输出的代码。我们可以执行这段程序并获得程序的输出。而所谓的测试便是这样的一段程序它会自动调用执行另一段需要被测试的代码在这里我们依靠一些测试框架来实现例如针对C#的测试框架NUnit并且根据输出的可见结果来验证某些假设是否成立例如输出的结果证明假设成立则测试通过。简单的了解了测试之后我们通过一个小例子来看看测试驱动开发的思路和流程是怎样的并且一探“驱动”的具体含义。红灯下面我们就利用NUnit来编写我们的第一个测试来看看测试是如何驱动开发的//测试被攻击之后伤害数值是否和预期值相等[Test]public void TakeDamage_BeAttacked_HpEqual(){HpComp health new HpComp();health.currentHp 100;health.TakeDamage(50);Assert.AreEqual(50f, health.currentHp);}首先可以看到测试代码的方法名很长而且测试名中还包括下划线来保证我们不会漏掉关于这个测试的重要信息被测试的方法_测试进行的条件_预期结果因为在编写测试代码时可读性是重要的考量之一。继续看测试代码我们现在测试的类是HpComp它包括一个字段currentHp保存了现在的血量值还有一个方法TakeDamage。最开始我们会将currentHp初始化为100之后调用TakeDamage方法最后使用NUnit的Assert类所提供的静态方法AreEqual来断言假设是否成立也即判断是否通过测试。此时由于我们还没有声明一个叫HpComp的类来处理和血量相关的逻辑也没有一个叫currentHp的字段来保存现在的血量更没有一个叫TakeDamage的方法因此我们运行这个测试的结果便是失败。换言之我们现在处于红灯阶段。绿灯测试写完了此时是红灯而此时将这个红灯变成绿灯的要求便驱使着我们进行开发。所幸的是我们要开发的内容已经在测试中体现了出来实现一个叫做HpComp的类为HpComp增加一个字段currentHp用来保存现在的血量实现一个叫做TakeDamage的方法而在这个测试中事实上只要求TakeDamage方法将currentHp的值变成50即可。只要满足这3点我们就可以很轻易的使红灯变成绿灯。所以为了满足测试条件我们可以十分简单粗暴的写出如下的代码public class HpComp{public float currentHp;public void TakeDamage(float damage){this.currentHp 50f;}}好了在上面的测试代码中只要调用TakeDamage方法currentHp的值便被设置为了50和断言中的预期符合因此测试通过状态也由红灯变成了绿灯。当然我们简单的实现就通过了第一个测试此时如果有优化代码的需求我们就需要对代码进行重构使得代码更加干净。再来几次我们的第一个测试用例驱动开发出的代码显然满足了第一个测试的需求但是如果我们重新回到原点并且思考一下除了满足第一个测试中提供的数据我们的代码还能做什么如果换一个测试条件结果会变得怎样呢我们来完成一个新的测试//测试被攻击之后伤害数值是否和预期值相等[Test]public void TakeDamage_BeAttacked_HpEqual2(){HpComp health new HpComp();health.currentHp 150;health.TakeDamage(10);Assert.AreEqual(140f, health.currentHp);}这是一个新的测试暂时叫做测试2这就意味着TakeDamage方法除了通过第一个测试之外还必须通过这个新的测试2。此时我们最初的TakeDamage的实现显然无法通过测试2因此测试2是红灯状态。这也就是说随着我们的测试增加会带来更多的预期和要求从而驱动我们开发出满足这些预期和要求的代码来。随着测试2的出现我们将TakeDamage方法编程了下面这个样子public void TakeDamage(float damage){this.currentHp - damage;}这样它不仅通过了测试1同时也通过了测试2。但是如果我们重复上面的流程提出更多的测试呢也许我们还会发现TakeDamage方法可能会出现越界的情况或者是输入不合法的情况等等。当然这些都可以通过更多的测试来驱动我们开发出更健康的代码。TDD流程小结通过上面的小例子我们可以看到TDD的流程或者说开发技巧并不难理解编写一个会失败的测试以证明产品中的代码或功能的缺陷。编写符合测试预期的代码。重构代码如果测试通过了就可以选择重构目标是使代码的可读性更强、减少重复代码。如果不重构则可以开始编写下一个测试即重复第4步。重复以上过程。0x03 问题方案由于游戏开发和传统软件开发之间的差异因此在开发游戏的过程中编写单元测试会面临两个主要的问题1.游戏开发中会涉及到很多的I/O操作处理以及视觉和UI的处理而这个部分是单元测试中比较难以处理的部分。2.具体到使用Unity3D开发游戏我们自然而然的希望能够将测试的框架集成到Unity3D的编辑器中这样更加容易操作。针对问题1由于对I/O处理以及UI视觉方面的操作比较难以实施单元测试所以我们单元测试的主要对象是逻辑操作以及数据存取的部分。针对问题2Unity5.3.x已经在editor中集成了测试模块。该测试模块依托了NUnit框架NUnit是一个单元测试框架,专门针对于.NET来写的.其实在前面有JUnit(Java),CPPUnit(C),他们都是xUnit的一员.最初,它是从JUnit而来.U3d使用的版本是2.6.4。而且除了Unity5.3.x自带的单元测试模块之外Unity官方还推出了一款测试插件Unity Test Tool基于NSubstitute。0x04 实践U3D中的单元测试在Untiy编辑器中写单元测试编写单元测试用例时使用的主要是Unity Editor自带的单元测试模块因此单元测试是基于NUnit框架的。这就要求编写单元测试时要引入NUnit.Framework命名空间且单元测试类要加上[TestFixture]属性单元测试方法要加上[Test]属性并将测试用例的文件放在Editor文件夹下。测试用例的编写结构要遵循3A原则即Arrange, Act, Assert。即先要设置测试环境例如实例化测试类为测试类的字段赋值。之后操作对象即写测试的行为。最后是断言某件事情是预期的即判断是否通过测试。下面是一个例子using UnityEngine;using System.Collections;