谈谈游戏服务器的自动化测试

当我们要测试一个’算法’是否正确时,常常会打开代码编辑器为其编写测试代码。

这种测试往往被称为单元测试,即测试相对独立的最小单元。

由于算法往往都是一个独立的个体,没有依赖,因此很容易为其编写测试代码。

这里的“容易”指的是,我们除了设计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指令,用于获取分布式系统中某个服务进程中的状态。

除此之外,测试框架还需要提供重启集群和动态修改时间的能力。

总结如下,测试框架只需要提供如下能力即可满足绝大部分测试需求:

  1. 创建高级号的能力

  2. 直接访问数据库的能力

  3. 修改配置文件的能力

  4. 修改服务器时间的能力(包括进程运行时修改)

  5. 重启集群的能力,用于每测试一个用例,都清档重启

  6. 感知集群内进程状态的能力

至此,经历七年的思考,我认为这个测试方案终于达到了可以实践的阶段。

ps. 本文是我在实践2个月之后写出的,基本上可以确定心智负担极小。

pps. 让我们将视野拉高,由于每次测试都将数据库清空,因此数据库中的数据也可以等价于内存结构。与此同时,很多单元测试的技巧也可以拿来使用。比如要保持测试用例尽可能的小(一旦测试用例过于复杂,测试用例的正确性就不能得到保证)等。

发表评论

90 − = eighty eight
Powered by MathCaptcha