每年的GDC大会上,全球顶尖的游戏开发者们将齐聚在这里,交流彼此的想法,构想游戏业的未来方向。接下来雷火UX公众号会选择一部分高质量的演讲,陆续为大家进行介绍。旨在通过对这些演讲内容进行学习,了解游戏领域的最新研究热点趋势,并期待与全世界的游戏爱好者一起产生更多创新和灵感的碰撞。
GDC 2021
Kevin Dill
Programming
演讲者信息:
在传统观点中, 单元测试有着如下的定义:
单元测试是一段自动执行的代码, 它调用一个程序单元, 并检查该单元的一个功能.
https://www.artofunittesting.com/definition-of-a-unit-test
同时, 传统观点中, 一个好的单元测试需要具有以下的特征:
• 全自动执行
• 可读
• 可维护
• 一致性
• 顺序无关
• 快速
• 内存执行
• 原子性
而作为单元测试的集合, 单元测试代码库应当包含大量的单元测试, 并满足以下条件:
• 与代码一起实现
• 覆盖尽可能多的实现代码
• 每次修改时都执行, 并可能有额外的定期运行
可能有人会问, 为什么我们要花费额外的时间和精力, 去编写、维护、运行单元测试?那么接下来, 让我们先了解一下单元测试能带来的好处.
在代码完成或者更新后,运行单元测试,,可以让我们立刻发现代码中的错误,并开始进行修复.
修复一个Bug的最佳时机是什么时候?正是刚刚写出这个Bug的时候. 此时,你了解代码的功能,并且了解代码的实现过程. 在此时进行Bug修复,不仅更容易完成修复,同时也能更好地避免引入其他Bug.
单元测试能精准地定位问题代码
如果是QA发现的Bug,并反馈给你,那么你常常会需要一步一步地进行Debug,从现象开始,一层层深入到引发问题的代码. 同时,对于一些比较特殊的案例,Bug的复现本身可能都需要非常多的尝试.
而在单元测试中发现的问题,可以直接定位到引发问题的代码,并且可以保证稳定的复现问题. 对开发人员来说,问题是否可以稳定复现,会极大地影响解决问题的难度.
单元测试可以同时作为代码的文档
通过阅读单元测试,可以迅速了解代码的功能和调用方式. 这一点在在查看旧的代码,或者其他人的代码时非常有用.
例子1-路径规划系统
作为例子,Kevin提到了他开发的一个路径规划系统.
Kevin为自由世界模式的游戏进行了路径规划系统的开发,用于任务中的自动寻路。游戏的主要流程中,由玩家来进行操作,因此不会使用路径规划功能. 但在一些任务中,需要系统进行自动寻路. 因此,这是一个很少使用的系统. 这个系统的功能实现较早,但一直未加入到游戏内。当游戏中终于出现实际需求,开始进行路径规划系统的对接时,作为作者的Kevin已经在进行另一个项目了.
这时,对接人对这个系统的功能提出了一个问题:是否能处理人行横道?
因为开发的时间已经过去了很久, Kevin已经无法立刻回答这个问题了. 在他模糊的印象中, 这个系统在一开始时是无法处理人行横道的, 但是否在之后加入了这个功能呢? 他一时也说不上来.
这时, 就是测试用例发挥文档作用的时候了. 打开测试代码, 查找Crosswalk, 发现了如下的测试用例:
这个测试给出了如图所示的路径规划结果:
通过这个测试, 我们可以立刻得出结论: 这个系统可以处理人行横道. 同时, 这一个测试用例还能告诉我们, 如何调用路径规划, 以及对人行横道的处理结果.
通过测试用例, 我们可以很清晰地了解系统中相关功能的工作方式和预期结果, 而不必去阅读源代码.
单元测试可以保证代码的正确性
在游戏开发的过程中, 需要持续修改源代码. 而在反复的修改中, 要如何保证每次的修改不会影响之前的功能? 在大型代码库中, 只依靠开发人员的经验, 是完全不可能覆盖所有可能受到影响的情况的. 而单元测试正是提供这样的保证的一个有效手段.
当然, QA所执行的回归测试也是对功能正确性的保证. 但正如之前所说的那样, QA所做的测试通常是从整体出发, 由外到内的, 而单元测试则注重单个功能, 贴近底层. 因此, 这两种测试往往能发现不同的Bug, 并且对开发人员来说, 单元测试中发现的Bug会更加容易修复.
例子2-行人防碰撞
Kevin提到了另一个例子: 在一个游戏中增加行人防碰撞系统. 这一系统可以在人群密度较高时避免出现行人位置重合的情况.
他们使用的系统需要根据人群密度, 为每个行人对象添加或者删除一个特定的防碰撞组件. 这个系统接入到游戏中后, 出现了一个这样的问题: 如果当玩家进入场景时, 人群密度从低变为高, 则会出现游戏崩溃的情况.
经过Debug, Kevin发现了问题的原因: 玩家进入场景的过程为: 1. 添加玩家(将同时添加防碰撞组件) 2. 为场景内的所有行人更新人群密度, 同时添加防碰撞组件.
由于步骤1和2都会为角色添加防碰撞组件, 因此出现了冲突, 导致游戏崩溃.
显然, 我们可以简单地通过调换这两个步骤的顺序来修复这个Bug, 但问题是, 这样是否会引入其他的Bug?
在一个大型的系统中, 很可能会有其他的组件依赖于这一行为, 而在没有测试和文档的情况下, 其他的开发人员对此是并不了解的. 由于没有相关的测试, Kevin不得不去询问添加玩家相关功能的开发人员, 确认调换顺序是否会带来Bug.
测试的缺失, 导致了修改时缺乏信心, 无法保证修改的正确性. 并且, 在修改引入了Bug的情况下, 单元测试的缺失还会导致无法在修改后迅速发现问题.
传统观念中的单元测试存在过多的限制, 因此Kevin基于他的经验, 重新对“单元”测试进行了定义:
“单元”测试是一段自动执行的代码, 它调用系统的功能并进行检查.
可能在软件工程师的定义中, 这样的测试已经不再属于单元测试的范围, 但我们仍将它称为“单元”测试.
同时, 对传统观点中, 一个好的单元测试需要具有的那些特征, Kevin也提出自己的看法.
Kevin赞同全自动执行、可读性、可维护、一致性、顺序无关这几个特征的必要性.
全自动执行保证了单元测试在每次修改代码后进行, 进而保证了Bug的迅速发现. 可读性保证了出现Bug时, 能迅速地确定导致Bug出现的代码, 并能告诉我们: 我们的预期行为、实际行为、为什么实际行为不符合预期. 由于代码会频繁地变化时, 因此也需要对测试进行相应的更新, 因此可维护性也是单元测试的必要特征. 一致性是单元测试发挥作用的基本要求, 每次运行时, 所有正确的代码会通过测试, 错误的代码会导致相同的异常. 因此, 我们才能将单元测试作为稳定复现Bug并进行解决的手段. 顺序无关同时也是一致性的一部分, 这一特性允许我们可以分别执行不同组件的测试, 并得到预期的结果.
单元测试是否需要快速完成? Kevin认为有一定程度的必要性, 但不必过度追求. 我们不必强行追求整套单元测试能在30s内运行完毕. 如果一套测试可能需要花费几分钟才能运行完毕, 这时可以考虑对测试进行优化, 加快测试的运行速度. 但是, 这并不是最重要的事情, 即将在下一周截止的里程碑才是最重要的. 即使测试运行稍慢, 我们可以将运行测试的窗口放到一边, 在等待测试完成的同时进行下一步的代码开发. 如果忘记检查测试结果, 当代码被提交时, 自动测试的结果会让整个团队的人知道你又提交了一个Bug, 相信这样的后果会让你下一次记住在本地进行测试并检查.
传统观点要求单元测试仅在内存中运行. 而Kevin认为, 我们不应该在乎单元测试是否仅在内存运行. 内存运行的主要目的是执行得更快, 但正如刚才所说, 没有必要过度追求. 如果使用硬盘能让某个测试更容易实现, 那么, 不必有任何的犹豫.
而传统观念中对原子性的追求, 在Kevin看来是一个错误的方向. 原子性要求每个单元测试只对系统中的单个功能进行测试, 系统的其他组件则通常需要用模拟的方式进行实现. 听上去很好, 但是维护这样的单元测试就是一个噩梦. 与之相反, 如果我们直接引用其他组件, 那么就不再需要单独维护模拟实现, 同时还能几乎“免费”地让测试覆盖更多的代码. 当然, 专注于测试目标是一个好的想法, 但同时, 能在测试主要目标的同时对其他组件进行测试也是一个优点. 例如, 在测试中往往会出现一些边缘情况, 这时使用的其他组件可能会出现错误. 而这类错误很可能是那个组件的开发人员和QA都没有考虑到的. 因此, 当你的测试代码需要依赖其他组件时, 不必感到害怕.
首先, 你应当对公共API进行测试. 但是, 专门为测试而将内部过程暴露为公共API是一个相当不好的实现方式. 不仅仅是因为你暴露了内部过程, 而且这种情况常常说明你测试的粒度太小, 你所测试的实施细节是代码中最常改变的部分. 你应当测试的对象是公共API, 而公共API是不常改变的, 即使对代码进行重构, API通常也需要保持一致.
但是, 有很多内容的确是很难仅仅通过API进行测试的, 如果确实需要对一些实施细节进行检查, 那么应该如何做? Kevin给出了他的解决方案: 将测试注入代码. 通过在代码中使用断言, 抛出错误和警告, 让测试失败. 在修复的Bug的过程中, 逐个消除这些错误和警告.
此外, 还可以对预期的错误进行检查, 例如给出一个损坏的文件, 然后检查代码是否给出了预期的异常信息.
简单而言, 测试注入就是在代码中对边缘情况进行检查并保证正确性, 而测试时只需要设计特殊的输入并触发边缘情况.
随着代码的更新, 你自然会对抛出这些错误和警告的代码进行修改, 而这时将不必再单独修改测试用例.
通过测试注入, 我们还可以方便地实现压力测试.
如图中的代码所示, 直接加载一个大型的地图, 添加大量的车辆和行人, 然后执行场景更新. 代码会通过抛出异常和警告来告诉我们, 系统在这样的情况下是否会出现Bug.
总会有各种原因, 阻止我们使用单元测试, 像是代码量太大, 维护太难, 不知道怎么入手等等. 一个很常见的情况是, 当前已有庞大的代码库, 但却一个单元测试都没有, 因此不知道应该从哪里开始. 此时, 就需要一些折衷, 放弃对测试覆盖率的追求, 从第一个测试开始实现.
毕竟, 有代码被测试, 始终优于没有代码被测试.
正如测试大师 Michael Feathers 所说: > 不好的测试经常运行, 也超过好的测试从不运行.
所以, 只要你的测试不至于糟糕到只能影响你的开发速度而无法帮助你, 你就应该进行测试.
因此, 从下一行代码开始, 或者从下一个需要修复的错误开始, 写一个测试. 这一个测试能保证这个错误确实被修复了, 并且没有因为其他的修改再次发生. 这时, 你已经在进行代码级别的回归测试了, 这时对QA的回归测试的一种补充.
有时, 维护测试的成本会大于测试所带来回报. 这也是Kevin很多次为之苦恼的问题. 如果你发现你正处于这种情况, 可以考虑一下这些方案: 1. 尝试增大测试的粒度, 对更大的系统进行测试, 而对子系统则在这一过程中隐式地进行测试. 2. 将验证注入到你的代码中, 通过抛出错误和警告来让测试失败, 从而避免在测试用例中构造错误和警告.
游戏行业的开发者可能会有不同的习惯和态度. 但是, 只要测试确实确实有利于开发, 那我们始终还是需要测试.
也许确实有一些特殊的情况, 需要暂时放弃测试, 但这时仍需要保证, 在回顾时进行补充. 例如, 在里程碑即将截止时, 项目经理会问开发人员: 里程碑只有一周就要截止了, 你是否还有时间来编写测试? Kevin的回答是, 必然有. 因为测试已经是他代码的一部分. 对他来说, 不能对代码进行测试是很荒谬的. 但同时, 他的团队中既存在熟悉测试编写方法的成员, 也存在不了解测试、不会编写测试的成员. 在这种情况下, 一部分测试可能被跳过, 但在之后, 必须进行回顾和补充.
对于测试所带来的时间消耗, Kevin所相信的是: 我们没有时间不写测试. 维护测试确实会带来一些额外的开销, 但只要建立起整套体系, 就可以极大地提高开发的效率. 你将会注意到这一点, 尤其是当你陷入疯狂的最后阶段, 那时任何一个错误或者缺陷都可能极大地影响游戏发行. 此时, 测试带来的正确性保证, 能在提交给QA之前自行对代码进行测试的能力, 都能带来非常大的帮助. 而如果你在此之前就放弃了测试, 就无法得到这样的能力和信心.
一套完善的单元测试体系, 可以让开发过程始终有正确性保障, 快速发现Bug, 减少Bug修复所需的精力和时间. 同时, 单元测试可以作为文档, 对代码进行说明. 而为了在维护测试的成本和测试带来的这些好处之间找到平衡, 可以尝试放宽对原子性和速度等特性的要求, 同时放宽测试粒度, 并利用测试注入等技巧, 减少测试维护的成本. 从下一行代码开始, 尝试一下用自动化测试加速你的调试和开发吧.
最后欢迎大家投递雷火UX设计部面向2022届毕业生的校招岗位
雷火UX商务沟通:grp.leihuoux@corp.netease.com