Unity中的单元测试简介

图片

您是否对Unity中的单元测试工作感到好奇? 不确定总体上是什么单元测试? 如果您对这些问题的回答是肯定的,那么本教程将对您有所帮助。 从中您将学到有关单元测试的以下内容:

  • 这是什么
  • 它的好处
  • 优缺点
  • 使用Test Runner在Unity中的工作方式
  • 如何编写和执行将要测试的单元测试

注意 :本教程假定您熟悉C#语言和Unity中的开发基础。 如果您不熟悉Unity,请首先查看此引擎上的其他教程

什么是单元测试?


在深入研究代码之前,重要的是要清楚地了解什么是单元测试。 简而言之,单元测试就是测试...单元。

单元测试 (理想情况下)旨在测试单独的代码单元。 “单元”的组成可能会有所不同,但是请务必记住,单元测试必须一次精确地测试一个“元素”。

需要创建单元测试,以验证特定场景中的一小段逻辑代码是否按预期运行。 在开始编写自己的单元测试之前,这可能很难理解,因此让我们看一个示例:

您已经编写了一种允许用户输入名称的方法。 编写该方法时,名称中不允许使用数字,并且名称本身只能包含十个或更少的字符。 您的方法拦截每个键的击键,并将相应的字符添加到name字段:

 public string name = "" public void UpdateNameWithCharacter(char: character) { // 1 if (!Char.IsLetter(char)) { return; } // 2 if (name.Length > 10) { return; } // 3 name += character; } 

这是怎么回事:

  1. 如果字符不是字母,则代码会预先存在该函数,并且不会将字符添加到字符串中。
  2. 如果名称长度为十个或更多字符,则该代码不允许用户添加其他字符。
  3. 如果通过了这两项检查,则代码将在名称的末尾添加一个字符。

可以测试此单元,因为它是所执行工作的“模块”。 单元测试强制执行方法逻辑。

单元测试示例


我们如何为UpdateNameWithCharacter方法编写单元测试?

在开始实施这些单元测试之前,我们需要仔细考虑这些测试的功能,并为其命名。

看一下下面的单元测试名称示例。 从名称中可以清楚地看出它们进行了检查:

UpdateNameDoesntAllowCharacterAddingToNameIfNameIsTenOrMoreCharactersInLength

UpdateNameAllowsLettersToBeAddedToName

UpdateNameDoesntAllowNonLettersToBeAddedToName

从这些测试方法的名称中,我们看到我们确实检查了工作的“单元”是否由UpdateNameWithCharacter方法UpdateNameWithCharacter 。 这些测试名称似乎太长且太详细,但对我们来说却很好。

您编写的每个单元测试都是一组测试的一部分。 测试套件包含与功能逻辑组相关的所有单元测试(例如,“战斗单元测试”)。 如果套件中的任何测试均未通过测试,则整个测试套件将失败。


游戏发布


打开Crashteroids Starter项目 (您可以从此处下载),然后从Assets / RW / Scenes文件夹中打开Game场景。


单击“ 播放”以启动Crashteroids,然后单击“ 开始游戏”按钮。 使用键盘上的左右箭头移动飞船。

要发射激光束,请按空格键 。 如果光束撞击小行星,则分数将增加一。 如果小行星与飞船相撞,飞船将爆炸并结束游戏(具有重新开始的能力)。


试着玩一点,并确保小行星与飞船相撞后出现题为游戏结束的文字。


Unity Test Runner入门


现在我们知道了游戏的运行方式,是时候编写单元测试以验证所有功能都可以正常工作了。 因此,如果您(或其他人)决定更新游戏,则可以确保该更新不会破坏以前可用的任何功能。

要编写测试,您首先需要了解Unity Test Runner。 Test Runner可让您运行测试并检查测试是否成功通过。 要打开Unity Test Runner,请选择窗口▸General▸Test Runner


在新窗口中打开“测试运行器”后,您可以通过单击“测试运行器”窗口并将其拖动到“场景”窗口旁边的位置来简化生活。


准备NUnit和测试文件夹


Test Runner是Unity提供的单元测试功能,但它使用NUnit框架。 当您开始更认真地使用单元测试时,建议您学习NUnit上的Wiki以了解更多信息。 本文将讨论您首次需要的所有内容。

要运行测试,我们首先需要创建一个测试文件夹,其中将存储测试类。

在“ 项目”窗口中选择RW文件夹。 查看“ 测试运行器”窗口,并确保选择了PlayMode

单击名为创建PlayMode测试程序文件夹的按钮。 您将看到一个新文件夹出现在RW文件夹中。 我们对标准名称Tests感到满意,因此您只需按Enter


您可能想知道Test Runner中这两个不同的选项卡是什么。

PlayMode选项卡用于在Play模式(实时运行游戏)中进行的测试。 “ EditMode”选项卡上的测试在“播放”模式之外运行,这对于在Inspector中测试诸如自定义行为之类的操作很方便。

在本教程中,我们将介绍PlayMode测试。 但是,当您感到舒适时,可以尝试在EditMode中进行测试。 在本教程中使用Test Runner时,请始终确保选择了PlayMode选项卡

测试套件中有什么?


如上所述,单元测试是一种功能,用于测试一小段特定代码的行为。 由于单元测试是一种方法,要运行它,它必须在类文件中。

Test Runner绕过所有测试类文件并从中执行单元测试。 包含单元测试的类文件称为测试套件。

在测试套件中,我们在逻辑上将测试细分。 我们必须将测试代码分成单独的逻辑集(例如,一组物理测试和一组战斗)。 在本教程中,我们只需要一组测试,是时候创建一个了。

准备测试程序集和测试套件


选择“ 测试”文件夹,然后在“ 测试运行器”窗口中,单击“ 在当前文件夹中创建测试脚本”按钮。 命名新的TestSuite文件。


除了新的C#文件之外,Unity引擎还创建了另一个名为Tests.asmdef的文件。 这是程序集定义文件 ,用于向Unity显示测试文件的依赖项所在的位置。 这是必需的,因为完成的应用程序代码与测试代码分开包含。

如果遇到Unity无法找到测试文件或测试的情况,请确保有一个包含测试套件的程序集定义文件。 下一步是配置它。

为了使测试代码能够访问游戏类,我们将创建一个类代码的程序集,并在“测试”程序集中设置链接。 单击脚本文件夹将其选中。 右键单击该文件夹,然后选择创建▸装配定义


将文件命名为GameAssembly


单击“ 测试”文件夹,然后单击“ 测试 ”构建定义文件。 在检查器中,单击“ 装配体定义参考”标题下的加号按钮。


您将看到“ 缺少参考”字段。 单击该字段旁边的以打开选择窗口。 选择GameAssembly文件。


您应该在链接部分中看到GameAssembly程序集文件。 单击“ 应用”按钮以保存这些更改。


如果不执行这些步骤,则将无法在单元测试文件中引用游戏的类文件。 处理完之后,您可以继续执行代码。

我们编写第一个单元测试


双击TestSuite脚本以在代码编辑器中将其打开。 将所有代码替换为:

 using UnityEngine; using UnityEngine.TestTools; using NUnit.Framework; using System.Collections; public class TestSuite { } 

我们需要编写哪些测试? 坦白说,即使在像Crashteroids这样的小游戏中,您也可以编写很多测试来验证所有功能是否正常运行。 在本教程中,我们仅将自己限制在关键领域:碰撞识别和基本游戏机制。

注意 :在生产级别编写产品的单元测试时,您应该花足够的时间来考虑需要在代码的所有方面进行测试的所有边界情况。

作为第一个测试,很高兴检查小行星是否真的在向下移动。 如果他们离开船,他们将很难与船相撞! 将以下方法和私有变量添加到TestSuite脚本中:

 private Game game; // 1 [UnityTest] public IEnumerator AsteroidsMoveDown() { // 2 GameObject gameGameObject = MonoBehaviour.Instantiate(Resources.Load<GameObject>("Prefabs/Game")); game = gameGameObject.GetComponent<Game>(); // 3 GameObject asteroid = game.GetSpawner().SpawnAsteroid(); // 4 float initialYPos = asteroid.transform.position.y; // 5 yield return new WaitForSeconds(0.1f); // 6 Assert.Less(asteroid.transform.position.y, initialYPos); // 7 Object.Destroy(game.gameObject); } 

只有几行代码,但是它们却做了很多事情。 因此,让我们停下来处理每个部分:

  1. 这是一个属性 。 属性定义特定的编译器行为。 该属性告诉Unity编译器代码是单元测试。 因此,启动测试时它将出现在“测试运行器”中。
  2. 创建一个游戏实例。 其他所有内容都嵌入在游戏中,因此当我们创建游戏时,它将包含需要测试的所有内容。 在生产环境中,很可能所有元素都不会位于同一预制件中。 因此,您将需要重新创建场景中所需的所有对象。
  3. 在这里,我们创建了一个小行星,以便我们可以监视它是否在移动。 SpawnAsteroid方法返回创建的小行星的实例。 Asteroid组件具有Move方法(如果您好奇运动的工作原理,可以查看RW / Scripts中的Asteroid 脚本 )。
  4. 为了确保小行星已经向下移动,必须跟踪起始位置。
  5. 所有Unity单元测试都是协程,因此您需要添加软收益。 我们还添加了0.1秒的时间步长,以模拟小行星应该向下移动的时间流逝。 如果不需要模拟时间步长,则可以返回null。
  6. 这是断言阶段,在该阶段中我们声称小行星的位置小于初始位置(也就是说,它已经向下移动)。 了解断言是单元测试的重要组成部分,并且NUnit提供了各种断言方法。 通过或不通过测试由此行确定。
  7. 当然,没有人会因为测试完成后留下的混乱而责骂您,但是其他测试可能会因此而失败。 在单元测试之后清理(删除或重置)代码总是很重要的,这样,当您运行下一个单元测试时,就不会留下任何可能影响该测试的工件。 我们只需删除游戏对象就足够了,因为对于每次测试,我们都会创建一个全新的游戏实例。

通过测试


好吧,您编写了第一个单元测试,但是您如何知道它是否有效? 当然,有了Test Runner! 在“测试运行器”窗口中,用箭头展开所有行。 您应该在列表中看到带有灰色圆圈的AsteroidsMoveDown测试:


灰色圆圈表示测试尚未完成。 如果测试已开始并通过,则旁边会显示一个绿色箭头。 如果测试失败,则会在其旁边显示一个红色的X,单击RunAll按钮运行测试。


这将创建一个临时场景并运行测试。 完成后,您应该看到测试已通过。


您已经成功编写了第一个单元测试,说明所创建的小行星正在向下移动。

注意 :在开始编写自己的单元测试之前,您需要了解要测试的实现。 如果您对正在测试的逻辑如何工作感到好奇,请研究RW / Scripts文件夹中的代码。

使用集成测试


在深入探讨单元测试的难题之前,是时候告诉您什么是集成测试以及它们与单元测试有何不同。

集成测试是验证代码的“模块”如何协同工作的测试。 “模块”是另一个模糊术语。 一个重要的区别是集成测试必须在实际生产中(即玩家真正玩游戏时)测试软件的操作。


假设您制作了一款战斗游戏,其中有一名玩家杀死了怪物。 您可以创建一个集成测试,以确保当玩家杀死100个敌人时,成就被打开(“成就”)。

此测试将影响几个代码模块。 它最有可能涉及物理引擎(冲突识别),敌人调度员(跟踪敌人的健康和处理损伤以及传递给其他相关事件)以及跟踪所有触发事件(例如,“怪物被杀死”)的事件跟踪器。 然后,当需要解锁成就时,他可以致电成就经理。

集成测试将模拟玩家杀死100个怪物并检查成就是否已解锁。 它与单元测试有很大的不同,因为它测试可协同工作的大型代码组件。

在本教程中,我们将不学习集成测试,但这应该显示工作单元(以及为什么要对其进行统一测试)与代码模块(以及为什么要对其进行集成测试)之间的区别。

将测试添加到测试套件


下一次测试将测试飞船与小行星碰撞时游戏的结束。 在代码编辑器中打开TestSuite的情况下 ,将以下所示的测试添加到第一个单元测试下并保存文件:

 [UnityTest] public IEnumerator GameOverOccursOnAsteroidCollision() { GameObject gameGameObject = MonoBehaviour.Instantiate(Resources.Load<GameObject>("Prefabs/Game")); Game game = gameGameObject.GetComponent<Game>(); GameObject asteroid = game.GetSpawner().SpawnAsteroid(); //1 asteroid.transform.position = game.GetShip().transform.position; //2 yield return new WaitForSeconds(0.1f); //3 Assert.True(game.isGameOver); Object.Destroy(game.gameObject); } 

在之前的测试中,我们已经看到了大部分代码,但是有一些区别:

  1. 我们迫使小行星与飞船相撞,从而使小行星明显地具有与飞船相同的位置。 这将造成他们的Hitbox发生碰撞,并导致游戏结束。 如果您好奇此代码的工作方式,请查看Scripts文件夹中的ShipGameAsteroid文件。
  2. 物理引擎的碰撞事件触发需要时间步长,因此返回0.1秒的延迟。
  3. 该语句为true,并验证Game脚本中的gameOver标志为true。 在游戏操作中,该标志在舰船被摧毁时为真,也就是说,我们进行测试以确保在舰船被摧毁后将其设置为true。

返回到“测试运行器”窗口,您将看到一个新的单元测试出现在这里。


这次我们将运行这个而不是整个测试套件。 单击GameOverOccursOnAsteroidCollision ,然后单击“ 运行所选”按钮。


瞧,我们通过了另一项测试。


调整和销毁阶段


您可能已经注意到,在我们的两个测试中,有重复的代码:创建Game对象的位置和设置Game脚本的链接的位置:

 GameObject gameGameObject = MonoBehaviour.Instantiate(Resources.Load<GameObject>("Prefabs/Game")); game = gameGameObject.GetComponent<Game>(); 

您还将注意到在Game对象的销毁中有重复:

 Object.Destroy(game.gameObject); 

测试时,这种情况经常发生。 当涉及到运行单元测试时,实际上有两个阶段: 设置阶段和拆卸阶段。

Setup方法中的所有代码将在单元测试之前(在此集合中)执行,而Tear Down方法中的所有代码将在单元测试之后(在此集合中)执行。

现在是时候通过将设置和拆卸代码移至特殊方法来简化我们的生活。 打开代码编辑器,并在第一个[UnityTest]属性之前,在TestSuite文件的开头添加以下代码:

 [SetUp] public void Setup() { GameObject gameGameObject = MonoBehaviour.Instantiate(Resources.Load<GameObject>("Prefabs/Game")); game = gameGameObject.GetComponent<Game>(); } 

SetUp属性指示在每次测试SetUp之前都会调用此方法。

然后添加以下方法并保存文件:

 [TearDown] public void Teardown() { Object.Destroy(game.gameObject); } 

TearDown属性指示在每次测试TearDown后调用此方法。

准备好设置和销毁代码后,请删除这些方法中存在的代码行,并将其替换为对相应方法的调用。 之后,代码将如下所示:

 public class TestSuite { private Game game; [SetUp] public void Setup() { GameObject gameGameObject = MonoBehaviour.Instantiate(Resources.Load<GameObject>("Prefabs/Game")); game = gameGameObject.GetComponent<Game>(); } [TearDown] public void Teardown() { Object.Destroy(game.gameObject); } [UnityTest] public IEnumerator AsteroidsMoveDown() { GameObject asteroid = game.GetSpawner().SpawnAsteroid(); float initialYPos = asteroid.transform.position.y; yield return new WaitForSeconds(0.1f); Assert.Less(asteroid.transform.position.y, initialYPos); } [UnityTest] public IEnumerator GameOverOccursOnAsteroidCollision() { GameObject asteroid = game.GetSpawner().SpawnAsteroid(); asteroid.transform.position = game.GetShip().transform.position; yield return new WaitForSeconds(0.1f); Assert.True(game.isGameOver); } } 

测试游戏结束和激光射击


准备好简化生活的调整和销毁方法之后,我们可以开始添加使用它们的新测试。 下一个测试是验证玩家单击New GamegameOver bool的值是否为 true。 在文件末尾添加这样的测试并保存:

 [UnityTest] public IEnumerator NewGameRestartsGame() { //1 game.isGameOver = true; game.NewGame(); //2 Assert.False(game.isGameOver); yield return null; } 

这对您来说应该已经很熟悉了,但是以下几点值得一提:

  1. 这段代码为gameOver布尔标志为true做好了测试准备。 调用NewGame方法时,必须再次将标志设置为false
  2. 在这里,我们认为bool isGameOverfalse ,在调用新游戏时应该为true。

返回Test Runner,您应该看到有一个新的测试NewGameRestartsGame 。 像我们之前一样运行此测试,您将看到它成功运行:


激光束声明


下一个测试是增加船发射的激光束飞起来的测试(类似于我们编写的第一个单元测试)。 在编辑器中打开TestSuite文件。 添加以下方法并保存文件:

 [UnityTest] public IEnumerator LaserMovesUp() { // 1 GameObject laser = game.GetShip().SpawnLaser(); // 2 float initialYPos = laser.transform.position.y; yield return new WaitForSeconds(0.1f); // 3 Assert.Greater(laser.transform.position.y, initialYPos); } 

这是这段代码的作用:

  1. 获取到从船舶发射的激光束的链接。
  2. 记录起始位置,以便我们可以验证其是否向上移动。
  3. 该陈述与AsteroidsMoveDown单元测试的陈述一致,只是现在我们声称该值更大(即激光器向上移动)。

保存文件并返回到测试运行器。运行LaserMovesUp测试并观察其如何通过:


现在,您应该已经开始了解一切工作原理,因此该添加最后两个测试并完成本教程了。

验证激光会摧毁小行星


接下来,我们将确保当被击中时,激光会摧毁小行星。打开编辑器,将以下测试添加到TestSuite的末尾,然后保存文件:

 [UnityTest] public IEnumerator LaserDestroysAsteroid() { // 1 GameObject asteroid = game.GetSpawner().SpawnAsteroid(); asteroid.transform.position = Vector3.zero; GameObject laser = game.GetShip().SpawnLaser(); laser.transform.position = Vector3.zero; yield return new WaitForSeconds(0.1f); // 2 UnityEngine.Assertions.Assert.IsNull(asteroid); } 

运作方式如下:

  1. 我们创建一个小行星和一个激光束,并将它们分配给相同的位置以触发碰撞。
  2. 这是具有重要区别的特殊测试。看到我们为此测试明确使用了UnityEngine.Assertions吗?这是因为在Unity是一类特殊的空,从“正常»零类不同。NUnit框架语句Assert.IsNull() 不适用于Unity的null检查。在Unity中检查null时,必须显式使用UnityEngine.Assertions.Assert,而不是从NUnit声明。

返回测试运行程序并运行新测试。您会看到一个绿色的图标,取悦我们。


测试还是不测试-这就是问题所在


坚持单元测试的决定不是一个容易的决定,不应轻易掉以轻心。但是,测试的好处值得付出努力。甚至有一个开发方法,所谓的测试驱动开发(测试驱动开发,TDD)。

在TDD的框架内工作,您在编写应用程序逻辑本身之前先编写测试。首先,创建测试,确保程序未通过测试,然后仅编写旨在通过测试的代码。这可能是一种非常不同的编码方法,但是可以确保以适合测试的方式编写代码。

在开始下一个项目时,请记住这一点。但是,现在是时候编写您自己的单元测试了,为此您需要一个我们提供给您的游戏。

: — , . , . «» , , . , . , . , , .

测试可能是一笔大投资,因此请考虑在项目中添加单元测试的优点和缺点:

单元测试的优势


单元测试具有许多重要的优点,其中包括:

  • 可以确信该方法的行为符合预期。
  • 用作学习代码库的新手的文档(单元测试非常适合教学)。
  • 使您以可测试的方式编写代码。
  • 允许更快地隔离和修复错误。
  • 它不允许将来的更新将新的错误添加到旧的工作代码中(它们称为回归错误)。

单元测试的缺点


但是,您可能没有时间或预算进行单元测试。这里是要考虑的缺点:

  • 编写测试可能比代码本身花费更长的时间。
  • .
  • .
  • , .
  • , -.
  • ( ), .
  • - .
  • UI .
  • .
  • .

,


现在该写最后一个测试了。打开代码编辑器,将以下代码添加到TestSuite文件的末尾并保存:

 [UnityTest] public IEnumerator DestroyedAsteroidRaisesScore() { // 1 GameObject asteroid = game.GetSpawner().SpawnAsteroid(); asteroid.transform.position = Vector3.zero; GameObject laser = game.GetShip().SpawnLaser(); laser.transform.position = Vector3.zero; yield return new WaitForSeconds(0.1f); // 2 Assert.AreEqual(game.score, 1); } 

这是一项重要的测试,可以验证玩家摧毁小行星时得分是否会提高。这是由以下内容组成的:

  1. 我们创建一个小行星和一个激光束,并将它们放在一个位置。因此,发生碰撞,触发得分增加。
  2. 声明game.score现在为1(而不是开头的0)。

保存代码,然后返回“测试运行器”以运行上一个测试,并查看它是否运行了游戏:


太棒了!所有测试均通过。

接下来要去哪里?


在本文中,我们检查了大量信息。如果您想将您的工作与最终项目进行比较,请在存档中进行查看,本文开头也提供了指向该链接的链接。

在本教程中,您学习了什么是单元测试以及如何在Unity中编写它们。此外,您编写了六个成功通过代码的单元测试,并熟悉了单元测试的一些优缺点。

感到自信?然后,您可以编写更多测试。检查游戏的类文件,并尝试为代码的其他部分编写单元测试。考虑为以下情况添加测试:

  • 触摸飞船时,每种类型的小行星都将导致游戏结束。
  • 开始新游戏将重置分数。
  • 船的左右移动正常。

如果您想增加对单元测试的了解,那么值得探索依赖项框架实现,以使用模拟对象这样可以大大简化测试设置。

另请阅读NUnit文档,以了解有关NUnit框架的更多信息。

并随时在论坛上分享您的想法和问题。

测试成功!

Source: https://habr.com/ru/post/zh-CN456090/


All Articles