来 testhome10 天了,发了两篇质量保证的水文,全是理论,有些读者老爷希望我来点干货。那今天就发一个干货,这是我测试三年来关于游戏接口自动化测试方面的心血,必定全网唯一,希望大家多多支持,多多点赞!(长文预警)

1.背景

1.0 作者背景

先说一下我的背景,上海某游戏公司 QA,3 年游戏测试经验,虽然工作时间不长,但是接手过一个大型的页游项目和一个在研手游项目,对接口自动化方面有一定的思考和技术沉淀。

1.1 游戏测试行业的现状

国内的游戏测试行业目前普遍的是功能测试走天下,偶尔出来个 UI 自动化测试就了不得了。众所周知,UI 自动化开发和维护成本高,投入性价比很低,而游戏又是一种界面千奇百怪又元素布局纷繁复杂的软件,UI 自动化更是得不偿失,即使上马了自动化测试项目,往往都是虎头蛇尾,在维护方面难以为继。

据我的观察,游戏测试应该是落后外面 5 年的技术沉淀。为什么其它互联网企业,自动化测试满天飞,游戏测试行业依然如此原始。可能游戏行业已经被踢出互联网行业,划归为传统软件开发领域了吧。😆

1.2 接口自动化测试的难点

虽然我们游戏行业落后,但是我们游戏也有接口可以测试啊!既然可以接口测试,干嘛不自动化呢?提高测试效率,多好的事儿啊。好是好,但没人做啊。

1.3.1 人员素质

游戏测试薪酬普遍比同行低几千,基本上招的都是黑盒测试,技术能力薄弱,接口测试还能搞搞,你让他们开发接口自动化平台,抱歉,没那个能力。所以游戏测试做到最后,并不是成为技术大牛,大部分转游戏策划了。毕竟游戏是我测的,我还不了解嘛,照葫芦画瓢也能写个策划案出来。

1.3.2 通信协议特殊

对于大部分的游戏而言,客户端和服务端交互,都是基于 TCP 协议自定义传输协议。划重点,用的不是 HTTP 协议!接口测试所需的数据的发送、接收和验证功能都必须建立在:有一个完备的游戏客户端的基础之上,完备性体现在能够正确接收和处理异步消息(包括服务端的推送消息和接口返回消息)。

1.3.3 没有测试工具

由于通信协议的特殊性,相当一部分(只支持 HTTP 协议的)接口测试工具是没办法用的。所以游戏测试人员测接口,基本上都是各个公司开发人员编写的发包工具来测试的。开发大哥给你个测试工具就不错了,你还想怎么样?什么?要写个自动化测试工具?一边凉快去,没看到我正忙着嘛!

1.3.4 没有公司的支持

对于老板来说,游戏出 bug 有什么大不了的,给玩家补偿就好了,我要的是游戏能尽快上线赚钱,其它的一律靠边站。你让我开高工资招测试开发,还要花时间搞这些花里胡哨的东西,那是不可能的😆

另一方面,公司的预算,更倾向于原画、策划、前端,原画可以画皮肤卖,策划可以想玩法套路,前端可以把东西做出来,怎么说都是性价比更高。

1.3.5 其他

其实还有其他方面的不利因素,比如:立项时就没有考虑到以后要做接口自动化,设计的时候也不会在给予便利;没有 HTTP 协议的那种 restful API,没有系统化结构化的接口设计,测一个 case 要有一套复杂的前置动作等。(但是我觉得那都不是主要原因)

1.3 实现自动化的意义

既然上面有一堆不利因素,你为什么还要去做接口的自动化?

一个很重要的现实因素:因为项目组就我一个 QA,我不做自动化,意味着每次更新,我都要手动去做回归测试。程序员最讨厌的事情是什么:重复!当我做了实现自动化,我可以把节省下来的时间用来喝咖啡啊,多好!

还没怎么介绍干货呢,废话倒是讲了一堆😅,下面要进入正题。

2.做一个游戏客户端

理论上这个时候要讲接口自动化测试框架设计,但是现在连接口测试工具都没有,也谈不上设计自动化测试框架,先写个游戏客户端作为测试工具吧。

2.1 游戏客户端架构设计

游戏客户端

2.2 做一个 socket 客户端

2.2.1socket 客户端

这个好做,随便一个编程语言,都有网络编程,你照着例子,你就能写出一个 socket 客户端。以 python 为例:

import socket

class Client(object):
    def __new__(cls, playerId, game_server, certificate):
        cls.playerId = playerId
        cls.game_server = game_server
        cls.certificate = certificate
        cls.cache = b""
        return super(Client, cls).__new__(cls)

    @classmethod
    def server_connect(cls, server):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect(server)
        return sock

    def receive(self, sock):
        pass
    def send(self, *args, **kwargs)
        pass

2.2.2 数据封装

我们知道 socket 数据传递的形式是二进制数据流,一般接口数据是以 json 格式为主,这就涉及到了数据的封装。简单来说就是把 json 数据转化为字符串,然后再通过封装成二进制数据流。

对于数据封装,就涉及到了各个公司自定义的通信协议格式。我们自定义一个协议(对于客户端而言),收发的数据报文的格式如下:

数据报文

假如我要发送一个接口,数据如下:

"""
服务器id:10203
接口名称:store@buy
数据包id:5
数据内容:giftId=7392&useTicket=1&ticketId=357860382
"""
import struct

serverId = 10203
command = "store@buy".encode("utf-8")
packId = 5
packData = "giftId=7392&useTicket=1&ticketId=357860382".encode("utf-8")
packLength = 40 + len(packData)
# 最终发送的数据流
data = struct.pack(">ii32si{0}s".format(
    len(packData)), packLength, serverId, command, packId, packData)

接收服务端的推送就可以这样解析:

"""
接口名称:store@buy
数据包id:5
状态码:200
数据内容:{msg:"purchase success!"}
"""
import struct

# 假设接收到的数据流如下
cache = b'\x00\x00\x00Cstore2@buy\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\xc8{msg\xef\xbc\x9a"purchase success!"}'
packDataLength = len(cache) - 40
# 接收数据流
struct.unpack(">32sii{0}s".format(packDataLength), cache)

这里的状态码可以我使用了 http 的状态码,你也可以自定义一套。

2.2.3 数据压缩解压缩

前面的数据都是没有经过压缩的,现在压缩是数据传输的标配,一方面是为了节省带宽,另一方面是为了节省磁盘消耗。

在 python 中,二进制数据流压缩使用 zlib 库。对于我们的通信协议而言,值得压缩的数据,就是数据主体部分。

import zlib

packData = zlib.compress(original_data)
original_data = zlib.decompress(packData)

2.2.4 数据加密

大部分游戏数据不需要加密,但是特殊场景下也是需要数据加密的,比如登陆或者支付等场景。常规的非对称加密思路如下:

  1. 客户端和服务器端建立连接
  2. 客户端产生非对称密钥,将公钥传送给服务器端
  3. 服务器端通过公钥将密钥进行加密并传送给客户端
  4. 客户端接收到密钥并进行解密,双方开始通信

类似地,数据加密针对的是数据包体的部分,在 python 中使用 rsa 库对这部分二进制数据加密。

import rsa

def rsa_encrypt(string):
    (public_key, private_key) = rsa.newkeys(512)
    encrypt_string = rsa.encrypt(content, public_key)
    return (encrypt_string, private_key)

def rsa_decrypt(string, public_key):
    decrypt_string = rsa.decrypt(string, public_key)
    return decrypt_string

2.3 接口层

2.3.1 接口定义

接口定义在 Command 类中,单独的方法,参数一目了然,方便调用,也有利于拓展。

class Command(object):
    @staticmethod
    def store_buy(serverId, client, packId, giftId, useTicket, ticketId):
        client.send(serverId, "store@buy", packId, giftId=giftId, useTicket=useTicket, ticketId=ticketId)
    @staticmethod
    def store_info(serverId, client, packId):
        client.send(serverId, "store@info", packId)
commands = Command()

2.3.2 接口处理

自定义一个 Handler 类处理服务端推送消息处理器。work 是事务处理方法,test 是测试时的数据验证方法。

class Handler(object):
    def __init__(self, robot):
        pass
    def work(self, robot, response_data):
        """
        事务用处理器
        """
        pass
    def test(self, robot, response_data):
        """
        测试用处理器
        """
        pass

class StoreBuy(Handler):
    def work(self, robot, response_data):
        state = response_data.get('')
        commands.store_buy(serverId, client, packId, giftId, useTicket, ticketId)

class StoreInfo(Handler):
    pass

2.4 消息处理层

这部分已经是游戏客户端的逻辑了。

2.4.1 客户端数据

将客户端定义为 Robot 类,客户端与服务端的交互数据都放在这个类中。

import multiprocessing

class Robot(object):
    inputs = list()
    outputs = list()
    socks = dict()
    playerData = dict()
    packId = 1
    def __init__(self, client):
        self.client = client
        self.queue = multiprocessing.Queue()

2.4.2 消息监听

使用 select 模块,由于是系统暴露的接口,效率比较高。我的破电脑是 windows 的,没办法用 epoll。在做压力测试的时候,大量的游戏客户端跑在一个机器上,用 epoll 效率就高很多了。

import select

class Robot():
    def message_collector(self)
        while self.inputs:
            print(self.inputs)
            readable, _, _ = select.select(self.inputs, [], [])
            for sock in readable:
                response = self.client.receive()
                self.queue.append(response)

2.4.3 消息处理

Handler 类中,取所有接口处理器字典作为 handlers。

handlers = {
    "store@buy": StoreBuy,
    "store@info": StoreInfo
}

Robot 中添加接口处理机制:

class Robot(object):
    @staticmethod
    def handler_selector(cmd):
        return handlers.get(cmd)

    def message_handler(self):
        while True:
            response = self.queue.get()
            handler = self.handler_selector(response[0])
            handler.work()

3.如何实现接口自动化

其实当你完成了游戏的客户端,那么测试的设计,就是小菜一碟了,理论上就是运行过程中的数据验证问题。

3.1 设计思路

自动化测试设计思路

3.2 测试用例

3.2.1 代码部分

在 Handler 中添加 test 方法,正常逻辑是走 Handler 的 work 方法,测试验证逻辑走 test 方法。

class Handler(object):
    def __init__(self, robot):
        pass
    def work(self, robot, response_data):
        """
        事务用处理器
        """
        pass
    def test(self, robot, response_data, expect):
        """
        测试用处理器
        """
        pass

class StoreBuy(Handler):
    def test(self, robot, response_data, expect):
        state = response_data.get('state')
        if state == expect['state']:
            print("test pass")
        else:
            print("test failed")

3.2.2 数据部分

测试用例主要有 4 个部分:id,接口名,发送的数据,期望返回数据。

testCases = [
    {
        "id": 163,
        "command": "store@buy"
        "data": {"giftId": 126, "useTicket": 1, "ticketId": 43992687}
        "expect": {"state": 200}
    }, {
        "id": 164,
        "command": "store@buy"
        "data": {"giftId": 126, "useTicket": 1, "ticketId": 0}
        "expect": {"state": 402}
    }
]

3.3 测试过程

3.3.1 执行逻辑

我们的验证逻辑是写在处理器的 test 方法里的,因此,只需要在 message_handler 中判断是不是需要调用 test 方法。

class Robot(object):
    def message_handler(self, testcase):
        while True:
            response = self.queue.get()
            handler = self.handler_selector(response[0])
            if response[0] == testcase.get('command'):
                handler.test()
            else:
                handler.work()

3.3.2 结果记录

Robot 类添加一个结果收集列表。

class Robot(object):
    testOutput = list()

将执行结果输出到列表中

testOutput = [
    {
        "command": "store@buy",
        "state": "fail",
        "playerData": self.playerData
    }
]

3.3.3 日志输出

为 Robot 对象添加 logging 方法,将日志输入到文件中

import time

class Robot(object):
    def logging(self, level, msg):
        with open("log.txt", "a") as fp:
            data="{datetime} [{level}] {msg}".format(
                datetime=time.strftime("%Y/%m/%d %H:%M:%S"), 
                level=level, msg=msg)
            fp.write(data+"\n")

3.4 测试报告

大家自行选择合适的展示方式,我倾向于将结果输出到 html 文件中。

3.5 持续集成

使用 linux 自带的 crontab 模块,定时执行测试任务,生成测试报告。

利用 Jenkins,新建触发器 Poll SCM,新建自动化接口测试任务,代码更新后触发自动化测试。

3.6 小结

这种验证方式,理论上适用所有基于 socket 协议的 C/S 应用,大家可以自行尝试。😋

4.拓展应用

4.1 游戏测试场景构建

假如游戏使用了 H2 数据库,这是一种内存数据库。这意味着游戏运行时,你很难直接修改数据库的数据,除非开发人员事先开发了专用的接口。某一个活动需要 2000 人报名,并且每个人都需要通过战斗获取勋章和积分,最后根据勋章和积分排名每个人都有随机的奖励。

对于这样一个测试场景,传统造数据方式:先停服,然后编写 SQL 将数据写入 MySQL,然后启动游戏服,让数据从 MySQL 数据库中读入内存数据库。

如果时使用接口自动化的工具,那么只要开启多进程,发送活动报名接口,再发一些战斗接口,就很轻松地创建了测试场景。既方便了测试,方便了前端开发。

4.2 压力测试

选择单一接口或场景,多进程跑接口,对服务端施加负载。

jvm 运行:使用 jstack,Jprofiler 等工具,

接口响应时间:游戏客户端编写接口响应时间统计模块(思路:接口发送时间和接收时间差,由于服务端存在推送包,数据包的发送后收到的包不一定是响应包)。

cpu、内存占用,psutil 和 matplotlib 绘制性能曲线。

TPS:awk 统计服务端日志

4.3 机器人仿真模拟

给游戏客户端制定一定的行为策略,就是游戏机器人。机器人可以模拟用户的操作,我们可以收集信息,供策划决策。

策划希望研究不同计策对游戏胜负的影响,那么我只要设计场景,游戏机器人带不同计策,记录游戏结果。重复若干场,得到统计数据。策划可以分析数据,调整计策的数值。

比如火攻计策对玩家的伤害过高,不符合策划对于改计策的定位,也影响了玩家的游戏体验,削弱。

我们知道王者荣耀的英雄数值,即使上线后还在调整,如果有了机器人仿真模拟,得到了某英雄太弱,可以提前调整数据,从而避免了把问题带到了线上。

5.作者的话

我一个测试搞了这么多幺蛾子干嘛,好好做功能测试不好吗😆

写这篇文章花了 4 天时间,不知道版主大哥能不能赏脸给个精华贴。


↙↙↙阅读原文可查看相关链接,并与作者交流