51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 1702|回复: 4
打印 上一主题 下一主题

[讨论] 聊一聊游戏的接口测试落地

[复制链接]

该用户从未签到

跳转到指定楼层
1#
发表于 2017-6-26 13:14:15 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
什么是游戏的接口测试
接口测试很官方的定义,上网一搜就有很多资料了。对于游戏而言,简单来说,接口就是服务端和客户端通讯请求的约定,客户端告诉服务端,我要做什么(操作协议号),怎么做(参数列表)。
举个栗子,升级技能,服务端和客户端有个约定:升级技能的操作协议号是110101,参数是技能的itemID(int类型),玩家点击了升级A技能(itemID:10001),客户端就发送包含协议号110101,参数为10001的封包到了服务端。
游戏接口测试的必要性
接口测试要不要做,就举两个如果没做接口测试,有可能会出现的bug吧:
重发领奖封包,可以重复领奖。
背包出售道具,修改售价溢出,获取大量游戏币。
接口测试的落地
这是游戏和APP很大的一个不同点。大部分的APP采用的通讯协议是公有协议,如HTTP。标准化的,成熟的协议,有不少测试框架和工具可以直接选择使用。
而游戏就略尴尬了。大部分都是私有协议,Socket通讯,封包结构自定义,数据采用二进制压缩传输,如Protocol Buffer。在工具的选择上,就没有APP那样百花齐放了。
有些团队会使用WPE,WPE是一个经典的网络封包编辑器,可以拦截,修改,重发Socket协议的封包,对于爱折腾的游戏玩家,是必备工具。操作简单,入门教程上网一搜就不少。但是使用起来,也不太方便。
对于二进制加密传输封包,WPE拦截到的封包,可读性不佳,乱码一团,修改封包的指定字段的数值也比较麻烦。
发送封包后,没有提供返回结果的显示,操作是否生效只能在游戏内确认。
虽然可以把发送过的封包保存起来,但是作为测试用例来统一管理是挺不方便的。
所以在经历的几个项目中,最终都是使用了内部开发的接口工具,而每个项目的接口工具的原理和使用方式区别还挺大,在此分享下。
项目1
刚刚从学校踏入测试坑那会,项目是一个SLG页游,服务端的主程MM在内网游戏服务器上开启一个Web服务,可以接收HTTP Get请求,指定格式如下:
  1. http://192.168.22.248/sftx/gameSocket/send?u=playerId&c=protocol&p=port¶ms=param1|param2|param3
复制代码
参数的含义如下:
  1. u:玩家ID
  2. c:操作协议号
  3. p:服务器端口号
  4. params:参数列表,多个参数使用"|"连接
复制代码
举个栗子:调用玩家升级主城协议
  1. 玩家ID:7001
  2. 操作是升级主城:协议号110001
  3. 参数列表:主城建筑ID是1001
  4. 内网端口号:默认5001
复制代码
所以相应的Get请求是
  1. http://192.168.22.248/sftx/gameSocket/send?u=7001&c=110001&p=5001¶ms=1001
复制代码
Web服务器接收到Get请求后,会解析出相应的玩家ID,操作协议,参数列表,自动开启一个Socket连入游戏服务器,执行相应的操作,并返回处理结果。
基于这个接口,可以对操作请求进行批量的新建,修改,发送,并解析返回结果。也因此实现了一些自动化的脚本,如批量建号,批量升级等,对工作效率的提升也是很明显的,比如新建军团后,跑个批量申请入团,军团就满人了。
当时的不足之处:发送请求后,因为Web那边会自行创建新的Socket连接,会自动挤号。这一点如果可以进行优化的话,就更好用了。
项目2
项目2是一个RPG页游,前后端使用Socket通讯,数据交互格式是AMF3。
当时后端底层在重写ing,所以没空折腾一个Web端口给我调用~这个时候前端主程FF站了出来,提出了一个方案。
游戏在网页上加载的时候,同时也加载一个测试用的js文件。
执行接口测试的方式,是在Chrome的console窗口,输入已经加载的js函数sendCommand,把操作内容作为函数的参数,回车运行后发送给Flash客户端,Flash客户端接收后,解析出相应的操作ID和参数列表,执行后在console窗口打印出服务端返回结果。
函数调用格式如下:

  1. sendCommand(“PackagesController”, “move”, 0, [“BACKPACK”,0,“BACKPACK”,30]);
  2. 操作模块:PackagesController  背包模块
  3. 操作行为:move   移动背包物品
  4. 参数列表:[“BACKPACK”,0,“BACKPACK”,30]   从第0个格子移动到第30个
复制代码
这个模式的一个好处:接口测试的请求是前端解析后,自行发出的,所以不会被挤号。
项目3
项目3是一个回合制的RPG手游,客户端使用Unity,Socket通讯,数据交互格式是PB,这次开始自己尝试独立完成接口工具,工具需求规划如下:
支持录制请求
可以对录制的请求进行复制,修改,删除
解析请求里边的参数列表
查看服务端的返回结果
自动校验返回结果
测试用例保存到本地
实现过程中的一些积累在此记录下:
录制的原理
点击一个技能升级的按钮的背后发生了什么?
UI接收到点击请求,调用技能模块
技能模块准备好参数列表,调用Server层的Send方法,生成一个请求
Server层接收后,对请求进行封装,加入校验key和请求头,压缩为PB格式,生成最终请求
发送给服务端
那么,要从哪里切入来录制请求呢?最终选择了在Server层接收后,对请求进行封装前,主要原因是,接口测试主要关注参数的不合理修改后,服务端能否做出正确判断,可以不用关心校验Key等其他信息,对请求进行修改后,点击发送,直接调用Send方法,底层就会完成新的请求封装和发送。省代码啊~
那么问题来了,如何录制?
一开始采用的方式,是直接在Send函数里边,嵌入了转存请求的代码,但是这个做法并不合理,因为已经直接修改了开发的代码,下次从git更新代码,会有冲突,后来调整为前端底层提供一个onRequest的事件,我在需要转存请求的时候,就注册自己的事件处理函数。
  1. public override void StartRecord() {
  2.     MsgCenter.AddMsg("Start to record Request");
  3.     EventHelper.Ins.Get<SystemEventGroup>().onRequest.AddHandler(OnRequest);
  4. }

  5. void OnRequest(ServerService ss, Request req) {
  6.     IList<object> paramList = null;
  7.     if (req.ParamList.IsNotBlank()) {
  8.         paramList = req.ParamList;
  9.     }
  10.     this.Add(new TRequest(req.Protocol, paramList));
  11. }
复制代码
转存请求的TRequest的定义
  1. [Serializable]
  2. public class TRequest : ICloneable {

  3.     public int protocol;
  4.     public IList<object> paramList;
  5.     public string des = "空描述";

  6.     public TRequest(int protocol, IList<object> paramList) {
  7.         this.protocol = protocol;
  8.         this.paramList = paramList;
  9.     }

  10.     public object Clone() {
  11.         MemoryStream stream = new MemoryStream();
  12.         BinaryFormatter formatter = new BinaryFormatter();
  13.         formatter.Serialize(stream, this);
  14.         stream.Position = 0;
  15.         return formatter.Deserialize(stream);
  16.     }
  17. }
复制代码
同样的,录制服务端的返回结果,也是类似的方式。
测试工具UI的编写
UI界面选择了Unity古老的OnGUI方法,原因就是:易学,够用。下边是一个简单的GUI界面。
  1. using UnityEngine;

  2. public class TestUI : MonoBehaviour {

  3.     private Rect windowRect = new Rect(Screen.width * 0.25f, 0, Screen.width / 2, Screen.height - 10);
  4.     public Vector2 scrollPosition = Vector2.zero;

  5.     void OnGUI() {
  6.             windowRect = GUI.Window(0, windowRect, WindowFunction, "接口测试工具");
  7.     }

  8.     void WindowFunction(int windowID) {

  9.         GUI.DragWindow(new Rect(0, 0, Screen.width/2, 30));
  10.         GUI.Box(new Rect(0,0,Screen.width,Screen.height),"");

  11.         GUILayout.BeginArea(new Rect(5, 20, Screen.width / 2-20, Screen.height));      
  12.         scrollPosition = GUILayout.BeginScrollView(scrollPosition,GUILayout.Width(Screen.width / 2 - 20),GUILayout.Height(Screen.height-60));

  13.         // 在这里请求列表解析

  14.         GUILayout.EndScrollView();
  15.         GUILayout.BeginHorizontal();

  16.         if (GUILayout.Button("统计数量")) {
  17.         }
  18.         if (GUILayout.Button("清空记录")) {
  19.         }
  20.         if (GUILayout.Button("录制")) {
  21.         }
  22.         if (GUILayout.Button("停止")) {
  23.         }

  24.         GUILayout.EndHorizontal();        
  25.         GUILayout.EndArea();
  26.     }
  27. }
复制代码
效果图:

参数列表解析
参数列表是一个object类型的数组,所以里边可以放各种基础类型,解析的时候,需要用到反射,动态修改里边的内容,解析函数如下:
  1. public void ParseBaseType(object field, FieldInfo fieldInfo = null, object dto = null, object aList = null, int index = 0) {
  2.            GUILayout.BeginHorizontal();
  3.            Type paramType = field.GetType();

  4.            if (paramType == typeof(string)) {
  5.                GUILayout.Label("String", GUILayout.Width(35));
  6.                field = GUILayout.TextField(field.ToString());

  7.            } else if (paramType == typeof(short)) {
  8.                GUILayout.Label("Short", GUILayout.Width(35));
  9.                field = Convert.ToInt16(GUILayout.TextField(field.ToString()));

  10.            } else if (paramType == typeof(int)) {
  11.                GUILayout.Label("Int", GUILayout.Width(35));
  12.                field = Convert.ToInt32(GUILayout.TextField(field.ToString()));

  13.            } else if (paramType == typeof(long)) {
  14.                GUILayout.Label("Long", GUILayout.Width(35));
  15.                field = Convert.ToInt64(GUILayout.TextField(field.ToString()));

  16.            } else if (paramType == typeof(bool)) {
  17.                GUILayout.Label("Bool", GUILayout.Width(35));
  18.                field = Convert.ToBoolean(GUILayout.TextField(field.ToString()));
  19.            } else {
  20.                GUILayout.Label("type can not parse,type is " + paramType.Name);
  21.                GUILayout.EndHorizontal();
  22.                return;
  23.            }
  24.            if (fieldInfo != null && dto != null) {
  25.                fieldInfo.SetValue(dto, field);
  26.            }
  27.            if (aList != null) {          // 数组需要用反射去修改

  28.                var removeAtMethod = aList.GetType().GetMethod("RemoveAt");
  29.                removeAtMethod.Invoke(aList, new object[] { index });

  30.                var insertMethod = aList.GetType().GetMethod("Insert");
  31.                insertMethod.Invoke(aList, new object[] { index, field });
  32.            }

  33.            GUILayout.EndHorizontal();

  34.        }
复制代码
保存和读取用例文件
直接使用了C#自带的序列号,不足之处,序列化后的文件,无法用文本编辑器直接阅读。
  1. public void SaveRequetsToFile(string fileName) {
  2.     Stream fStream = new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite);
  3.     BinaryFormatter binFormat = new BinaryFormatter();//创建二进制序列化器
  4.     binFormat.Serialize(fStream, InterfaceService.Ins.getAll());
  5.     fStream.Close();
  6.     Debug.LogWarning("成功保存" + fileName);
  7. }

  8. public List<TRequest> LoadRequetsFromFile(string fileName) {
  9.     //string fileName = @"C:\VSTest\InterfaceTest.dat";//文件名称与路径
  10.     Stream fStream = new FileStream(fileName, FileMode.Open, FileAccess.ReadWrite);
  11.     BinaryFormatter binFormat = new BinaryFormatter();//创建二进制序列化器                     
  12.     var result = (List<TRequest>) binFormat.Deserialize(fStream);
  13.     fStream.Close();
  14.     Debug.LogWarning("成功读取" + fileName);
  15.     return result;

  16. }
复制代码
最终成品效果图:

分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
收藏收藏
回复

使用道具 举报

  • TA的每日心情
    无聊
    2024-7-12 13:16
  • 签到天数: 1 天

    连续签到: 1 天

    [LV.1]测试小兵

    2#
    发表于 2017-6-26 14:39:16 | 只看该作者
    厉害了,之前做protobuf的时候,只会傻傻的一条条的添加
    回复 支持 反对

    使用道具 举报

    该用户从未签到

    3#
    发表于 2017-6-26 14:40:25 | 只看该作者
    很赞的文章,没做过游戏测试,但是思路可以学习下。还有socket.....
    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    无聊
    2024-9-19 09:07
  • 签到天数: 11 天

    连续签到: 2 天

    [LV.3]测试连长

    4#
    发表于 2017-6-26 14:41:15 | 只看该作者
    厉害啊,我们是笨笨的本地自己写协议,然后send调用。我们经常花很多时间来解析具体的道具唯一ID等等。
    楼主提供的方案非常容易上手。
    另外,我们在实际游戏测试中由于经常需要构建复杂的测试条件和环境,所以没有把自动化执行起来,我们通常是在黑盒测试时,同步进行协议发送和接受验证测试。
    楼主有什么好的建议吗?
    回复 支持 反对

    使用道具 举报

    该用户从未签到

    5#
     楼主| 发表于 2017-6-26 15:18:58 | 只看该作者
    八戒你干嘛 发表于 2017-6-26 14:41
    厉害啊,我们是笨笨的本地自己写协议,然后send调用。我们经常花很多时间来解析具体的道具唯一ID等等。
    楼 ...

    你提到的构建复杂的测试条件和环境,我的理解是两类:

    1.游戏玩家帐号的准备,如等级XX级,拥有XX武将这种。2.游戏内服务器环境的准备,如开启某个限时活动之类。
    如果是这两个的准备的话,对于第一种,游戏内可以有一个发资源道具的接口,可以直接获取指定的资源。也支持通过编程方式去调

    用,这样子可以把账号准备脚本保存起来,批量使用。第二种的话,1种方式是使用脚本修改活动配置表的开启时间,然后自动上传

    上去开启活动。也可以让后端提供类似GM指令的东东,一个命令就开启指定的活动。

    对于接口测试的自动化,我上边那个工具并不是一个好的方案,那个更适用于在功能测试阶段,方便测试人员快速验证有没有接口漏

    洞。
    回复 支持 反对

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-11-11 04:15 , Processed in 0.068820 second(s), 23 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表