当我们要测试一个’算法’是否正确时,常常会打开代码编辑器为其编写测试代码。
这种测试往往被称为单元测试,即测试相对独立的最小单元。
由于算法往往都是一个独立的个体,没有依赖,因此很容易为其编写测试代码。
这里的“容易”指的是,我们除了设计testcase
之外,不会有额外的心智负担。
当我们开始为业务逻辑编写测试代码时,其复杂程度往往会是我们放弃的开始。
举个例子,class A
依赖class B
和class C
。
当我们为class A
编写测试代码时,为了解决class B
和class C
对测试结果的干扰,或者验证class A
执行结果的正确性,往往需要为class B
和class C
编写mock class。
这意味着我们在每写一个class
的同时,也需要维护至少一个对应的mock class,以方便依赖的模块来编写测试代码。
在一些编程语言中,还意味着我们需要为每个class
提供一个interface
的定义。即,class A
依赖的是interface B
和interface C
,而class B
和class C
只是interface B
和interface C
的实现。不然无法实现测试时注入mock class的需求。
我简单尝试了一下,很快便放弃了。
一是因为它对业务有侵入性,需要强制定义interface
;二是因为我认为,为每个class
维护一个mock class,工作量太大了。
后来我尝试对这种测试思路做出一些简化。
在编写测试代码之前,我会先人工分析出这些class
之间的依赖关系。先为依赖链中的末端class
编写测试代码。
当某个class
的测试代码成功执行后,就认为这个模块是正确的。然后再依次向上进行测试,直到测试完依赖链的顶端。
在这个过程中,我不会提供mock class
,而是直接读取到所需要的class
中的内部状态来验证结果是否正确。
以上面的依赖关系为例,我会先为class B
和class C
编写测试代码。
当我认为class B
和class C
没有bug
时,就会开始为class A
编写测试代码。
为了验证class A
结果是否正确,我会直接读取class B
和class C
的内部状态来验证。
但这依然不足以让我为游戏服务器编写测试代码,因为游戏服务器是有状态的,而且依赖关系远比我想象的更复杂。
有一天,我突然灵光一闪,想看看lua
和redis
是怎么来做测试的。
我发现它们也没有mock class
(这里的class
仅代表某个代码单元,不局限于语法)。
但是作为互联网开源软件,它们的测试是非常到位的,不然无法保证质量。
它们采用的方式都很类似,都是针对特性进行测试,而不是某个函数或class
。
lua
作为语言虚拟机,它是没有API
的,但为了帮助测试,它会在测试模式下导出一些API
用于感知当前luaVM
的内部状态。
redis
作为一个单独的服务,它是从协议
层面去测试每一个特性的。
这给了我很大的启发。我想游戏服务器也是需要从协议
层面去测试所有功能的,虽然这样代码覆盖率没有单元测试那么高,但想必对代码质量也是有足够帮助的。
既然通过协议
来对游戏服务器进行测试,那就势必要启动真正的游戏服务器,这就会导致更多的外部依赖:时间
、配置
、数据库
。
-
时间
会要求我们在测试某个功能时,服务器时间必须处于某个指定的时间区间。 -
配置
会要求我们在测试某个功能时,某个配置必须包含多少条数据或什么数值,以便测试一些corner case
。 -
数据库
会有持久化问题,因此某个功能测过一遍之后,状态就被保存下来,下次就无法再次触发了。
这些外部依赖无一不在揭示着“测试代码不能重复执行”这一问题。
为了解决这个问题,我当时的设想是,每测试一个功能就将数据库清空,然后时间修改到本功能所需要的时间,同时为每一个testcase
保留一份配置用于测试。
但随之而来的问题就是,因为清档了,所以每执行一个测试用例,都必须要创建新号。
而测试A模块时,它可能对B模块的数据有依赖,否则协议就无法成功执行。
我最初的想法是,在测试代码库中提供很多辅助条件函数。
随着testcase
的增加,需要新增的辅助函数将会越来越少。
比如我要测试A模块,它需要玩家等级达到35级,那么就有一个辅助函数,它可以将玩家升到指定等级(是通过正常协议途径,而不是GM直接修改,因为升级有可能还会触发连带效应)。
后面再依赖玩家等级的testcase
就不需要再编写这个辅助函数了。
这虽然很麻烦,但理论上它是完备的,可以测试任意代码。
为了验证这一想法,我挑了一个SLG的核心地图玩法,为其编写了测试代码,来证明它的可行性。
事实证明,这确实是可行的,但也确实是麻烦的。
因为我不仅遇到了为玩家升级,还碰到了为英雄升级,为技能升级,装备技能,操作队伍等一系列的测试辅助需求。
我想朝着这个方向继续简化,却一直没有头绪。
随着这两年我对游戏服务器理解的加深,以及见到了不一样的GM
创号思路,我发现我终于可以简化这一流程了。
首先,我之前为每个testcase
保存一份配置表是没有必要的。
一般来讲,每个模块只会需要有限的几个表或几行数据。
我们只需要在测试框架中提供修改配置表的能力即可。
在运行测试代码前,可以直接将配置改成测试代码需要的数据即可。
其次,为了解决模块间的数据依赖,使用辅助函数达到本模块要求是可行的,但不是必要的。
据我这两年的观察和反思,模块间的依赖其实没有那么紧密,大部分情况下只要分为达到
和达不到
即可。
比如我需要玩家等级为30级,那么100级也是满足的。
因此提供一个GM指令,创建一个足够高级的号就足够了。
如果恰好我们需要测试的模块也被高级号给初始化了,我们的testcase
代码可以直接操作数据库,清空这部分数据,然后重新登录即可。
lua
在测试模式下导出一些API
用于感知当前luaVM
的内部状态,也给了我一些启示。
我们可以增加一个指定GM
指令用来获取玩家或全服的状态,用于我们在测试代码中感知游戏服务器进程中的准确状态。
如果游戏服务器采用了分布式技术,还需要提供一个透传GM指令,用于获取分布式系统中某个服务进程中的状态。
除此之外,测试框架还需要提供重启集群和动态修改时间的能力。
总结如下,测试框架只需要提供如下能力即可满足绝大部分测试需求:
-
创建高级号的能力
-
直接访问数据库的能力
-
修改配置文件的能力
-
修改服务器时间的能力(包括进程运行时修改)
-
重启集群的能力,用于每测试一个用例,都清档重启
-
感知集群内进程状态的能力
至此,经历七年的思考,我认为这个测试方案终于达到了可以实践的阶段。
ps. 本文是我在实践2个月之后写出的,基本上可以确定心智负担极小。
pps. 让我们将视野拉高,由于每次测试都将数据库清空,因此数据库中的数据也可以等价于内存结构。与此同时,很多单元测试的技巧也可以拿来使用。比如要保持测试用例尽可能的小(一旦测试用例过于复杂,测试用例的正确性就不能得到保证)等。