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

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

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

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

这里的“容易”指的是,我们除了设计testcase之外,不会有额外的心智负担。

当我们开始为业务逻辑编写测试代码时,其复杂程度往往会是我们放弃的开始。

举个例子,class A依赖class Bclass C

当我们为class A编写测试代码时,为了解决class Bclass C对测试结果的干扰,或者验证class A执行结果的正确性,往往需要为class Bclass C编写mock class。

这意味着我们在每写一个class的同时,也需要维护至少一个对应的mock class,以方便依赖的模块来编写测试代码。

在一些编程语言中,还意味着我们需要为每个class提供一个interface的定义。即,class A依赖的是interface Binterface C,而class Bclass C只是interface Binterface C的实现。不然无法实现测试时注入mock class的需求。

我简单尝试了一下,很快便放弃了。

一是因为它对业务有侵入性,需要强制定义interface;二是因为我认为,为每个class维护一个mock class,工作量太大了。

后来我尝试对这种测试思路做出一些简化。

在编写测试代码之前,我会先人工分析出这些class之间的依赖关系。先为依赖链中的末端class编写测试代码。

当某个class的测试代码成功执行后,就认为这个模块是正确的。然后再依次向上进行测试,直到测试完依赖链的顶端。

在这个过程中,我不会提供mock class,而是直接读取到所需要的class中的内部状态来验证结果是否正确。

以上面的依赖关系为例,我会先为class Bclass C编写测试代码。

当我认为class Bclass C没有bug时,就会开始为class A编写测试代码。

为了验证class A结果是否正确,我会直接读取class Bclass C的内部状态来验证。

但这依然不足以让我为游戏服务器编写测试代码,因为游戏服务器是有状态的,而且依赖关系远比我想象的更复杂。

有一天,我突然灵光一闪,想看看luaredis是怎么来做测试的。

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

发表评论

three + 7 =