Python 解析 Swagger 生成用例 doc2case

一、背景

由于目前团队使用的HTTP接口测试框架是借鉴 HTTPrunner2 的基础上进行二次开发的,组员每次编写api都是fiddler抓包然后使用har2case去解析生成api用例,然后再编写修改。基本都做着一些重复的工作,而开发团队之前的接口文档管理方式比较杂乱(不同的项目组存在不同的接口管理方式),刚好近期前端的同学开始整合接口文档平台,决定统一使用swagger来进行管理。那么借着这个契机,就可以尝试解析swagger自动生成用例,来降低重复编写api的工作,只需要关注testcase场景的串联即可。为后续需求实例化默默的打下基础。


二、swagger 分析

记得有这个想法的时候,就想着直接F12抓包看下接口看下每个api数据的结构来进行分析,然后直接 懵逼 ,返回的是个html。没办法,百度了一遍,刚好发现有人跟我一样的情况,正好解决了这个问题,原来swagger文档的数据是在 api-docs 这个接口返回的json中,既然数据有了,那么接下来 好戏 开场。

1.swagger 基本结构 参考官网

{
        "swagger": "2.0",
        "info": "文档信息相关",
        "host": "127.0.0.1",
        "basePath": "/useradmin/user",
        "tags": [],
        "paths": {},
        "definitions": {},
        "securityDefinitions": {}
    
}

首先分析一下swagger的基本结构,其中本次解析的重点对象就是paths和definitions,具体可以去参考swagger官网的描述。下面对paths和definitions的节本结构进行逐个分析。

# paths 节点
    
    {
        "paths":{
            "/userAadmin/user/userId":{  # 接口路径
                "get":{  # 请求方法
                    "tags":[
                        "用户管理"  # 模块名称
                    ],
                    "summary":"根据用户ID获取信息",  # 接口描述
                    "operationId":"userIdGET",
                    "consumes":[
                        "application/json"  # 请求参数类型
                    ],
                    "produces":[
                        "*/*"
                    ],
                    "parameters":[  # 请求参数,注意是 list
                        {
                            "name":"userId",  # 字段名称
                            "in":"query",  # 参数位置 query in params || body in data||json 
                            "description":"商品ID",
                            "required":true,  # 是否必填
                            "type":"integer",
                            "format":"int64"
                        }
                    ],
                    "responses":{
                        "200":{
                            "description":"OK",
                            "schema":{
                                "$ref":"#/definitions/UserVo"
                            }
                        }
                    }
                }
            },
            "/userAadmin/user/userUpdate":{  # 接口路径
                "post":{  # 请求方法
                    "tags":[
                        "用户管理"  # 模块名称
                    ],
                    "summary":"更新用户信息",  # 接口描述
                    "operationId":"userUpdatePOST",
                    "consumes":[
                        "application/json"  # 请求参数类型
                    ],
                    "produces":[
                        "*/*"
                    ],
                    "parameters":[  # 请求参数,注意是 list
                        {
                            "in":"query",  # 参数位置 query in params || body in data||json 
                            "name":"userId",  # 字段名称
                            "description":"商品ID",
                            "required":true,  # 是否必填
                            "type":"integer",
                            "format":"int64"
                        },
                        {
                            "in":"body",  # 参数位置 query in params || body in data||json 
                            "name":"user",  # 对象名称,首字母小写,需要解析的时候转一下
                            "description":"params description",
                            "required":true,  # 是否必填
                            "schema":{
                                "$ref":"#/definitions/User"  # 入参对象,需要去 definitions 节点下获取对应的对象字段
                            }
                        }
                    ],
                    "responses":{
                        "200":{
                            "description":"OK",
                            "schema":{
                                "$ref":"#/definitions/UserVo"
                            }
                        }
                    }
                }
            }
        }
    }

从上面的基本结构来看,paths节点下面是多个接口信息,示例接口下面描述的是请求方式分别有get、post两种请求接口,在解析的时候需要重点关注有注释的节点,注意这里的注释说明 很重要!!!
这些节点是获取数据然后转化接口用例的必需字段,都是一一对应的。
注意:由于开发维护的接口文档如果不规范,或者乱定义,就会存在解析这些字段的时候存在缺失和错误的情况。如果想避免这些情况,就必须事先同相关开发统一规范。

# definitions 节点
        {
            "definitions":{
                "UserVo":{  # 对象
                    "type":"object",
                    "properties":{  # 属性字段集合
                        "userId":{ # 字段
                            "type":"string",
                            "example":"10086",
                            "description":"用户ID"
                        },
                        "userName":{  # 字段
                            "type":"integer",
                            "format":"int64",
                            "example":1,
                            "description":"用户名"
                        },
                        "age":{  # 字段
                            "type":"integer",
                            "format":"int64",
                            "example":18,
                            "description":"年龄"
                        },
                        "address":{  # 字段
                            "type":"string",
                            "example":"深圳市/南山区/西丽",
                            "description":"地址信息"
                        }
                    }
                }
            }
        }

definitions 节点获取的数据主要是从paths那边拿到入参对象来匹配,然后遍历获取 properties 下面的所有keys,当做接口用例的请求参数。一般作用于post请求方式的接口。


三、代码实现

1.在了解了swagger的基本结构之后,那么在代码实现之前先进行逻辑拆分:
  • 先请求接口文档获取 entry json
  • 从 entry_json 分别获取 paths / definitions 数据
  • 从 paths中获取path对应接口模板中的 url
  • 从 paths 中获取 get/post 对应模板中request.method
  • 从 paths 路径下获取请求数据类型 consumes
  • 从 paths 路径下获取请求头参数 params,根据 in=query 来判断
  • 从 paths 路径下获取请求 body 参数,根据 in=body 来判断
  • 获取响应数据定义断言
  • 根据用例模板聚合生成用例文件
# 用例模板
{
    "config": {
        "base_url": "${ENV(BASE_URL)}",
        "name": "testCase description",
        "variables": {}
    },
    "testSteps": testStep_dict
}

testStep_dict = {
                    "name": "",
                    "request": {},
                    "validate": []
                }
2.代码实现: doc2case
from loguru import logger
import requests

from kernel.utils import get_target_value, get_file_path, dump_yaml, dump_json
from kernel import USER_AGENT


class SwaggerParser(object):
    s = requests.session()

    def __init__(self, url):
        self.api_doc_json = self.get_entry_json(url)

    def get_entry_json(self, url):
        response = self.s.get(url).json()
        if response is not None:
            return response

    @staticmethod
    def _make_request_url(testStep_dict, path):
        testStep_dict["name"] = path
        testStep_dict["request"]["url"] = path

    @staticmethod
    def _make_request_method(testStep_dict, entry_json):
        """ parse HAR entry request method, and make testStep method.
        """
        testStep_dict["request"]["method"] = [x for x in entry_json.keys()][0].upper()

    @staticmethod
    def _make_request_headers(testStep_dict, entry_json):
        testStep_headers = {}
        for method, params in entry_json.items():
            testStep_headers["Content-Type"] = params["consumes"][0]
            
        if testStep_headers:
            testStep_headers["User-Agent"] = USER_AGENT
            testStep_dict["request"]["headers"] = testStep_headers

    @staticmethod
    def _make_request_params(testStep_dict, entry_json):
        for method, params in entry_json.items():
            query_dict = {}
            for param in params.get("parameters"):
                if param.get("in") == "query":
                    queryString = param.get("name")
                    if queryString:
                        query_dict[f"{queryString}"] = f"${queryString}"
                        testStep_dict["request"]["params"] = query_dict

    def _make_request_data(self, testStep_dict, entry_json):
        for method, params in entry_json.items():
            request_data_key = "json" if params.get("consumes")[0].startswith("application/json") else "data"
            if method.upper() in ["POST", "PUT", "PATCH"]:
                for param in params.get("parameters"):
                    if param.get("in") == "body":
                        schema_obj = param.get("name")
                        for obj, properties in self.api_doc_json.get("definitions").items():
                            data_dict = {}
                            if obj in schema_obj:
                                for k, v in properties.get("properties").items():
                                    data_dict[k] = f"${k}"
                            testStep_dict["request"][request_data_key] = data_dict

    @staticmethod
    def _make_validate(testStep_dict):
        testStep_dict["validate"].append({"eq": ["status_code", 200]})

    def _prepare_testStep(self, path, entry_json):
        testStep_dict = {
            "name": "",
            "request": {},
            "validate": []
        }
        self._make_request_url(testStep_dict, path)
        self._make_request_method(testStep_dict, entry_json)
        self._make_request_headers(testStep_dict, entry_json)
        self._make_request_params(testStep_dict, entry_json)
        self._make_request_data(testStep_dict, entry_json)
        self._make_validate(testStep_dict)
        return testStep_dict

    @staticmethod
    def _prepare_config():
        return {
            "base_url": "${ENV(BASE_URL)}",
            "name": "testCase description",
            "variables": {}
        }

    def _prepare_testSteps(self, path, entry_json):
        """ make testStep list.
            testSteps list are parsed from HAR log entries list.

        """
        return [self._prepare_testStep(path, entry_json)]

    def _make_testCase(self, path, entry_json):
        """ Extract info from HAR file and prepare for testCase
        """
        logger.debug("Extract info from HAR file and prepare for testCase.")

        config = self._prepare_config()
        testSteps = self._prepare_testSteps(path, entry_json)
        return {
            "config": config,
            "testSteps": testSteps
        }

    def gen_testCase(self, path=None, file_type="yml"):
        """
        Generate test cases based on the specified path
        """
        if path is not None:
            for test_mapping in get_target_value(path, self.api_doc_json.get("paths")):
                logger.info(f"Start to generate testCase.: {path}")
                testCase = self._make_testCase(path, test_mapping)

                file = get_file_path(path, test_mapping) + "." + file_type
                dump_yaml(testCase, file) if file_type == "yml" else dump_json(testCase, file)
                logger.debug("prepared testCase: {}".format(testCase))

        else:
            for path, test_mapping in self.api_doc_json.get("paths").items():
                logger.info(f"Start to generate testCase.: {path}")
                testCase = self._make_testCase(path, test_mapping)

                file = get_file_path(path, test_mapping) + "." + file_type
                logger.debug("spanned file : {}".format(file))
                dump_yaml(testCase, file) if file_type == "yml" else dump_json(testCase, file)
                logger.debug("prepared testCase: {}".format(testCase))

以上 doc2case 的代码封装是根据上面拆分的逻辑步骤一步步进行组装模板数据,最后生成的用例文件。默认是在当前根目录下创建api/模块名/用例名。
注意点:以上关键节点的字段需要根据实际swagger节点的字段进行分析,再进行代码解析。因为不确定开发是如何去维护对应字段。重点就是要统一字段的命名和格式。

3.案例演示

下面我是使用 setup.py 封装到命令行执行(如果想学习如何封装命令行工具或者打包插件发布分享,可以关注一下我其他的文章)
Python 实现命令行工具
无处安放的Py插件,如何正确使用setup.py打包发布

从图1 help 中可以看出,目前是支持json和yaml两种格式的用例生成,默认是 json。只需要传入 api-doc 的url,这个是必传的。也可以根据指定的path进行单个用例的解析

doc2case url -f yml

从清晰的log中看到此时用例均已生成,这里使用的是loguru 日志,想学习了解的可以看下我上面一篇 Python 如何更优雅的记录日志(loguru) 有专门的介绍使用。

图中左边的api目录就是本次生成的用例了,下面是核心代码的结构,右边的是yaml格式的用例。是参考HTTPrunner2格式生成的,与har2case 解析的结果一致,当然本质代码就是参考它的,只不过是解析的对象不一样,稍做了修改。


最后,如果对doc2case工具感兴趣想要获取源码的,可以 点赞+关注+回复
代码已上传到 pypi 仓库中可以使用pip直接下载:>> pip install doc2case

版权声明:
作者:ht
链接:https://www.techfm.club/p/44945.html
来源:TechFM
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>