在Unity中编写单元测试

2023-11-15

最近在我忙于我的最新项目时,我一直在思考,我如何能单元测试代码。我知道如果我先把它搁一边,在编写一大段游戏代码后,我可能再也不会回头来写测试了。

编写单元测试对我有两个挑战,首先,游戏不同于其他类型的软件,没有好的代码分段来处理好输入,以及图形/UI。这是两块众所周知的“不好单元测试”的系统部分。

举个例子,你如何编写出测试代码来检测你游戏中的手雷爆炸效果是否正确?(请注意有其他的测试能处理好这种情况,至少能不去回溯,但它们并不是单元测试)

第二个挑战是,在Unity中做单元测试。在这篇文章中我将分享一些我对用Unity引擎做单元测试的实验和想法。这并不是这个主题的最终解,我相信我所写的有所不足。我只是单纯地想展开对这一主题的讨论。

单元测试框架

一开始我想找出一个框架让我用在MonoDev和Unity中。实际上有许多免费的解决方案如NUnitLite、UUnit以及Sharp Unit。也有一些商用的产品如Test Star,它有更多的功能。

所列出的免费方案已经很过时并有些难用,而我又同时觉得预算很紧(反正是我的借口而已),我决定自己把这些综合起来。这里我不打算讲的太细,但它基本上和NUnit差不多。

首先定义一些诸如[TestFixture]和[SetUp]这样的属性,在你的测试管理器中用映射找出所有测试的类和它们的的测试方法,调用并捕获任何错误并将它们保存在一条列表中。最后用一个UI扩展来显示这个列表。我在这里给出源码的链接地址(注:我用的是C#)

Unity的编辑器扩展

这里我真要大呼爽快,因为Unity的开发团队把其做得十分易于扩展UI!我兴奋地发现我可以很快地就把测试UI搞定到编辑器的菜单和窗口中,并且只要简单地执行两个方法,而这还是免费版的Unity。

我已在考虑把所有能添加的工具都加上,包括数据编辑器。这里我遇到一个问题,你不能在一个工作线程上运行MonoBehaviour代码,而调用Repaint并不能立即重画UI。同样地,测试的结果只能在所有测试都被运行后才可显示出来。

那么我能实际地测试些什么呢?

这是一个数百万美元的问题。基本上,它不能测试每个东西。而之前也提过,与图形相关的代码并不太适合做单元测试。这里有一些我目前获得的结果,或许对你用这个有所帮助。

独立于图形的逻辑

更具体的说,就是保持纯粹的类不属于MonoBehaviour,最好不处理和GameObjects以及其他特殊场景的结构。你的数据访问类就是一个例子,或者是与AI相关的类。

也有许多更为详细的例子,你可以通过它们来观察一个MonoBehaviour并决定是否重构方法里的一些逻辑,比如更新到一个帮助的类里让其更容易测试。请注意,这里并不是说你无法在这些类中使用任何Unity类型,如果它们从属于场景、其他对象、组建以及它们的状态,那么事情就变得有些诡异了。

讲到数据访问,如果你考虑为你的游戏建立个数据存储,那么sqlite是个可行的选择。请谨记它支持内存内模式,这极易测试。简单地在测试中运行“:memory:”来断开连接符,你就可以绕过所有的文件处理问题,来加速你的测试。

测试MonoBehavious

当然在MoniBehavious中总有你不能单独拿出来测试的东西。如果你试着简单地在你的脚本中实例化一个MonoBehaviour,你会在控制台里看到这样的错误:

你正用关键词‘new‘来创建MonoBehaviour。这是不被允许的。MonoBehaviours仅能通过使用AddComponent()来添加。另外,你的脚本可以继承于ScipatableObject或者根本不需要基于什么类。

MonoBehviour仅能存在于它的父对象中的上下文。如果你不修改该对象,那么就不需要一个MonoBehaviour。为了避开这个,我把一个简单的实用类合在一起,看起来像这样:

public class ScriptInstantiator

{

  private List GameObjects { get; set; }

  public ScriptInstantiator()

  {

    GameObjects = new List();

  }

  public T InstantiateScript<T>() where T : MonoBehaviour

  {

    GameObject gameObject;

    object prefab = Resources.Load("Prefabs/" + typeof(T).Name);

    // If there is no prefab with the same name, just use an empty object

    //

    if (prefab == null)

    {

      gameObject = new GameObject();

    }

    else

    {

      gameObject = GameObject.Instantiate(Resources.Load("Prefabs/"

         + typeof(T).Name)) as GameObject;

    }

    gameObject.name = typeof(T).Name + " (Test)";

    // Prefabs should already have the component

    T inst = gameObject.GetComponent<T>();

    if (inst == null)

    {

      inst = gameObject.AddComponent<T>();

    }

    // Call the start method to initialize the object

    //

    MethodInfo startMethod = typeof(T).GetMethod("Start");

    if (startMethod != null)

    {

      startMethod.Invoke(inst, null);

    }

    GameObjects.Add(gameObject);

    return inst;

  }

  public void CleanUp()

  {

    foreach (GameObject gameObject in GameObjects)

    {

      // Destroy() does not work in edit mode

      GameObject.DestroyImmediate(gameObject);

    }

    GameObjects.Clear();

  }

}

 

InstantiateScript()方法为脚本建立一个适当的prefab对象,或如果一个不能用的话仅创建一个空对象,然后相关联的脚本为其实例化。Start()方法在可行时被调用。如果你用其他比如Awake()的方法,它同样也需要被调用。Awake/Start/Update方法需要在这个例子里声明为public,这样你才能在你的测试中调用到它们。

我需要承认这些不稳定,因为要初始化一个MonoBehaviour可能更加复杂,而以上代码有些情况下是不完整的。但于简单的测试上,这些可用。

另一个要提的是,这里我是从资源文件夹里载入prefabs的,它们的命名同脚本中的一致。在更复杂的项目里,同样的脚本可能用于各种不同的prefabs组件,你需要仔细输对prefab的名称。

另外可能你会想只创建一个简单的prefab来测试。那么这样的话,你应该把测试的prefab放在资源文件夹外面(例如:Assets/TestPrefabs),确保它在出产编译时被移除。

CleanUp方法会在你的TearDown方法内被调用,来确保对象被清除。

这里是一个测试的例子:

[Test]

public void MovingEntitiesUpdatesConnector()

{

  var source = ScriptInstantiator.InstantiateScript<Entity>();

  var target = ScriptInstantiator.InstantiateScript<Entity>();

  var connector = ScriptInstantiator.InstantiateScript<Connector>();

  connector.SetSourceEntity(source);

  connector.SetTargetEntity(target, true);

  source.transform.position = new Vector3(-10.0f, 0.0f, 0.0f);

  target.transform.position = new Vector3(0.0f, 10.0f, 0.0f);

  connector.Update();

  Assert.IsTrue(Vector3.Distance(connector.transform.position,

     source.transform.position) < 0.01f)

  Assert.IsTrue(Vector3.Distance(connector.EndPoint,

     target.transform.position) < 0.01f);

}

没有完美的测试

在我们的讨论中,Richard提出一个问题,那就是游戏开发本来就是一个不停调试错误的过程。大部分种类的软件易于改变设计,但我不认为它们能像游戏那样从前到后的需要调整。所以能写出一大堆测试来频繁的修改,这会成为负担的。

当然,对于此还有许多苛刻的条件。基于经验和能用主义,我们需要找出那块代码能被测试,并期望它们可以不要被频繁地修改,也要找出一些特别不稳定的代码。但我们要记住,所有的代码能写出来就不要怕写测试代码,因为我们或许需要修改它们。

同样也要知道,单元测试会在之后的开发周期里非常有用,这样修改起来就不怎么频繁了。例如当bug在测试语句中报告出来,你可以为bug写一个测试来修复它。这样你就不用回溯你的代码了。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

在Unity中编写单元测试 的相关文章

  • FSCapture注册码

    企业版序列号 name bluman serial 序列号 注册码 VPISCJULXUFGDDXYAUYF 转载于 https www cnblogs com wshsdlau p 4396184 html

随机推荐

  • HTML<DIV>常用标签

    目录 1 什么是DIV 1 1 div是什么意思 1 2 div标签怎么用 1 3 div布局优势 1 4 DIV作用是什么 1 5 有哪些DIV方式 1 5 1行内样式 1 5 2内嵌样式 1 5 3外部样式 1 6 样式使用规则 2 D
  • 使用队列实现stack

    两个队列实现一个stack q1只保持一个元素即可 多余的转换到q2当中 出队列元素 有两种情况 q1不为空 直接出队列 如果连续出队列 q1可能为空 需要q2的部分元素放到q1当中去 说白了就是元素捣鼓来捣鼓去的问题即可 class My
  • Linux-安装redis6.2.1及主备复制模式(replication)

    Linux 安装redis6 2 1 下载redis6 2 1资源 上传至安装目录 解压及编译 解压 修改名称 编译 修改配置文件 主节点 从节点 启动及测试 启动 主节点 从节点 测试 下载redis6 2 1资源 地址 https re
  • 华为数通方向HCIP-DataCom H12-821题库(单选题:101-120)

    第101题 可用于多种路由协议 由 if match 和 apply 子句组成的路由选择工具是 A route policy B IP Prefix C commnityfilter D as path filter 答案 A 解析 Rou
  • QT对话框去掉帮助和关闭按钮 拦截QT关闭窗口的CloseEvent

    建了一个对话框 我不想把边框去掉 只想去掉关闭按钮 setWindowFlags windowFlags Qt WindowCloseButtonHint Qt WindowContextHelpButtonHint 结果那个问号的按钮去掉
  • c++序列化以及反序列化实现

    1 什么是序列化和反序列化 当我们在写程序时 比如说我们自定义了一个实体类Person 然后在程序中创建一个该实体类对象 并给对象赋了一些值 但是我们想将这些数据发给我们的其他的程序员朋友 让他们也可以调用我们创建的这个实体类并使用我们的数
  • 数据库实时同步利器——CDC(变化数据捕获技术)

    在进行数据ETL过程中 我们经常需要通过周期性的定时调度将业务数据按照T 1的方式同步到数据仓库中 进行数据分析处理 最终通过BI报表展示给最终用户 但这种方式实时性较差 用户往往只能看到昨天的数据 会影响用户决策的及时性 而如果用户要近实
  • 更换持续集成工具,从 Travis 到 Github Actions

    我真傻 真的 单单受文档的推荐就选择了 Travis 作为部分项目的持续集成工具 没有料到它早已于 2020 年 12 月更换了免费政策 不再为开源项目提供免费的用于持续集成使用的 Credits 了 当赠送的 10000 个点数用完 就需
  • 【踩坑经历】Java Long 类型传给前端损失精度的问题

    最近在做一个 SpringBoot Vue 的项目 持久层框架用的是 MyBatis Plus 然后遇到了一个问题 一起来看下怎么回事 这个项目就是一个文章收藏器 可以收藏一些技术文章 然后可以选择星标 以便查找这篇文章 那么点击星标的按钮
  • 服务器的tomcat调优和jvm调化

    下面讲述的是tomcat的优化 及jvm的优化 Tomcat 的缺省配置是不能稳定长期运行的 也就是不适合生产环境 它会死机 让你不断重新启动 甚至在午夜时分唤醒你 对于操作系统优化来说 是尽可能的增大可使用的内存容量 提高CPU 的频率
  • 操作系统12----进程间通信IPC

    进程间通信IPC 1 进程通信 IPC Inter Process Communication 1 1直接通信 1 2间接通信 1 3阻塞通信 1 4非阻塞通信 2 信号 Signal 3 管道 pipe 4 消息队列 5 共享内存 1 进
  • 基于面板数据的熵值法介绍与实现

    熵值法是一种基于信息熵理论的客观赋值方法 即数据越离散 所含信息量越多 对综合评价影响越大 目录 一 基于面板数据熵值法介绍 二 R语言实现 参考文献 一 基于面板数据熵值法介绍 传统的熵值法有个弊端 只能针对于截面数据 即根据某一年 k
  • MySQL创建表时提示:1067 - Invalid default value for ‘sex‘

    问题 在创建表的时候如果有中文 则会提示 1067 Invalid default value for sex 比如 创建信息表 create table userInfo card id int primary key auto incr
  • unity 内嵌网页简单流程(3D WebView 3.14.1)

    我是用于 web 平台 特此记录 3D WebView 主要实现在unity 中制作网页浏览器 可使用平台 很强大 其他类似插件都有平台缺陷 Android iOS UWP Hololens Windows macOS WebGL 0 插件
  • 制造行业主数据同步集成

    主数据是描述企业核心业务实体的数据 是企业核心业务的主要构成 各个订单 合同以及业务的主体 在企业内部被重复 共享应用的数据 主数据跨越企业各个业务部门以及各类业务系统 是应用系统间数据交互的基础 近期一直北方某制造业进行主数据治理工作 谈
  • React Router源码解析

    虽然React Router已经到了V6版本了 但在我们项目中 目前主要用的还是React Router的V5版本 所以此处我们从V5版本着手 去了解前端路由的实现原理 目标 希望收获 前端路由的基本原理 React Router 的实现原
  • Scanner的.next()以及.nextLine()各自代表什么意思

    String str new Scanner System in nextLine String str new Scanner System in next next 方法一次读取一个无间隔子串 比如 TAB 空格 回车符 的时候 会终止
  • Chromium OS 初体验

    Chromium OS可是早有耳闻 但是一直没有尝试 最近很多评论甚至认为会对Windows和Mac都能够造成压力 于是迫不及待的想尝试一下了 百度下了官网 官网很贴心 不光给了用于写入U盘的镜像文件 最初是针对上网本的 所以自然不是刻录成
  • Python 基础知识6 字典

    字典 定义字典 d key1 22 key2 meng print d print type d 访问字典里的值 dict Name Runoob Age 7 Class First print dict Name dict Name pr
  • 在Unity中编写单元测试

    最近在我忙于我的最新项目时 我一直在思考 我如何能单元测试代码 我知道如果我先把它搁一边 在编写一大段游戏代码后 我可能再也不会回头来写测试了 编写单元测试对我有两个挑战 首先 游戏不同于其他类型的软件 没有好的代码分段来处理好输入 以及图