来 testhome10 天了,发了两篇质量保证的水文,全是理论,有些读者老爷希望我来点干货。那今天就发一个干货,这是我测试三年来关于游戏接口自动化测试方面的心血,必定全网唯一,希望大家多多支持,多多点赞!(长文预警)
先说一下我的背景,上海某游戏公司 QA,3 年游戏测试经验,虽然工作时间不长,但是接手过一个大型的页游项目和一个在研手游项目,对接口自动化方面有一定的思考和技术沉淀。
国内的游戏测试行业目前普遍的是功能测试走天下,偶尔出来个 UI 自动化测试就了不得了。众所周知,UI 自动化开发和维护成本高,投入性价比很低,而游戏又是一种界面千奇百怪又元素布局纷繁复杂的软件,UI 自动化更是得不偿失,即使上马了自动化测试项目,往往都是虎头蛇尾,在维护方面难以为继。
据我的观察,游戏测试应该是落后外面 5 年的技术沉淀。为什么其它互联网企业,自动化测试满天飞,游戏测试行业依然如此原始。可能游戏行业已经被踢出互联网行业,划归为传统软件开发领域了吧。
虽然我们游戏行业落后,但是我们游戏也有接口可以测试啊!既然可以接口测试,干嘛不自动化呢?提高测试效率,多好的事儿啊。好是好,但没人做啊。
游戏测试薪酬普遍比同行低几千,基本上招的都是黑盒测试,技术能力薄弱,接口测试还能搞搞,你让他们开发接口自动化平台,抱歉,没那个能力。所以游戏测试做到最后,并不是成为技术大牛,大部分转游戏策划了。毕竟游戏是我测的,我还不了解嘛,照葫芦画瓢也能写个策划案出来。
对于大部分的游戏而言,客户端和服务端交互,都是基于 TCP 协议自定义传输协议。划重点,用的不是 HTTP 协议!接口测试所需的数据的发送、接收和验证功能都必须建立在:有一个完备的游戏客户端的基础之上,完备性体现在能够正确接收和处理异步消息(包括服务端的推送消息和接口返回消息)。
由于通信协议的特殊性,相当一部分(只支持 HTTP 协议的)接口测试工具是没办法用的。所以游戏测试人员测接口,基本上都是各个公司开发人员编写的发包工具来测试的。开发大哥给你个测试工具就不错了,你还想怎么样?什么?要写个自动化测试工具?一边凉快去,没看到我正忙着嘛!
对于老板来说,游戏出 bug 有什么大不了的,给玩家补偿就好了,我要的是游戏能尽快上线赚钱,其它的一律靠边站。你让我开高工资招测试开发,还要花时间搞这些花里胡哨的东西,那是不可能的
另一方面,公司的预算,更倾向于原画、策划、前端,原画可以画皮肤卖,策划可以想玩法套路,前端可以把东西做出来,怎么说都是性价比更高。
其实还有其他方面的不利因素,比如:立项时就没有考虑到以后要做接口自动化,设计的时候也不会在给予便利;没有 HTTP 协议的那种 restful API,没有系统化结构化的接口设计,测一个 case 要有一套复杂的前置动作等。(但是我觉得那都不是主要原因)
既然上面有一堆不利因素,你为什么还要去做接口的自动化?
一个很重要的现实因素:因为项目组就我一个 QA,我不做自动化,意味着每次更新,我都要手动去做回归测试。程序员最讨厌的事情是什么:重复!当我做了实现自动化,我可以把节省下来的时间用来喝咖啡啊,多好!
还没怎么介绍干货呢,废话倒是讲了一堆,下面要进入正题。
理论上这个时候要讲接口自动化测试框架设计,但是现在连接口测试工具都没有,也谈不上设计自动化测试框架,先写个游戏客户端作为测试工具吧。
这个好做,随便一个编程语言,都有网络编程,你照着例子,你就能写出一个 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
我们知道 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 的状态码,你也可以自定义一套。
前面的数据都是没有经过压缩的,现在压缩是数据传输的标配,一方面是为了节省带宽,另一方面是为了节省磁盘消耗。
在 python 中,二进制数据流压缩使用 zlib 库。对于我们的通信协议而言,值得压缩的数据,就是数据主体部分。
import zlib
packData = zlib.compress(original_data)
original_data = zlib.decompress(packData)
大部分游戏数据不需要加密,但是特殊场景下也是需要数据加密的,比如登陆或者支付等场景。常规的非对称加密思路如下:
类似地,数据加密针对的是数据包体的部分,在 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
接口定义在 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()
自定义一个 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
这部分已经是游戏客户端的逻辑了。
将客户端定义为 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()
使用 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)
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()
其实当你完成了游戏的客户端,那么测试的设计,就是小菜一碟了,理论上就是运行过程中的数据验证问题。
在 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")
测试用例主要有 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}
}
]
我们的验证逻辑是写在处理器的 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()
Robot 类添加一个结果收集列表。
class Robot(object):
testOutput = list()
将执行结果输出到列表中
testOutput = [
{
"command": "store@buy",
"state": "fail",
"playerData": self.playerData
}
]
为 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")
大家自行选择合适的展示方式,我倾向于将结果输出到 html 文件中。
使用 linux 自带的 crontab 模块,定时执行测试任务,生成测试报告。
利用 Jenkins,新建触发器 Poll SCM,新建自动化接口测试任务,代码更新后触发自动化测试。
这种验证方式,理论上适用所有基于 socket 协议的 C/S 应用,大家可以自行尝试。
假如游戏使用了 H2 数据库,这是一种内存数据库。这意味着游戏运行时,你很难直接修改数据库的数据,除非开发人员事先开发了专用的接口。某一个活动需要 2000 人报名,并且每个人都需要通过战斗获取勋章和积分,最后根据勋章和积分排名每个人都有随机的奖励。
对于这样一个测试场景,传统造数据方式:先停服,然后编写 SQL 将数据写入 MySQL,然后启动游戏服,让数据从 MySQL 数据库中读入内存数据库。
如果时使用接口自动化的工具,那么只要开启多进程,发送活动报名接口,再发一些战斗接口,就很轻松地创建了测试场景。既方便了测试,方便了前端开发。
选择单一接口或场景,多进程跑接口,对服务端施加负载。
jvm 运行:使用 jstack,Jprofiler 等工具,
接口响应时间:游戏客户端编写接口响应时间统计模块(思路:接口发送时间和接收时间差,由于服务端存在推送包,数据包的发送后收到的包不一定是响应包)。
cpu、内存占用,psutil 和 matplotlib 绘制性能曲线。
TPS:awk 统计服务端日志
给游戏客户端制定一定的行为策略,就是游戏机器人。机器人可以模拟用户的操作,我们可以收集信息,供策划决策。
策划希望研究不同计策对游戏胜负的影响,那么我只要设计场景,游戏机器人带不同计策,记录游戏结果。重复若干场,得到统计数据。策划可以分析数据,调整计策的数值。
比如火攻计策对玩家的伤害过高,不符合策划对于改计策的定位,也影响了玩家的游戏体验,削弱。
我们知道王者荣耀的英雄数值,即使上线后还在调整,如果有了机器人仿真模拟,得到了某英雄太弱,可以提前调整数据,从而避免了把问题带到了线上。
我一个测试搞了这么多幺蛾子干嘛,好好做功能测试不好吗
写这篇文章花了 4 天时间,不知道版主大哥能不能赏脸给个精华贴。