TDD在Unity3D游戏项目开发中的实践
0x00 前言
关于TDD测试驱动开发的文章已经有很多了,但是在游戏开发尤其是使用Unity3D开发游戏时,却听不到特别多关于TDD的声音。那么本文就来简单聊一聊TDD如何在U3D项目中使用以及如何使用U3D 5.3.X之后版本已经集成的单元测试模块Editor Test Runner。
0x01 你好,TDD
TDD,测试驱动开发改变了我们常见的工作流程,不要求先写逻辑代码,反而要求先完成测试代码。待测试代码完成之后,我们再将目光转移到逻辑代码,根据测试的要求,完成逻辑代码,使之能够通过经过拆分后粒度已经很小的测试。这样做有什么好处呢?
- 要将任务拆分成可测试的各个测试用例,这就要求我们在完成逻辑代码时要将代码的功能尽可能细分,换句话说就是让一个类/方法只负责单一责任,当这个类/方法需要承担其他类型/方法的责任的时候,就需要分解这个类/方法。这就迫使我们要把程序设计成易于调用和可测试的,即迫使我们解除软件中的耦合。
- 更加适合应对需求的经常性变更。身处游戏开发行业的从业人员都不能否认的一点便是游戏开发中需求变更是一件不可避免甚至是必不可少的事情,而基于测试驱动开发的另一个好处便是一旦因为需求变更而出现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视觉方面的操作比较难以实施单元测试,所以我们单元测试的主要对象是逻辑操作以及数据存取的部分。
针对问题2,Unity5.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;
using NUnit.Framework;
[TestFixture]
public class HpCompTests
{
//测试被攻击之后伤害数值是否和预期值相等
[Test]
public void TakeDamage_BeAttacked_HpEqual()
{
HpComp health = new HpComp();
health.currentHp = 100;
health.TakeDamage(50);
Assert.AreEqual(50f, health.currentHp);
}
}
完成之后,我们就可以打开Unity 5.3.x中集成的单元测试模块来进行自动化测试了。
好了,本文到此就暂时打住了,之后有新的体验和想法,还会继续这个话题的总结,也欢迎各位讨论。