Python 解析 Swagger 生成用例 doc2case

一劈彪、背景

由于目前團隊使用的HTTP接口測試框架是借鑒 HTTPrunner2 的基礎(chǔ)上進行二次開發(fā)的齿诉,組員每次編寫api都是fiddler抓包然后使用har2case去解析生成api用例筝野,然后再編寫修改≡辆纾基本都做著一些重復(fù)的工作遗座,而開發(fā)團隊之前的接口文檔管理方式比較雜亂(不同的項目組存在不同的接口管理方式),剛好近期前端的同學(xué)開始整合接口文檔平臺俊扳,決定統(tǒng)一使用swagger來進行管理途蒋。那么借著這個契機,就可以嘗試解析swagger自動生成用例馋记,來降低重復(fù)編寫api的工作号坡,只需要關(guān)注testcase場景的串聯(lián)即可。為后續(xù)需求實例化默默的打下基礎(chǔ)梯醒。


二宽堆、swagger 分析

記得有這個想法的時候,就想著直接F12抓包看下接口看下每個api數(shù)據(jù)的結(jié)構(gòu)來進行分析茸习,然后直接 懵逼 畜隶,返回的是個html。沒辦法号胚,百度了一遍籽慢,剛好發(fā)現(xiàn)有人跟我一樣的情況,正好解決了這個問題猫胁,原來swagger文檔的數(shù)據(jù)是在 api-docs 這個接口返回的json中箱亿,既然數(shù)據(jù)有了,那么接下來 好戲 開場弃秆。

1.swagger 基本結(jié)構(gòu) 參考官網(wǎng)

{
        "swagger": "2.0",
        "info": "文檔信息相關(guān)",
        "host": "127.0.0.1",
        "basePath": "/useradmin/user",
        "tags": [],
        "paths": {},
        "definitions": {},
        "securityDefinitions": {}
    
}

首先分析一下swagger的基本結(jié)構(gòu)届惋,其中本次解析的重點對象就是paths和definitions,具體可以去參考swagger官網(wǎng)的描述菠赚。下面對paths和definitions的節(jié)本結(jié)構(gòu)進行逐個分析脑豹。

# paths 節(jié)點
    
    {
        "paths":{
            "/userAadmin/user/userId":{  # 接口路徑
                "get":{  # 請求方法
                    "tags":[
                        "用戶管理"  # 模塊名稱
                    ],
                    "summary":"根據(jù)用戶ID獲取信息",  # 接口描述
                    "operationId":"userIdGET",
                    "consumes":[
                        "application/json"  # 請求參數(shù)類型
                    ],
                    "produces":[
                        "*/*"
                    ],
                    "parameters":[  # 請求參數(shù),注意是 list
                        {
                            "name":"userId",  # 字段名稱
                            "in":"query",  # 參數(shù)位置 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"  # 請求參數(shù)類型
                    ],
                    "produces":[
                        "*/*"
                    ],
                    "parameters":[  # 請求參數(shù)衡查,注意是 list
                        {
                            "in":"query",  # 參數(shù)位置 query in params || body in data||json 
                            "name":"userId",  # 字段名稱
                            "description":"商品ID",
                            "required":true,  # 是否必填
                            "type":"integer",
                            "format":"int64"
                        }瘩欺,
                        {
                            "in":"body",  # 參數(shù)位置 query in params || body in data||json 
                            "name":"user",  # 對象名稱,首字母小寫峡捡,需要解析的時候轉(zhuǎn)一下
                            "description":"params description",
                            "required":true,  # 是否必填
                            "schema":{
                                "$ref":"#/definitions/User"  # 入?yún)ο蠡魍耄枰?definitions 節(jié)點下獲取對應(yīng)的對象字段
                            }
                        }
                    ],
                    "responses":{
                        "200":{
                            "description":"OK",
                            "schema":{
                                "$ref":"#/definitions/UserVo"
                            }
                        }
                    }
                }
            }
        }
    }

從上面的基本結(jié)構(gòu)來看筑悴,paths節(jié)點下面是多個接口信息们拙,示例接口下面描述的是請求方式分別有g(shù)et稍途、post兩種請求接口,在解析的時候需要重點關(guān)注有注釋的節(jié)點砚婆,注意這里的注釋說明 很重要P蹬摹!装盯!
這些節(jié)點是獲取數(shù)據(jù)然后轉(zhuǎn)化接口用例的必需字段坷虑,都是一一對應(yīng)的。
注意:由于開發(fā)維護的接口文檔如果不規(guī)范埂奈,或者亂定義迄损,就會存在解析這些字段的時候存在缺失和錯誤的情況。如果想避免這些情況账磺,就必須事先同相關(guān)開發(fā)統(tǒng)一規(guī)范芹敌。

# definitions 節(jié)點
        {
            "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":"深圳市/南山區(qū)/西麗",
                            "description":"地址信息"
                        }
                    }
                }
            }
        }

definitions 節(jié)點獲取的數(shù)據(jù)主要是從paths那邊拿到入?yún)ο髞砥ヅ洌缓蟊闅v獲取 properties 下面的所有keys垮抗,當(dāng)做接口用例的請求參數(shù)氏捞。一般作用于post請求方式的接口。


三冒版、代碼實現(xiàn)

1.在了解了swagger的基本結(jié)構(gòu)之后液茎,那么在代碼實現(xiàn)之前先進行邏輯拆分:
  • 先請求接口文檔獲取 entry json
  • 從 entry_json 分別獲取 paths / definitions 數(shù)據(jù)
  • 從 paths中獲取path對應(yīng)接口模板中的 url
  • 從 paths 中獲取 get/post 對應(yīng)模板中request.method
  • 從 paths 路徑下獲取請求數(shù)據(jù)類型 consumes
  • 從 paths 路徑下獲取請求頭參數(shù) params,根據(jù) in=query 來判斷
  • 從 paths 路徑下獲取請求 body 參數(shù)辞嗡,根據(jù) in=body 來判斷
  • 獲取響應(yīng)數(shù)據(jù)定義斷言
  • 根據(jù)用例模板聚合生成用例文件
# 用例模板
{
    "config": {
        "base_url": "${ENV(BASE_URL)}",
        "name": "testCase description",
        "variables": {}
    },
    "testSteps": testStep_dict
}

testStep_dict = {
                    "name": "",
                    "request": {},
                    "validate": []
                }
2.代碼實現(xiàn): 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 的代碼封裝是根據(jù)上面拆分的邏輯步驟一步步進行組裝模板數(shù)據(jù)捆等,最后生成的用例文件。默認是在當(dāng)前根目錄下創(chuàng)建api/模塊名/用例名续室。
注意點:以上關(guān)鍵節(jié)點的字段需要根據(jù)實際swagger節(jié)點的字段進行分析楚里,再進行代碼解析。因為不確定開發(fā)是如何去維護對應(yīng)字段猎贴。重點就是要統(tǒng)一字段的命名和格式班缎。

3.案例演示

下面我是使用 setup.py 封裝到命令行執(zhí)行(如果想學(xué)習(xí)如何封裝命令行工具或者打包插件發(fā)布分享,可以關(guān)注一下我其他的文章)
Python 實現(xiàn)命令行工具
無處安放的Py插件她渴,如何正確使用setup.py打包發(fā)布

從圖1 help 中可以看出达址,目前是支持json和yaml兩種格式的用例生成,默認是 json趁耗。只需要傳入 api-doc 的url沉唠,這個是必傳的。也可以根據(jù)指定的path進行單個用例的解析

doc2case url -f yml

從清晰的log中看到此時用例均已生成苛败,這里使用的是loguru 日志满葛,想學(xué)習(xí)了解的可以看下我上面一篇 Python 如何更優(yōu)雅的記錄日志(loguru) 有專門的介紹使用径簿。


圖中左邊的api目錄就是本次生成的用例了,下面是核心代碼的結(jié)構(gòu)嘀韧,右邊的是yaml格式的用例篇亭。是參考HTTPrunner2格式生成的,與har2case 解析的結(jié)果一致锄贷,當(dāng)然本質(zhì)代碼就是參考它的译蒂,只不過是解析的對象不一樣,稍做了修改谊却。


最后柔昼,如果對doc2case工具感興趣想要獲取源碼的,可以 點贊+關(guān)注+回復(fù)炎辨!
代碼已上傳到 pypi 倉庫中可以使用pip直接下載:>> pip install doc2case

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末捕透,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子碴萧,更是在濱河造成了極大的恐慌乙嘀,老刑警劉巖,帶你破解...
    沈念sama閱讀 217,734評論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件勿决,死亡現(xiàn)場離奇詭異乒躺,居然都是意外死亡,警方通過查閱死者的電腦和手機低缩,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,931評論 3 394
  • 文/潘曉璐 我一進店門嘉冒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人咆繁,你說我怎么就攤上這事讳推。” “怎么了玩般?”我有些...
    開封第一講書人閱讀 164,133評論 0 354
  • 文/不壞的土叔 我叫張陵银觅,是天一觀的道長。 經(jīng)常有香客問我坏为,道長究驴,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,532評論 1 293
  • 正文 為了忘掉前任匀伏,我火速辦了婚禮洒忧,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘够颠。我一直安慰自己熙侍,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,585評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著蛉抓,像睡著了一般庆尘。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上巷送,一...
    開封第一講書人閱讀 51,462評論 1 302
  • 那天驶忌,我揣著相機與錄音,去河邊找鬼惩系。 笑死位岔,一個胖子當(dāng)著我的面吹牛如筛,可吹牛的內(nèi)容都是我干的堡牡。 我是一名探鬼主播,決...
    沈念sama閱讀 40,262評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼杨刨,長吁一口氣:“原來是場噩夢啊……” “哼晤柄!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起妖胀,我...
    開封第一講書人閱讀 39,153評論 0 276
  • 序言:老撾萬榮一對情侶失蹤芥颈,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后赚抡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體爬坑,經(jīng)...
    沈念sama閱讀 45,587評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,792評論 3 336
  • 正文 我和宋清朗相戀三年涂臣,在試婚紗的時候發(fā)現(xiàn)自己被綠了盾计。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,919評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡赁遗,死狀恐怖署辉,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情岩四,我是刑警寧澤哭尝,帶...
    沈念sama閱讀 35,635評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站剖煌,受9級特大地震影響材鹦,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜耕姊,卻給世界環(huán)境...
    茶點故事閱讀 41,237評論 3 329
  • 文/蒙蒙 一桶唐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧箩做,春花似錦莽红、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,855評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽醉蚁。三九已至,卻和暖如春鬼店,著一層夾襖步出監(jiān)牢的瞬間网棍,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,983評論 1 269
  • 我被黑心中介騙來泰國打工妇智, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留滥玷,地道東北人。 一個月前我還...
    沈念sama閱讀 48,048評論 3 370
  • 正文 我出身青樓巍棱,卻偏偏與公主長得像惑畴,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子航徙,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,864評論 2 354

推薦閱讀更多精彩內(nèi)容