1、思路:
yamlpy即为yaml文件+pytest单元测试框架的缩写,
可以看作是一个脚手架工具,
可以快速生成项目的各个目录与文件,
只需维护一份或者多份yaml文件即可,
不需要大量写代码。
与yamlapi接口测试框架对比,
整体结构仍然保持不变,
yaml文件格式仍然保持不变,
可以通用,
抛弃了python自带的unittest单元测试框架、ddt数据驱动第三方库、BeautifulReport测试报告第三方库,
修改了测试类文件,
传参方式由ddt的@ddt.ddt、@ddt.file_data()改为pytest的@pytest.mark.parametrize(),
删掉了tool工具包里面的beautiful_report_run.py文件,
其他文件保持不变,
新增了pytest-assume多重断言插件。
(yamlapi接口测试框架也支持双重断言)
2、安装:
pip install yamlpy
请访问
https://pypi.org/project/yamlpy/
3、文件举例:
README.md文件:
1 # yamlpy 2 接口测试框架 3 4 5 # 一、思路 6 1、采用requests+PyMySQL+demjson+loguru+PyYAML+ruamel.yaml+pytest+pytest-html+allure-pytest+pytest-assume+pytest-rerunfailures+pytest-sugar+pytest-timeout 7 2、requests是发起HTTP请求的第三方库 8 3、PyMySQL是连接MySQL的第三方库 9 4、demjson是解析json的第三方库 10 5、loguru是记录日志的第三方库 11 6、PyYAML与ruamel.yaml是读写yaml文件的第三方库 12 7、pytest是单元测试的第三方库 13 8、pytest-html是生成html测试报告的插件 14 9、allure-pytest是生成allure测试报告的插件 15 10、pytest-assume是多重断言的插件 16 11、pytest-rerunfailures是失败重跑的插件 17 12、pytest-sugar是显示进度的插件 18 13、pytest-timeout是设置超时时间的插件 19 20 21 # 二、目录结构 22 1、case是测试用例包 23 2、log是日志目录 24 3、report是测试报告的目录 25 4、resource是yaml文件的目录 26 5、setting是工程的配置文件包 27 6、tool是常用方法的封装包 28 29 30 # 三、yaml文件说明 31 1、字段(命名和格式不可修改,顺序可以修改) 32 case_name: 用例名称 33 mysql: MySQL语句,-列表格式,顺序不可修改 34 第一行:mysql[0] 35 第二行:mysql[1] 36 第三行:mysql[2] 37 第一行为增删改语句,第二行为查语句,第三行为查语句(数据库双重断言) 38 第一行是发起请求之前的动作,没有返回结果 39 第二行是发起请求之前的动作,有返回结果,是为了动态传参 40 第三行是发起请求之后的动作,有返回结果,但是不可用于动态传参,是为了断言实际的响应结果 41 当不需要增删改查和双重断言时,三行都为空 42 当只需要增删改时,第一行为增删改语句,第二行为空,第三行为空 43 当只需要查时,第一行为空,第二行为查语句,第三行为空 44 当只需要双重断言时,第一行为空,第二行为空,第三行为查语句 45 request_mode: 请求方式 46 api: 接口路径 47 data: 请求体,缩进字典格式或者json格式 48 headers: 请求头,缩进字典格式或者json格式 49 query_string: 请求参数,缩进字典格式或者json格式 50 expected_code: 预期的响应代码 51 expected_result: 预期的响应结果,-列表格式、缩进字典格式或者json格式 52 regular: 正则,缩进字典格式 53 >>variable:变量名,-列表格式 54 >>expression:表达式,-列表格式 55 56 2、参数化 57 正则表达式提取的结果用${变量名}匹配,一条用例里面可以有多个 58 MySQL查询语句返回的结果,即第二行mysql[1]返回的结果,用{__SQL索引}匹配 59 即{__SQL0}、{__SQL1}、{__SQL2}、{__SQL3}。。。。。。一条用例里面可以有多个 60 随机数字用{__RN位数},一条用例里面可以有多个 61 随机英文字母用{__RL位数},一条用例里面可以有多个 62 以上4种类型在一条用例里面可以混合使用 63 ${变量名}的作用域是全局的,其它3种的作用域仅限该条用例 64 65 66 # 四、运行 67 在工程的根目录下执行命令 68 pytest+--cmd=环境缩写 69 pytest --cmd=dev 70 pytest --cmd=test 71 pytest --cmd=pre 72 pytest --cmd=formal
demo_test.py文件:
""" 测试用例 """ import json import re from itertools import chain from time import sleep import allure import demjson import pytest import requests from pytest_assume.plugin import assume from setting.project_config import * from tool.connect_mysql import ConnectMySQL from tool.read_write_yaml import merge_yaml from tool.function_assistant import function_dollar, function_rn, function_rl, function_sql @allure.feature(test_scenario) class DemoTest(object): temporary_list = merge_yaml() # 调用合并所有yaml文件的方法 @classmethod def setup_class(cls): cls.variable_result_dict = {} # 定义一个变量名与提取的结果字典 # cls.variable_result_dict与self.variable_result_dict都是本类的公共属性 @allure.story(test_story) @allure.severity(test_case_priority[0]) @allure.testcase(test_case_address, test_case_address_title) @pytest.mark.parametrize("temporary_dict", temporary_list) # 传入临时列表 def test_demo(self, temporary_dict): """ 测试用例 :param temporary_dict: :return: """ global mysql_result_list_after temporary_dict = str(temporary_dict) if "None" in temporary_dict: temporary_dict = temporary_dict.replace("None", "''") temporary_dict = demjson.decode(temporary_dict) # 把值为None的替换成''空字符串,因为None无法拼接 # demjson.decode()等价于json.loads()反序列化 case_name = temporary_dict.get("case_name") # 用例名称 self.test_order.__func__.__doc__ = case_name # 测试报告里面的用例描述 mysql = temporary_dict.get("mysql") # mysql语句 request_mode = temporary_dict.get("request_mode") # 请求方式 api = temporary_dict.get("api") # 接口路径 if type(api) != str: api = str(api) payload = temporary_dict.get("data") # 请求体 if type(payload) != str: payload = str(payload) headers = temporary_dict.get("headers") # 请求头 if type(headers) != str: headers = str(headers) query_string = temporary_dict.get("query_string") # 请求参数 if type(query_string) != str: query_string = str(query_string) expected_code = temporary_dict.get("expected_code") # 预期的响应代码 expected_result = temporary_dict.get("expected_result") # 预期的响应结果 if type(expected_result) != str: expected_result = str(expected_result) regular = temporary_dict.get("regular") # 正则 logger.info("{}>>>开始执行", case_name) if environment == "formal" and mysql: pytest.skip("生产环境跳过此用例,请忽略") # 生产环境不能连接MySQL数据库,因此跳过 if self.variable_result_dict: # 如果变量名与提取的结果字典不为空 if mysql: if mysql[0]: mysql[0] = function_dollar(mysql[0], self.variable_result_dict.items()) # 调用替换$的方法 if mysql[1]: mysql[1] = function_dollar(mysql[1], self.variable_result_dict.items()) if mysql[2]: mysql[2] = function_dollar(mysql[2], self.variable_result_dict.items()) if api: api = function_dollar(api, self.variable_result_dict.items()) if payload: payload = function_dollar(payload, self.variable_result_dict.items()) if headers: headers = function_dollar(headers, self.variable_result_dict.items()) if query_string: query_string = function_dollar(query_string, self.variable_result_dict.items()) if expected_result: expected_result = function_dollar(expected_result, self.variable_result_dict.items()) else: pass if mysql: db = ConnectMySQL() # 实例化一个MySQL操作对象 if mysql[0]: mysql[0] = function_rn(mysql[0]) # 调用替换RN随机数字的方法 mysql[0] = function_rl(mysql[0]) # 调用替换RL随机字母的方法 if "INSERT" in mysql[0]: db.insert_mysql(mysql[0]) # 调用插入mysql的方法 sleep(2) # 等待2秒钟 if "UPDATE" in mysql[0]: db.update_mysql(mysql[0]) # 调用更新mysql的方法 sleep(2) if "DELETE" in mysql[0]: db.delete_mysql(mysql[0]) # 调用删除mysql的方法 sleep(2) if mysql[1]: mysql[1] = function_rn(mysql[1]) # 调用替换RN随机数字的方法 mysql[1] = function_rl(mysql[1]) # 调用替换RL随机字母的方法 if "SELECT" in mysql[1]: mysql_result_tuple = db.query_mysql(mysql[1]) # mysql查询结果元祖 mysql_result_list = list(chain.from_iterable(mysql_result_tuple)) # 把二维元祖转换为一维列表 logger.info("发起请求之前mysql查询的结果列表为:{}", mysql_result_list) if api: api = function_sql(api, mysql_result_list) # 调用替换MySQL查询结果的方法 if payload: payload = function_sql(payload, mysql_result_list) if headers: headers = function_sql(headers, mysql_result_list) if query_string: query_string = function_sql(query_string, mysql_result_list) if expected_result: expected_result = function_sql(expected_result, mysql_result_list) if api: api = function_rn(api) api = function_rl(api) if payload: payload = function_rn(payload) payload = function_rl(payload) payload = demjson.decode(payload) if headers: headers = function_rn(headers) headers = function_rl(headers) headers = demjson.decode(headers) if query_string: query_string = function_rn(query_string) query_string = function_rl(query_string) query_string = demjson.decode(query_string) url = service_domain + api # 拼接完整地址 logger.info("请求方式为:{}", request_mode) logger.info("地址为:{}", url) logger.info("请求体为:{}", payload) logger.info("请求头为:{}", headers) logger.info("请求参数为:{}", query_string) logger.info("预期的响应代码为:{}", expected_code) logger.info("预期的响应结果为:{}", expected_result) response = requests.request( request_mode, url, data=json.dumps(payload), headers=headers, params=query_string, timeout=(12, 18) ) # 发起HTTP请求 # json.dumps()序列化把字典转换成字符串,json.loads()反序列化把字符串转换成字典 # data请求体为字符串,headers请求头与params请求参数为字典 actual_time = response.elapsed.total_seconds() # 实际的响应时间 actual_code = response.status_code # 实际的响应代码 actual_result_text = response.text # 实际的响应结果(文本格式) if mysql: if mysql[2]: mysql[2] = function_rn(mysql[2]) mysql[2] = function_rl(mysql[2]) if "SELECT" in mysql[2]: db_after = ConnectMySQL() mysql_result_tuple_after = db_after.query_mysql(mysql[2]) mysql_result_list_after = list(chain.from_iterable(mysql_result_tuple_after)) logger.info("发起请求之后mysql查询的结果列表为:{}", mysql_result_list_after) logger.info("实际的响应代码为:{}", actual_code) logger.info("实际的响应结果为:{}", actual_result_text) logger.info("实际的响应时间为:{}", actual_time) if regular: # 如果正则不为空 extract_list = [] # 定义一个提取结果列表 for i in regular["expression"]: regular_result = re.findall(i, actual_result_text)[0] # re.findall(正则表达式, 实际的响应结果)返回一个符合规则的list,取第1个 extract_list.append(regular_result) # 把提取结果添加到提取结果列表里面 temporary_dict = dict(zip(regular["variable"], extract_list)) # 把变量列表与提取结果列表转为一个临时字典 for key, value in temporary_dict.items(): self.variable_result_dict[key] = value # 把临时字典合并到变量名与提取的结果字典,已去重 else: pass for key in list(self.variable_result_dict.keys()): if not self.variable_result_dict[key]: del self.variable_result_dict[key] # 删除变量名与提取的结果字典中为空的键值对 expected_result = re.sub("{|}|\'|\"|\\[|\\]| ", "", expected_result) actual_result_text = re.sub("{|}|\'|\"|\\[|\\]| ", "", actual_result_text) # 去除大括号{、}、单引号'、双引号"、中括号[、]与空格 expected_result_list = re.split(":|,", expected_result) actual_result_list = re.split(":|,", actual_result_text) # 把文本转为列表,并去除:与, logger.info("切割之后预期的响应结果列表为:{}", expected_result_list) logger.info("切割之后实际的响应结果列表为:{}", actual_result_list) if expected_code == actual_code: # 如果预期的响应代码等于实际的响应代码 if set(expected_result_list) <= set(actual_result_list): # 判断是否是其真子集 logger.info("{}>>>预期的响应结果与实际的响应结果断言成功", case_name) else: logger.error("{}>>>预期的响应结果与实际的响应结果断言失败!!!", case_name) assume(set(expected_result_list) <= set(actual_result_list)) # 预期的响应结果与实际的响应结果是被包含关系 if mysql: if mysql[2]: if set(mysql_result_list_after) <= set(actual_result_list): # 判断是否是其真子集 logger.info("{}>>>发起请求之后mysql查询结果与实际的响应结果断言成功", case_name) else: logger.error("{}>>>发起请求之后mysql查询结果与实际的响应结果断言失败!!!", case_name) assume(set(mysql_result_list_after) <= set(actual_result_list)) # 发起请求之后mysql查询结果与实际的响应结果是被包含关系 logger.info("##########用例分隔符##########\n") # 双重断言 else: logger.error("{}>>>执行失败!!!", case_name) logger.error("预期的响应代码与实际的响应代码不相等:{}!={}", expected_code, actual_code) assume(expected_code == actual_code) logger.info("##########用例分隔符##########\n") if __name__ == "__main__": pytest.main()
project_config.py文件:
""" 整个工程的配置文件 """ import os import sys import time from loguru import logger parameter = sys.argv[1] # 从命令行获取参数 if "--cmd=" in parameter: parameter = parameter.replace("--cmd=", "") else: pass environment = os.getenv("measured_environment", parameter) # 环境变量 if environment == "dev": service_domain = "http://www.dev.com" # 开发环境 db_host = 'mysql.dev.com' db_port = 3306 elif environment == "test": service_domain = "http://www.test.com" # 测试环境 db_host = 'mysql.test.com' db_port = 3307 elif environment == "pre": service_domain = "http://www.pre.com" # 预生产环境 db_host = 'mysql.pre.com' db_port = 3308 elif environment == "formal": service_domain = "https://www.formal.com" # 生产环境 db_host = None db_port = None db_user = 'root' db_password = '123456' db_database = '' # MySQL数据库配置 current_path = os.path.dirname(os.path.dirname(__file__)) # 获取当前目录的父目录的绝对路径 # 也就是整个工程的根目录 case_path = os.path.join(current_path, "case") # 测试用例的目录 yaml_path = os.path.join(current_path, "resource") # yaml文件的目录 today = time.strftime("%Y-%m-%d", time.localtime()) # 年月日 report_path = os.path.join(current_path, "report") # 测试报告的目录 if os.path.exists(report_path): pass else: os.mkdir(report_path, mode=0o777) log_path = os.path.join(current_path, "log") # 日志的目录 if os.path.exists(log_path): pass else: os.mkdir(log_path, mode=0o777) logging_file = os.path.join(log_path, "log{}.log".format(today)) logger.add( logging_file, format="{time:YYYY-MM-DD HH:mm:ss}|{level}|{message}", level="INFO", rotation="500 MB", encoding="utf-8", ) # loguru日志配置 test_scenario = "测试场景:XXX接口测试" test_story = "测试故事:XXX接口测试" test_case_priority = ["blocker", "critical", "normal", "minor", "trivial"] test_case_address = "http://www.testcase.com" test_case_address_title = "XXX接口测试用例地址" # allure配置 project_name = "XXX接口自动化测试" swagger_address = "http://www.swagger.com/swagger-ui.html" test_department = "测试部门:" tester = "测试人员:" # conftest配置 first_yaml = "demo_one.yaml" # 第一个yaml文件