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

As promised in my previous blog post Unit testing part 1 – Unit tests by the book, this one is dedicated to designing MonoBehaviours with testability in mind. MonoBehaviour is kind of a special class that is handled by Unity in a special way. Every time you try to instantiate a MonoBehaviour derivative you will get a warning saying that it’s not allowed. Being a good boy-scout and not ignoring the warning (ignoring a warning is bad in the long term!) you might have asked yourself the question, how can I mock MonoBehaviour then? The good news is that you don’t have to! Let me introduce you to…

就像我之前在博客文章单元测试第1部分–书中的单元测试中所承诺的那样,这一部分致力于在设计时考虑到可测试性的MonoBehavioursMonoBehaviour是一种特殊的类,由Unity以特殊方式处理。 每次尝试实例化MonoBehaviour派生类时,都会收到一条警告,提示您不允许这样做。 作为一名优秀的童子军,不要忽视警告(长期来看,忽视警告是不好的!),您可能会问自己一个问题,那我该如何嘲笑MonoBehaviour ? 好消息是您不必这样做! 让我把你介绍给…

谦虚的对象模式 (The Humble Object Pattern)

If you’ve already tried to write tests, you’ve probably stumbled upon some of the natural enemies of unit testing like UI, legacy code, bad design with no source-code access or areas with a high degree of concurrency. What make these parts hard to test? Achieving isolation: separating what is being tested from the context. There are tools out there that can help for legacy code, but for new code a very simple pattern can be used: The Humble Object Pattern.

如果您已经尝试编写测试,那么您可能偶然发现了一些单元测试的天敌,例如UI,旧代码,没有源代码访问权限的不良设计或高度并发的区域。 是什么使这些零件难以测试? 实现隔离:将要测试的内容与上下文分开。 有一些工具可以帮助遗留代码,但是对于新代码,可以使用非常简单的模式:Humble Object Pattern。

The idea behind this pattern is very simple. Whenever you want to test a component that has any dependencies that are hard to test, extract all the logic from the component to a separate, decoupled (thus testable) class and then reference it. In other words, the problematic component (with a dependency that makes test authors’ lives miserable) becomes a very thin layer of code that has as little logic code as possible with all logic operations delegated to the newly created class.

这种模式背后的想法非常简单。 每当您要测试具有难以测试的任何依赖项的组件时,请将组件中的所有逻辑提取到单独的,已解耦(因此可测试)的类中,然后对其进行引用。 换句话说,有问题的组件(具有使测试作者的生活陷入困境的依赖)变成了非常薄的代码层,其逻辑代码越少越好,并将所有逻辑操作委派给新创建的类。

From a state where the test has an indirect dependency to the untestable component…

从测试与不可测试组件间接依赖的状态开始……

example-dependancy1

…we got to a state where the test is not even aware of the bad (well, just untestable) code:

…我们进入了一种状态,在该状态下,测试甚至没有意识到不良的代码(很好,只是无法测试的代码):

example-dependancy2

That’s pretty much it. It’s a no-brainer to be honest.

就是这样。 老实说,这很容易。

游戏与可测试性 (Games vs testability)

What makes games so special in term of code and testability? How is testing games different from testing other software? Personally, I consider games as a pretty sophisticated pieces of software. It would be naive to say games aren’t that much different from the software you use every day. In games (with exceptions of course) you will find shiny and polished graphics, background music and other well-engineered sound samples. Games often need to handle realtime input, potentially from a variety of sources, as well as a range of output devices (read resolution). Non-functional requirements can be also more strict for games. Multiplayer games will require you to have a reliable, synchronized network connection while, at the same time, keeping the performance you need to maintain a constant frame rate.

是什么让游戏在代码和可测试性方面如此特别? 测试游戏与测试其他软件有何不同? 我个人认为游戏是相当复杂的软件。 说游戏与您每天使用的软件并没有太大不同会很天真。 在游戏中(当然有例外),您会发现有光泽的图形,背景音乐和其他精心设计的声音样本。 游戏通常需要处理可能来自各种来源以及一系列输出设备(读取分辨率)的实时输入。 对游戏的非功能性要求也可能更加严格。 多人游戏要求您具有可靠的同步网络连接,同时要保持保持恒定帧频所需的性能。

This can make for a complex system that touches on many different kinds of media and technologies. For me, games were always masterpieces of software end-product, with some of them aspiring to be recognized as pieces of art (in the classical, visual way as well as the technical, behind-the-scenes side).

这可以构成涉及许多不同种类的媒体和技术的复杂系统。 对我而言,游戏始终是软件最终产品的杰作,其中一些游戏渴望被人们视为艺术品(在古典,视觉方式以及技术,幕后方面)。

统一性与可测试性 (Unity vs testability)

All this complexity has consequences for the code architecture. To our misfortune, high performance architectures usually work against good code design, a restriction you may also encounter in Unity. One of the core mechanisms that had to be designed in a special way, is the MonoBehaviours mechanism. If you ever wondered why the callbacks in MonoBehaviours aren’t implemented with interfaces or inheritance (as common sense perhaps suggests), it is for performance reasons(See Lucas Meijer’s clarification in the comments). Without going into detail, this also works against the testability of MonoBehaviours. The fact that you can’t instantiate a MonoBehaviour with the new operator pretty much prohibits you from using any mocking frameworks out there. It probably wouldn’t be a good idea anyway with all the things that are going on behind the scene every time a MonoBehaviour is used. Intercepting this behaviour would generate lots of problems.

所有这些复杂性都会对代码体系结构产生影响。 令我们感到不幸的是,高性能架构通常不利于良好的代码设计,这在Unity中也可能遇到限制。 必须以特殊方式设计的核心机制之一是MonoBehaviours机制。 如果您想知道为什么MonoBehaviours中的回调没有通过接口或继承来实现(如常识所示), 这是出于性能原因 (请参阅评论中的Lucas Meijer的说明)。 无需赘述,这也不利于MonoBehaviours的可测试性。 您无法使用new运算符实例化MonoBehaviour的事实实际上使您无法使用任何模拟框架。 无论如何,每次使用MonoBehaviour时幕后发生的所有事情可能都不是一个好主意。 拦截这种行为会产生很多问题。

您与可测试性
例子猫
(You vs testability)

In the end it’s all about you, and how motivated you are to write testable code. Many approaches can solve the same problem but only few will work well for test automation. If you want to write testable code, sometimes you will need to write more code than you would think is necessary. If you are still learning (shouldn’t we be learning our whole life, anyway?) or just got on the test automation adventure path, you may find some of the code pieces or design assumptions as an unnecessary overhead. These, however, become a habit so quickly that you will not even notice when you start using the pro-automation designs without even thinking about it.

最后,一切都与您有关,以及编写可测试代码的动机。 许多方法可以解决相同的问题,但只有少数方法可以很好地实现测试自动化。 如果要编写可测试的代码,有时您将需要编写超出您认为必要的代码。 如果您仍在学习(无论如何,我们是否应该学习我们的一生?)或只是踏上了测试自动化的冒险之路,您可能会发现一些代码段或设计假设,这是不必要的开销。 但是,这些习惯很快就变成一种习惯,以至于您甚至在不考虑它就开始使用亲自动化设计时都不会注意到。

In this blog post, I promised to show you a way to design MonoBehaviour to be able to test them afterwards. It wasn’t completely true, because we won’t be testing MonoBehaviours themselves. You probably already have an idea of how to implement the Humble Object Pattern to your design to make it more testable but, nevertheless, let me show you the idea implemented in a real project.

在本博客中,我答应向您展示一种设计MonoBehaviour的方法,以便以后对其进行测试。 这不是完全正确的,因为我们不会自己测试MonoBehaviours 。 您可能已经对如何在设计中实现“谦虚对象模式”以使其更具可测试性有了一个想法,但是,让我向您展示在真实项目中实现的想法。

这个例子 (The example)

Let’s create a use-case for the purpose of this example. Imagine a simple player controller that is responsible for steering a spaceship. To simplify the example, let’s put it in a 2D worldspace. We want the spaceship to be able to fly around in every direction. It has a gun that can shoot straight with bullets (space-rockets?) but not more frequently than a given firing rate. The number of bullets is also limited by the capacity of the bullet holder so once you shoot all of them you need to reload. To make it more interesting, let’s the make the movement speed dependent on the spaceship’s health.

为此示例创建一个用例。 想象一个简单的玩家控制器,它负责操纵飞船。 为了简化示例,我们将其放在2D世界空间中。 我们希望太空飞船能够在各个方向飞行。 它的枪可以直接发射子弹(太空火箭?),但不会比给定的发射频率更频繁。 子弹的数量也受到子弹架容量的限制,因此,一旦发射全部子弹,就需要重新装弹。 为了使它更有趣,让运动速度取决于飞船的健康状况。

A Monobehaviour that will serve as a controller for our spaceship could look like this:

将作为我们飞船的控制器的Monobehaviour可能看起来像这样:

In the FixedUpdate callback we read the input and perform the action depending on which buttons were pressed by the user. To move around the spaceship we need translate spaceship’s position with the speed constant according to the direction of the axes. As you can see in the code, the deltaX and deltaY variables are multiplications of: Time.fixedDeltaTime, the value from the input axis and the speed constant which itself is dependant on the health level.

FixedUpdate回调中,我们根据用户按下的按钮读取输入并执行操作。 为了绕飞船移动,我们需要根据轴的方向以恒定的速度平移飞船的位置。 正如您在代码中看到的那样, deltaXdeltaY变量是以下各项的乘积: Time.fixedDeltaTime ,来自输入轴的值和速度常数,速度常数本身取决于运行状况级别。

On the Fire1 event (e.g. left mouse button click) we want to check if it is possible to shoot the bullet. In the first place, we need to have at least one bullet left in the bullet holder. Secondly, we want to only allow the spaceship to shoot at certain rate (once every half a second in this case). Therefore, we check how much time has passed since the last bullet was fired. If we’re good to go, we spawn the bullet.

Fire1事件中(例如,单击鼠标左键),我们要检查是否有可能发射子弹。 首先,我们需要在子弹夹中至少留有一个子弹。 其次,我们只想让飞船以一定的速度射击(在这种情况下,每半秒射击一次)。 因此,我们检查了自发射最后一颗子弹以来已经过去了多少时间。 如果我们走的很好,我们会产生子弹。

The Fire2 event will simply reload the bullet holder.

Fire2事件将仅重新加载弹头支架。

To write unit tests for this logic, we need to overcome two problems. The first one, as previously mentioned, is the non-mockable MonoBehaviour class on which we depend on via inheritance. The second problem is more general for real-time software. Our logic is dependent on time (firing rate) which makes it impossible to perform unit tests since we can’t intercept the static Time class from Unity. The good news is that all this can be solved.

要为此逻辑编写单元测试,我们需要克服两个问题。 如前所述,第一个是不可模仿的MonoBehaviour类,我们通过继承依赖该类。 第二个问题是实时软件的一般性问题。 我们的逻辑取决于时间(触发率),这使得无法执行单元测试,因为我们无法从Unity拦截静态的Time类。 好消息是所有这些都可以解决。

Let’s refactor our code a bit by applying some simple method extraction refactorization and keeping in mind that the logic methods should not reference the Unity API (Input handling and bullet instantiation in this case). The time dependency in the if statement, should be extracted to a separate method as well. The final result should look more or less like this:

让我们通过应用一些简单的方法提取重构来重构代码,并记住逻辑方法不应引用Unity API(在这种情况下为输入处理和项目符号实例化)。 if语句中的时间相关性也应提取到单独的方法中。 最终结果应大致如下所示:

example2

As you can see, the FixedUpdate method here does nothing more than passing on the input from the users to the method that does the the logic part. The firing rate check was extracted to CanFire method, that generate the result “true” if a specified amount of time has passed. This extraction is important as it will allows us write unit tests later. If we were able mock the SpaceshipMotor class right now, we would simply intercept the CanFire method and make it return true or false whenever we intended to. It would make the test time-independent. But since we can’t mock SpaceshipMotor because it inherits from Monobehaviour, we need to apply the Humble Object Pattern.

如您所见, FixedUpdate方法仅将用户的输入传递给执行逻辑部分的方法。 将触发率检查提取到CanFire方法,如果经过指定的时间量,则生成结果“ true”。 此提取很重要,因为它将允许我们稍后编写单元测试。 如果我们现在能够模拟SpaceshipMotor类,则只需拦截CanFire方法,并在需要时使其返回true或false。 这将使测试与时间无关。 但是由于我们不能模拟SpaceshipMotor,因为它是从Monobehaviour继承而来的,因此我们需要应用Humble Object Pattern。

How do we do that? We simply need to extract all the logic code that doesn’t use the Unity API to a separate class and introduce a reference to it to the SpaceshipMotor. Let’s look at the class again and see what to extract. The TranformPosition and InstanciateBullet use the Unity API but everything else can be extracted. I know there is also the static Time class but let me get back to that later.

我们该怎么做? 我们只需要将所有不使用Unity API的逻辑代码提取到一个单独的类中,并将对它的引用引入SpaceshipMotor 。 让我们再次查看该类,看看要提取什么。 TranformPositionInstanciateBullet使用Unity API,但其他所有内容都可以提取。 我知道也有静态的Time类,但让我稍后再讲。

The last thing left to explain before we do the actual extraction is how the extracted logic communicates with the Unity API without depending on it. This is the place where the interfaces come in. The class with logic will have a reference to an interface, and I will not care about the actual implementation. To keep things simple, we can implement those interfaces directly in MonoBehaviour itself! Let’s take a look at the following 2 classes:

在进行实际提取之前,剩下的最后要解释的是提取的逻辑如何与Unity API通信而不依赖它。 这是接口进入的地方。带有逻辑的类将对接口进行引用,而我将不在乎实际的实现。 为了简单起见 ,我们可以直接在MonoBehaviour本身中实现这些接口! 让我们看一下以下两个类:

Example3
Example4

Let’s start with the SpaceshipMotor class. The class implemented some interfaces that are responsible for transforming the position of out spaceship and instantiating the bullet respectively. The class itself got a field that refers to the SpaceshipController which implements all the logic now. The SpaceshipController class knows nothing about the SpaceshipMotor and the only thing it can do is it invoke methods from the interfaces it references.

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

Unity won’t serialize references to the interfaces. If you don’t care about serialization, simply pass the interface references while constructing the SpaceshipController class. Otherwise, you can set the references in OnEnable callback that is called every time after the serialization happens. Just for the record, the whole SpaceshipMotor class will be serialized in the usual way, it’s just the interface references that will be lost.

Unity不会序列化对接口的引用。 如果您不关心序列化,则在构造SpaceshipController类时只需传递接口引用即可。 否则,您可以在序列化发生后每次调用的OnEnable回调中设置引用。 仅作记录,整个SpaceshipMotor类将以通常的方式进行序列化,只是接口引用将丢失。

You must have noticed the Time class reference in SpaceshipMotor. I know I said there should be no Unity API reference in here but I left it there to demonstrate a different approach for handling time dependant dependencies. Ideally, we could simply pass the Time.time value as an argument to the methods.

您一定已经注意到SpaceshipMotor中Time类参考。 我知道我说过这里应该没有Unity API参考,但是我把它留在那里展示了一种用于处理时间依赖性的不同方法。 理想情况下,我们可以简单地将Time.time值作为方法的参数传递。

For UML fans, this is the end result as a (simplified) UML diagram:

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

example-uml1

单元测试 (Unit tests)

With the decoupled SpaceshipMotor class there’s nothing preventing us from writing some unit tests. Take a look at one of the tests:

通过解耦的SpaceshipMotor类,没有什么可以阻止我们编写一些单元测试。 看一下其中一项测试:

Example5

The test validates that you can’t fire if you have no bullets left. The test itself is structured according to the Arrange-Act-Assert pattern. In the arrange part we create object mocks with GetGunMock and GetControllerMock methods. The GetControllerMock, besides creating a mock, overrides the behaviour of CanFire method to always return true. This removes the time dependency from the controller object. Next, we set the current bullet number to 0. After that, we apply fire to the controller class and we assert if Fire has not been called on the gun controller interface.

如果没有子弹,测试将验证您无法射击。 测试本身是根据“安排-行为-声明”模式构造的。 在安排部分,我们使用GetGunMockGetControllerMock方法创建对象模拟GetControllerMock除了创建模拟之外,还覆盖CanFire方法的行为以始终返回true。 这从控制器对象中消除了时间依赖性。 接下来,我们将当前的项目符号编号设置为0。之后,将火应用于控制器类,并断言是否尚未在喷枪控制器接口上调用

There are few more tests in the project. You can grab it from here and play with it bit. I used NSubstitute for the mocking object. We also ship a version of it with the Unity Test Tools. All of the three versions of the controller we discussed here are attached in the project.

项目中还有更多测试。 您可以从这里抓取它并玩一点。 我使用NSubstitute作为模拟对象。 我们还随Unity测试工具一起提供了该版本。 我们在此讨论的控制器的所有三个版本都附在项目中。

That’s it from me today. I hope you enjoyed the read, and happy testing!

今天就是我了。 希望您阅读愉快,测试愉快!

Tomek

托梅克

翻译自: https://blogs.unity3d.com/2014/06/03/unit-testing-part-2-unit-testing-monobehaviours/

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值