使用 NUnit 为 Unity3D 编写高质量单元测试

0x00 单元测试 Pro & Con

最近尝试在我参与的游戏项目中引入TDD(测试驱动开发)的开发模式,所以单元测试便变得十分必要。这篇博客就来聊一聊这段时间的感悟和想法。因为游戏开发和传统软件开发之间的差别,所以在开发游戏,特别是使用Unity3D开发游戏的过程当中编写单元测试每每会面临两个主要的问题:html

  1. 游戏开发中会涉及到不少的I/O操做处理,以及视觉和UI的处理,而这个部分是单元测试中比较难以处理的部分。git

  2. 具体到使用Unity3D开发游戏,咱们天然而然的但愿可以将测试的框架集成到Unity3D的编辑器中,这样更加容易操做。程序员

可是,单元测试的好处也十分多。github

  1. TDD,测试驱动开发。编写单元测试将使咱们从调用者观察、思考。特别是先写测试,迫使咱们把程序设计成易于调用和可测试的,即迫使咱们解除软件中的耦合。能够将任务的粒度下降。固然TDD是否适合游戏开发尚有争论,可是单元测试的必要性是无需置疑的。框架

  2. 单元测试是一种无价的文档,它是展现方法或类如何使用的最佳文档。这份文档是可编译、可运行的,而且它保持最新,永远与代码同步。编辑器

  3. 更加适合应对需求的常常性变动。身处游戏开发行业的从业人员都不可否认的一点即是游戏开发中需求变动是一件不可避免甚至是必不可少的事情,而单元测试另外一个好处即是一旦由于需求变动而出现bug,可以很快的发现,进而解决问题。工具

0x01 Unity3D 中经常使用的测试工具

针对问题 1,因为对I/O处理以及UI视觉方面的操做比较难以实施单元测试,因此咱们单元测试的主要对象是逻辑操做以及数据存取的部分。布局

针对问题 2,Unity5.3.x 已经在editor中集成了测试模块。该测试模块依托了NUnit框架(NUnit是一个单元测试框架,专门针对于.NET来写的。其实在前面有 JUnit(Java),CPPUnit(C++),他们都是xUnit的一员。最初,它是从JUnit而来,U3d使用的版本是2.6.4)。
在Unity Editor中实现测试而不是在IDE中进行测试的缘由在于,一些Unity的API须要在Unity的环境中来运行,而没法直接在外部的IDE中实现,例如实例化GameObject。单元测试

并且除了 Unity5.3.x 自带的单元测试模块以外,Unity官方还推出了一款测试插件Unity Test Tool(基于NSubstitute),除了单元测试以外还包括:测试

  1. 单元测试

  2. 集成测试

  3. 断言组件

须要指出的是Unity Test Tool基于NSubstitute这个库。

0x02 初识单元测试

既然本文的主题是单元测试,那么咱们就必须先对单元测试下一个定义:

一个单元测试是一段自动化的代码,这段代码调用被测试的工做单元,以后对这个单元的单个最终结果的某些假设进行检验。单元测试使用单元测试框架编写,并要求单元测试可靠、可读而且可维护。只要产品代码不发生变化,单元测试的结果是稳定的。

既然有了单元测试的定义,下面咱们就尝试在Unity项目中写单元测试吧。

一个单元测试的小例子

编写单元测试用例时,使用的主要是Unity Editor自带的单元测试模块,所以单元测试是基于NUnit框架的。
借助NUnit,咱们能够:

  1. 编写结构化的测试。

  2. 自动执行选中的或所有的单元测试。

  3. 查看测试运行的结果。

所以这就要求编写Unity3D项目的单元测试时,要引入 NUnit.Framework 命名空间,且单元测试类要加上[TestFixture]属性,单元测试方法要加上[Test]属性,并将测试用例的文件放在Editor文件夹下。
下面是一个例子:

using UnityEngine;
using System.Collections;
using NUnit.Framework;

[TestFixture]
public class HpCompTests
{
  //测试被攻击以后伤害数值是否和预期值相等
  [Test]
  public void TakeDamage_BeAttacked_HpEqual()
{
  //Arrange
      HpComp health = new HpComp();
      health.currentHp = 100;
     //Act
      health.TakeDamage(50);
     //Assert
      Assert.AreEqual(50f, health.currentHp);
    }
}

该例子是测试英雄受到伤害以后,血量是否和预期的相等。

测试框架会建立这个测试用例类,而且调用TakeDamage_BeAttacked_HpEqual方法来和其交互,最后使用Nunit的Assert类来断言是否经过测试。

0x03 单元测试的结构

经过上面的小例子,咱们能够发现单元测试实际上是有结构的。下面咱们就来具体分析一下:

使用NUnit提供的特性来标识测试代码

NUnit使用C#的特性机制识别和加载测试。这些特性就像是书签,用来帮助测试框架识别哪些部分是须要调用的测试。
若是要使用NUnit的特性,咱们须要在测试代码中首先引入NUnit.Framework命名空间。

而NUnit运行器至少须要两个特性才知道须要运行什么。

  1. [TestFixture]:标识一个自动化 NUnit 测试的类。

  2. [Test]:能够加在一个方法上,标识这个方法是一个须要调用的自动化测试。

固然,还有一些别的特性供咱们使用,来方便咱们更好的控制测试代码,例如[Category]特性能够将测试分类、[Ignore]特性能够忽略测试。

经常使用的NUnit属性见下表:

[SetUp] 
[TearDown]  
[TestFixture] 
[Test] 
[TestCase] 
[Category] 
[Ignore]

测试命名和布局标准

  • 测试类的命名:对应被测试项目中的一个类,建立一个名为[ClassName]Tests的类。

  • 工做单元的命名:对每一个工做单元(测试),测试方法的方法名由三部分组成,而且按照以下规则命名:[被测试的方法名]_[测试进行的假设条件]_[对测试方法的预期]。

具体来讲:

  1. 被测试的方法名

  2. 测试进行的假设条件,例如“登入失败”、“无效用户”、“密码正确”。

  3. 对测试方法的预期:在测试场景指定的条件下,咱们对被测试方法的行为的预期。

其中,对测试方法的预期会有三种可能的结果:

  1. 返回一个值(数值、布尔值等等)。

  2. 改变被测试的系统的一个状态。

  3. 调用一个第三方系统。

能够看出,咱们的测试代码在格式上与标准的代码有所不一样,测试名能够很长,可是在编写测试代码时,可读性是最为重要的方面之一,而测试名中的下划线能够令咱们不会遗漏全部的重要信息,咱们甚至能够将测试方法名当作一个句子来读,这样就会使得这个测试方法的测试目标、场景以及预期都十分明确,无需额外的注释。

测试单元的行为——3A原则

有了NUnit属性能够标识能够自动运行的测试代码和测试代码的一些命名规则,下面咱们就来看看如何测试本身的代码。
一个单元测试一般包含三个行为,能够概括为3A原则即:

  1. Arrange:准备对象,建立对象并进行必要的设置

  2. Act:操做对象

  3. Assert:断言某件事情是预期的

下面是以前的那段简单的代码,包含了以上的NUnit的属性、命名规范以及3A原则下的行为,其中断言部分使用了NUnit框架提供的Assert类,被测试的类为HpComp,被测试的方法为TakeDamage。

using NUnit.Framework;

[TestFixture]
public class HpCompTests
{
  //测试被攻击以后伤害数值是否和预期值相等
  [Test]
  public void TakeDamage_BeAttacked_HpEqual()
{
  //Arrange
      HpComp health = new HpComp();
      health.currentHp = 100;
     //Act
      health.TakeDamage(50);
     //Assert
      Assert.AreEqual(50f, health.currentHp);
    }
}

单元测试的断言——Assert类

NUnit框架提供了一个Assert类来处理断言的相关功能。Asset类用于声明某个特定的假设应该成立,所以若是传递给Assert类的参数和咱们断言(预期)的值不一样,则NUnit框架会认为测试没有经过。

Assert类会提供一些静态方法,供咱们使用。

例如:

Assert.AreEqual(预期值,实际值);
Assert.AreEqual(1,2 - 1);

关于Assert类的静态方法,各位能够直接在代码中看。

0x04 单元测试的可靠性

咱们的目标是写出可靠、可维护、可读的测试。

所以,除了遵循单元测试结构规范编写单元测试以外,咱们还须要注意可靠性、可维护性以及可读性这些方面。所以,一些原则咱们也须要注意。

不轻易删除和修改测试

一旦测试写好了而且经过了,就不该该轻易的修改和删除这些测试。由于这些测试是对应系统代码的保护伞,在修改系统代码时,这些测试会告诉咱们修改后的代码是否会破坏已有的功能。

尽可能避免测试中的逻辑

随着测试中的逻辑增多,测试代码出现缺陷的概率也会增大。并且因为咱们每每相信测试是可靠的,所以一旦测试出现缺陷咱们每每不会首先考虑是测试的问题,可能会浪费时间去修改系统代码。而单元测试中,最好保持逻辑的简单,所以尽可能避免使用下面的逻辑控制代码。

  1. switch、if

  2. foreach、for、while

一个单元测试应该是一系列的方法调用和断言,可是不该该包含控制流语句。

只测试一个关注点

在一个单元测试中验证多个关注点会使得测试代码变得复杂,但却没有价值。相反,咱们应该在分开的、独立的单元中验证多余的关注点,这样才能发现真正致使失败的地方。

0x05 单元测试的可维护性

去除重复代码

和系统中的重复代码同样,在单元测试中重复代码一样意味着测试对象某方面改变时要修改更多的测试代码。

若是测试看上去都同样,仅仅是参数不一样,那么咱们彻底可使用参数化测试即便用[TestCase]特性将不一样的数据做为参数传入测试方法。

实施测试隔离

所谓的测试隔离,指的是一个测试和其余的测试隔离,甚至不知道其余测试的存在,而只在本身的小世界中运行。

将测试隔离的目的是防止测试之间的互相影响,常见的测试之间互相影响的状况能够总结以下:

  1. 强制的测试顺序:测试要以某种顺序执行,后一个测试须要前面的测试结果,这种状况有可能会致使问题的缘由是由于NUnit不能保证测试按照某种特定的顺序执行,所以今天经过的测试,明天可能就很差用了

  2. 隐藏的测试调用:测试调用其余测试

  3. 共享状态被破坏:测试要共享状态,可是在一个测试完成以后没有重置状态,进而影响后面的测试

0x06 单元测试的可读性

正如概述中所说单元测试是一种无价的文档,它是展现方法或类如何使用的最佳文档。所以,可读性这条要求的重要性即可见一斑。试想一下即使是几个月以后别的程序员均可以经过单元测试来理解一个系统的组成以及使用方法,并可以很快的理解他们要作的工做以及在哪里切入。

单元测试命名

在单元测试的结构中已经有过要求和介绍。参考那部分。

单元测试中的变量命名

经过合理的命名变量,能够提升可读性,使得阅读测试的人员能够尽快的理解你要验证的内容。

仍是看看上面的例子

[Test]
public void TakeDamage_BeAttacked_HpEqual()
{
//Arrange
    HpComp health = new HpComp();
    health.currentHp = 100;
   //Act
    health.TakeDamage(50);
   //Assert
    Assert.AreEqual(50f, health.currentHp);
}

这段代码中的断言使用了一个魔数50,可是这个数字并无使用描述性的名字,所以咱们没法尽快的知道这个数字预期的是什么。所以,咱们尽量不要直接使用数字和结果比较,而是使用一个有意义命名的变量来和结果进行比较。

[Test]
public void TakeDamage_BeAttacked_HpEqual()
{
    HpComp health = new HpComp();
    health.currentHp = 100;

    health.TakeDamage(50);

    float leftHp = 50f;

    Assert.AreEqual(leftHp, health.currentHp);
}

0x07 在Untiy编辑器中写单元测试

在Unity编辑器中编写单元测试用例时,使用的主要是Unity编辑器自带的单元测试模块,所以单元测试是基于NUnit框架的。

这就要求编写单元测试时,要引入NUnit.Framework命名空间,且单元测试类要加上[TestFixture]属性,单元测试方法要加上[Test]属性,并将测试用例的文件放在Editor文件夹下。

测试用例的编写结构要遵循 3A 原则,即 Arrange, Act, Assert。即先要设置测试环境,例如实例化测试类,为测试类的字段赋值。以后写测试的行为,最后是判断是否经过测试。

下面是一个例子:

using UnityEngine;
using System.Collections;
using NUnit.Framework;

[TestFixture]
public class HealthComponentTests
{
  //测试伤害以后,血的值是否比0大
  [Test]
  public void TakeDamage_BeAttacked_BiggerZero()
    {
      //Arrange 
      UnMonoHealthClass health = new UnMonoHealthClass();
      health.healthAmount = 50f;

      //Act
      health.TakeDamage(60f);

      //Assert
        Assert.GreaterOrEqual(health.healthAmount, 0);
    }
}

该例子是测试英雄受到伤害以后,血量是否会越界出现负值。

测试框架会建立这个测试用例类,而且调用TakeDamage_BeAttacked_BiggerZero方法来和其交互,最后使用Nunit的Assert类来断言是否经过测试。

使用Editor Tests Runner开始单元测试

写完了单元测试用例以后,咱们就能够在Unity5.3.x的editor中开始单元测试了。如图所示:

在这里,咱们既能够跑单独的测试用例,也能够跑全部的测试用例,经过的是绿色标识,未经过的是红色标识。
而在最上面的一行,则是咱们能够操做的部分:

  • Run All:测试所有用例

  • Run Selected:测试选中的用例

  • Rerun Failed: 从新测试上一次未经过的测试用例

  • 搜索框:能够搜索用例

  • 种类过滤器:能够根据种类来筛选用例,种类须要在测试代码中使用CategoryAttribute来标识。

  • 测试结果筛选器:能够按照经过、失败以及忽略来筛选用例

在这里咱们还能够设置在编译前自动运行单元测试。

使用命令行运行单元测试

除了可以在Editor中使用单元测试,咱们天然更但愿可以将单元测试也归入自动集成的流水线中,所以有必要从U3D外部调用测试。不过好在U3D也提供了外部调用的方式,这样将单元测试也加入到咱们的自动集成的流水线中是可行的。
Unity3D 5.3.x版本中提供的命令行选项以下:

runEditorTests  必须,运行editor test的选项
editorTestsResultFile 用来保存测试结果
editorTestsFilter 根据用例名称,来运行指定的用例
editorTestsCategories 根据用例种类,来运行指定的用例
editorTestsVerboseLog 打印更加详细的日志
projectPath 工程目录

因此在命令行中开启测试能够这样写:

Unity -runEditorTests -projectPath /Users/fanyou/UnitTest -editorTestsResultFile  /Users/fanyou/UnitTest/test.xml -batchmode -quit

0x08 后记

以上即是关于在U3D中引入单元测试的一些思考,固然,游戏开发是否适合TDD,换言之是否要先写单元测试后实现功能是值得讨论的事情,可是单元测试自己是十分有必要在工程中使用的。在代码结构设计、往后的重构都会颇有帮助。

相关

TDD在Unity3D游戏项目开发中的实践

相关文章
相关标签/搜索