单元测试第 2 部分 - MonoBehaviours 单元测试

TOMEK PASZEK Anonymous
Jun 3, 2014|10 Min
单元测试第 2 部分 - MonoBehaviours 单元测试
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

正如我在上一篇博文《单元测试第一部分--照本宣科的单元测试》中所承诺的,这篇博文将专门讨论如何在设计MonoBehaviours时考虑到可测试性。MonoBehaviour是一种特殊的类,Unity 会以特殊的方式处理它。每次尝试实例化MonoBehaviour衍生工具时,都会收到警告,说不允许这样做。作为一个好童子军,你没有忽视警告(从长远来看,忽视警告是不好的!),你可能会问自己这样一个问题:那我怎样才能嘲笑MonoBehaviour呢?好消息是,您不必这样做!让我向你们介绍

谦卑对象模式

如果你已经尝试过编写测试,那么你可能已经遇到了一些单元测试的天敌,如用户界面、遗留代码、无法访问源代码的糟糕设计或具有高度并发性的区域。是什么原因导致这些部件难以测试?实现隔离:将测试内容与环境分开。有一些工具可以帮助处理遗留代码,但对于新代码来说,可以使用一种非常简单的模式:谦卑对象模式

这种图案的设计理念非常简单。无论何时要测试一个有任何难以测试的依赖关系的组件,都应将该组件的所有逻辑提取到一个单独的、解耦的(因此是可测试的)类中,然后引用它。换句话说,有问题的组件(其依赖性让测试作者苦不堪言)变成了一层非常薄的代码,其中的逻辑代码越少越好,所有逻辑操作都委托给新创建的类。

从测试间接依赖于不可测试组件的状态...

example-dependancy1

......在这种情况下,测试根本无法察觉到糟糕的代码(嗯,就是无法测试的代码):

差不多就是这样。老实说,这是没有问题的。

游戏与可测试性

是什么让游戏在代码和可测试性方面如此特别?测试游戏与测试其他软件有何不同?我个人认为,游戏是一种相当复杂的软件。如果说游戏和你日常使用的软件没有太大区别,那就太天真了。在游戏中(当然也有例外),你会看到光鲜亮丽的画面、背景音乐和其他精心设计的声音样本。游戏通常需要处理实时输入(可能来自各种来源)以及一系列输出设备(读取分辨率)。对于游戏而言,非功能性要求也会更加严格。Multiplayer 游戏需要可靠、同步的网络连接,同时,还要保持恒定帧频所需的性能。

这可能会形成一个涉及多种不同媒体和技术的复杂系统。对我来说,游戏一直都是软件终端产品的杰作,其中一些游戏渴望被公认为艺术品(既包括经典的、视觉上的,也包括技术上的、幕后的)。

统一性与可测试性

所有这些复杂性都会对代码架构产生影响。不幸的是,高性能架构通常与良好的代码设计背道而驰,这也是您在 Unity 中可能遇到的限制。其中一个必须以特殊方式设计的核心机制是MonoBehaviours机制。如果你曾想过为什么MonoBehaviours中的回调没有使用接口或继承来实现(也许这是常识),那是出于性能方面的考虑(参见 Lucas Meijer 在评论中的说明)。不用细说,这也不利于MonoBehaviours 的可测试性。由于无法使用 new 操作符实例化MonoBehaviour,因此几乎无法使用任何模拟框架。反正每次使用MonoBehaviour时,都会有很多事情在幕后进行,这可能不是一个好主意。拦截这种行为会产生很多问题。

您与可测试性

归根结底,一切都取决于你,取决于你编写可测试代码的动力有多大。许多方法都能解决同样的问题,但只有少数几种方法能很好地解决测试自动化问题。如果您想编写可测试的代码,有时您需要编写比您认为必要的更多的代码。如果您还在学习(难道我们不应该终生学习吗?)或刚刚踏上测试自动化的冒险之路,您可能会发现某些代码片段或设计假设是不必要的开销。然而,这些很快就会成为一种习惯,以至于当你开始使用专业自动化设计时,你甚至都不会注意到。

在这篇博文中,我承诺向大家展示一种设计MonoBehaviour 的方法,以便能够在之后对它们进行测试。这并不完全正确,因为我们不会测试MonoBehaviours本身。你可能已经知道如何在你的设计中实施谦卑对象模式,使其更具可测试性,不过,还是让我向你展示如何在一个真实项目中实施这个想法。

示例

让我们为本示例创建一个用例。想象一下,一个简单的玩家控制器负责操纵一艘飞船。为了简化示例,我们把它放在二维世界空间中。我们希望飞船能向各个方向飞行。它有一把枪,可以直接发射子弹(太空火箭?子弹的数量也受到弹夹容量的限制,因此一旦打光所有子弹,就需要重新装弹。为了增加趣味性,让飞船的移动速度取决于飞船的健康状况。

作为飞船控制器的MonoBehaviour可以是这样的:

图片

FixedUpdate回调中,我们读取输入内容,并根据用户按下的按钮执行相应操作。要在飞船周围移动,我们需要根据轴的方向以恒定的速度平移飞船的位置。正如您在代码中看到的,deltaXdeltaY变量是相乘的:Time.fixedDeltaTime、输入轴的值和速度常数,速度常数本身取决于健康水平。

Fire1事件(例如点击鼠标左键)中,我们要检查是否可以发射子弹。首先,我们需要在子弹夹里至少留有一颗子弹。其次,我们只允许飞船以一定的速度(本例中每半秒一次)进行射击。因此,我们要检查最后一颗子弹发射后过去了多长时间。如果没问题,我们就发射子弹。

Fire2事件将简单地重新装载子弹夹。

要为这种逻辑编写单元测试,我们需要克服两个问题。第一个,如前所述,是我们通过继承依赖的不可模拟的MonoBehaviour类。第二个问题对于实时软件来说更为普遍。我们的逻辑依赖于时间(发射率),这使得我们无法执行单元测试,因为我们无法截取 Unity 中的静态 Time 类。好消息是,这一切都可以迎刃而解。

让我们对代码进行一下重构,应用一些简单的方法提取重构,并牢记逻辑方法不应引用 Unity API(本例中为输入处理和子弹实例化)。if 语句中的时间依赖性也应提取到一个单独的方法中。最终结果应该大致是这样的:

example2

正如你所看到的,这里的FixedUpdate方法只是将用户的输入传递给执行逻辑部分的方法。发射率检查被提取到CanFire方法中,如果指定时间已过,该方法会生成 "true "结果。这种提取非常重要,因为它有助于我们以后编写单元测试。如果我们现在就能模拟SpaceshipMotor类,我们只需截取CanFire方法,让它返回 true 或 false 即可。这将使测试与时间无关。但由于SpaceshipMotor继承自Monobehaviour,我们无法对其进行模拟,因此需要应用谦卑对象模式。

如何做到这一点?我们只需将所有不使用 Unity API 的逻辑代码提取到一个单独的类中,并在SpaceshipMotor 中引入对它的引用。让我们再看看这个类,看看要提取什么。TranformPositionInstanciateBullet使用 Unity API,但其他所有功能都可以提取。我知道还有静态时间类,但让我稍后再谈。

在我们进行实际提取之前,最后要说明的是提取的逻辑如何与 Unity API 通信,而不依赖于它。这就是接口的作用所在。有逻辑的类将引用一个接口,我不会关心实际的实现。为了简单起见,我们可以直接在MonoBehaviour本身中实现这些接口!让我们来看看下面的两个班级:

示例3
示例4

让我们从SpaceshipMotor类开始。该类实现了一些接口,分别负责转换飞船的位置和实例化子弹。类本身有一个字段指向SpaceshipController,而 SpaceshipController现在实现了所有的逻辑。SpaceshipController类对SpaceshipMotor一无所知,它唯一能做的就是调用它所引用接口的方法。

Unity 不会序列化对接口的引用。如果您不在乎序列化,只需在构建SpaceshipController类时传递接口引用即可。否则,您可以在每次序列化后调用的OnEnable回调中设置引用。在此说明一下,整个SpaceshipMotor类都将以通常的方式序列化,丢失的只是接口引用。

您一定注意到了SpaceshipMotor 中的时间类引用。我知道我说过这里不应该引用 Unity API,但我还是把它留在了这里,以演示处理时间依赖关系的不同方法。理想情况下,我们只需将Time.time值作为参数传递给方法即可。

对于 UML 爱好者来说,这就是(简化的)UML 图表的最终结果:

example-uml1
单元测试

有了解耦的SpaceshipMotor类,我们就可以编写一些单元测试了。来看看其中一项测试:

示例5

测试证明,如果没有子弹了,就不能开火。测试本身是按照 "安排--行动--插入 "模式组织的。在安排部分,我们使用GetGunMockGetControllerMock方法创建对象模拟。GetControllerMock 除了创建一个 mock 之外,还重写了CanFire方法的行为,使其始终返回 true。这就消除了控制器对象对时间的依赖。接下来,我们将当前子弹编号设置为 0。然后,我们将 fire 应用于控制器类,并断言枪控制器接口上是否已调用Fire

项目中还有一些测试。您可以从这里下载并使用它。我使用NSubstitute作为模拟对象。我们还随Unity Test Tools 提供了一个版本。我们在这里讨论的三个版本的控制器都附在项目中。

今天就到这里吧。希望您喜欢阅读,祝您测试愉快!

托梅克