接口和协议组成 聊一聊游戏的接口测试落地

煎饼 · December 14, 2016 · Last by HonJoe replied at December 13, 2019 · 4042 hits
本帖已被设为精华帖!

😂 潜水多年,首次发帖,请轻拍~

什么是游戏的接口测试

接口测试很官方的定义,上网一搜就有很多资料了。对于游戏而言,简单来说,接口就是服务端和客户端通讯请求的约定,客户端告诉服务端,我要做什么(操作协议号),怎么做(参数列表)。

举个栗子,升级技能,服务端和客户端有个约定:升级技能的操作协议号是 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 请求,指定格式如下:

http://192.168.22.248/sftx/gameSocket/send?u=playerId&c=protocol&p=port&params=param1|param2|param3

参数的含义如下:

u玩家ID
c操作协议号
p服务器端口号
params参数列表多个参数使用"|"连接

举个栗子:调用玩家升级主城协议

玩家ID7001
操作是升级主城协议号110001
参数列表主城建筑ID是1001
内网端口号默认5001

所以相应的 Get 请求是

http://192.168.22.248/sftx/gameSocket/send?u=7001&c=110001&p=5001&params=1001

Web 服务器接收到 Get 请求后,会解析出相应的玩家 ID,操作协议,参数列表,自动开启一个 Socket 连入游戏服务器,执行相应的操作,并返回处理结果。

基于这个接口,可以对操作请求进行批量的新建,修改,发送,并解析返回结果。也因此实现了一些自动化的脚本,如批量建号,批量升级等,对工作效率的提升也是很明显的,比如新建军团后,跑个批量申请入团,军团就满人了。

当时的不足之处:发送请求后,因为 Web 那边会自行创建新的 Socket 连接,会自动挤号。这一点如果可以进行优化的话,就更好用了。

项目 2

项目 2 是一个 RPG 页游,前后端使用 Socket 通讯,数据交互格式是 AMF3。

当时后端底层在重写 ing,所以没空折腾一个 Web 端口给我调用~这个时候前端主程 FF 站了出来,提出了一个方案。

游戏在网页上加载的时候,同时也加载一个测试用的 js 文件。

执行接口测试的方式,是在 Chrome 的 console 窗口,输入已经加载的 js 函数 sendCommand,把操作内容作为函数的参数,回车运行后发送给 Flash 客户端,Flash 客户端接收后,解析出相应的操作 ID 和参数列表,执行后在 console 窗口打印出服务端返回结果。

函数调用格式如下:

sendCommand(PackagesController, move, 0, [BACKPACK,0,BACKPACK,30]);
操作模块PackagesController  背包模块
操作行为move   移动背包物品
参数列表[BACKPACK,0,BACKPACK,30]   从第0个格子移动到第30个

这个模式的一个好处:接口测试的请求是前端解析后,自行发出的,所以不会被挤号。

项目 3

项目 3 是一个回合制的 RPG 手游,客户端使用 Unity,Socket 通讯,数据交互格式是 PB,这次开始自己尝试独立完成接口工具,工具需求规划如下:

  • 支持录制请求
  • 可以对录制的请求进行复制,修改,删除
  • 解析请求里边的参数列表
  • 查看服务端的返回结果
  • 自动校验返回结果
  • 测试用例保存到本地

实现过程中的一些积累在此记录下:

录制的原理

点击一个技能升级的按钮的背后发生了什么?

  1. UI 接收到点击请求,调用技能模块
  2. 技能模块准备好参数列表,调用 Server 层的 Send 方法,生成一个请求
  3. Server 层接收后,对请求进行封装,加入校验 key 和请求头,压缩为 PB 格式,生成最终请求
  4. 发送给服务端

那么,要从哪里切入来录制请求呢?最终选择了在 Server 层接收后,对请求进行封装前,主要原因是,接口测试主要关注参数的不合理修改后,服务端能否做出正确判断,可以不用关心校验 Key 等其他信息,对请求进行修改后,点击发送,直接调用 Send 方法,底层就会完成新的请求封装和发送。省代码啊~

那么问题来了,如何录制?
一开始采用的方式,是直接在 Send 函数里边,嵌入了转存请求的代码,但是这个做法并不合理,因为已经直接修改了开发的代码,下次从 git 更新代码,会有冲突,后来调整为前端底层提供一个 onRequest 的事件,我在需要转存请求的时候,就注册自己的事件处理函数。

public override void StartRecord() {
    MsgCenter.AddMsg("Start to record Request");
    EventHelper.Ins.Get<SystemEventGroup>().onRequest.AddHandler(OnRequest);
}

void OnRequest(ServerService ss, Request req) {
    IList<object> paramList = null;
    if (req.ParamList.IsNotBlank()) {
        paramList = req.ParamList;
    }
    this.Add(new TRequest(req.Protocol, paramList));
}

转存请求的 TRequest 的定义

[Serializable]
public class TRequest : ICloneable {

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

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

    public object Clone() {
        MemoryStream stream = new MemoryStream();
        BinaryFormatter formatter = new BinaryFormatter();
        formatter.Serialize(stream, this);
        stream.Position = 0;
        return formatter.Deserialize(stream);
    }
}

同样的,录制服务端的返回结果,也是类似的方式。

测试工具 UI 的编写

UI 界面选择了 Unity 古老的 OnGUI 方法,原因就是:易学,够用。下边是一个简单的 GUI 界面。

using UnityEngine;

public class TestUI : MonoBehaviour {

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

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

    void WindowFunction(int windowID) {

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

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

        // 在这里请求列表解析

        GUILayout.EndScrollView();
        GUILayout.BeginHorizontal();

        if (GUILayout.Button("统计数量")) {
        }
        if (GUILayout.Button("清空记录")) {
        }
        if (GUILayout.Button("录制")) {
        }
        if (GUILayout.Button("停止")) {
        }

        GUILayout.EndHorizontal();        
        GUILayout.EndArea(); 
    }
}

效果图:

参数列表解析

参数列表是一个 object 类型的数组,所以里边可以放各种基础类型,解析的时候,需要用到反射,动态修改里边的内容,解析函数如下:

public void ParseBaseType(object field, FieldInfo fieldInfo = null, object dto = null, object aList = null, int index = 0) {
           GUILayout.BeginHorizontal();
           Type paramType = field.GetType();

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

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

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

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

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

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

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

           GUILayout.EndHorizontal();

       }

保存和读取用例文件

直接使用了 C# 自带的序列号,不足之处,序列化后的文件,无法用文本编辑器直接阅读。

public void SaveRequetsToFile(string fileName) {
    Stream fStream = new FileStream(fileName, FileMode.Create, FileAccess.ReadWrite);
    BinaryFormatter binFormat = new BinaryFormatter();//创建二进制序列化器
    binFormat.Serialize(fStream, InterfaceService.Ins.getAll());
    fStream.Close();
    Debug.LogWarning("成功保存" + fileName);
}

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

}

最终成品效果图:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 19 条回复 时间 点赞

煎饼,很赞的文章

思寒_seveniruby 将本帖设为了精华贴 14 Dec 22:32

加精理由:接口测试理解透彻 执行力和思路都很优秀

很赞的文章,没做过游戏测试,但是思路可以学习下。还有 socket.....

写得挺好的。

看完收藏

写得很好,实践性比较强

#5 楼 @seveniruby 😄谢谢思寒大大

—— 来自 TesterHome 官方 安卓客户端

#10 楼 @jianjianjianbing 😂 老是老了一点, 以后能不能喊哥啊.

厉害了,之前做 protobuf 的时候,只会傻傻的一条条的添加

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

煎饼 #12 · December 16, 2016 Author

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

1.游戏玩家帐号的准备,如等级 XX 级,拥有 XX 武将这种。2.游戏内服务器环境的准备,如开启某个限时活动之类。
如果是这两个的准备的话,对于第一种,游戏内可以有一个发资源道具的接口,可以直接获取指定的资源。也支持通过编程方式去调用,这样子可以把账号准备脚本保存起来,批量使用。第二种的话,1 种方式是使用脚本修改活动配置表的开启时间,然后自动上传上去开启活动。也可以让后端提供类似 GM 指令的东东,一个命令就开启指定的活动。

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

已经打赏 10 元

煎饼 #14 · December 22, 2016 Author

#15 楼 @seveniruby 😂谢谢思寒哥

—— 来自 TesterHome 官方 安卓客户端

#16 楼 @jianjianjianbing 别谢我 是社区的打赏制度

匿名 #16 · January 13, 2017

学习了~

楼主,请问一下,
我是做 rpg 页游的, 也是基于 socket 通讯的 ,数据交互是 xml, 请问我要怎么开展接口测试呢?
是模仿客户端做一个长连接去访问服务器?然后调用里面的协议,
还是直接拿着后端的代码,去对协议做单元测试?

煎饼 手游可以做接口测试吗 中提及了此贴 07 Nov 10:20

心跳包怎么解决的

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up