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
共有 0 条评论