diff --git a/Pipfile b/Pipfile index 4248951..972c697 100644 --- a/Pipfile +++ b/Pipfile @@ -20,6 +20,8 @@ pytest = "==6.2.5" pytest-html = "==2.1.1" pytest-rerunfailures = "*" allure-pytest = "==2.9.45" +pydantic = "*" +xpinyin = "*" [dev-packages] diff --git a/README.md b/README.md index 81f182b..51eccd9 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,15 @@ ## 二、实现功能 * 通过session会话方式,解决了登录之后cookie关联处理 - * 框架天然支持接口动态传参、关联灵活处理 - +* 支持测试数据分析,测试数据不符合规范有预警机制 * 测试数据隔离, 实现数据驱动 - * 自动生成用例代码: 测试人员在yaml/excel文件中填写好测试用例, 程序可以直接生成用例代码,纯小白也能使用 - * 动态多断言: 支持响应断言和数据库断言 - * 多种报告随心选择:框架支持pytest-html以及Allure测试报告,可以动态配置所需报告 - * 日志模块: 采用loguru管理日志,可以输出更为优雅,简洁的日志 - * 钉钉、企业微信通知: 支持多种通知场景,执行成功之后,可选择发送钉钉、或者企业微信、邮箱通知 - * 执行环境一键切换,解决多环境相互影响问题 - * 使用pipenv管理虚拟环境和依赖文件,提供了一系列命令和选项来帮助你实现各种依赖和环境管理相关的操作 @@ -52,7 +44,8 @@ │ ├────allure_handle.py 操作allure的相关方法 │ ├────platform_handle.py 跨平台的支持allure,用于生成allure测试报告 │ ├────assert_handle.py 断言处理, 包括响应断言和数据库断言 -│ ├────case_handle.py 根据配置文件,从指定类型文件中读取用例数据,并调用生成用例文件方法,生成用例文件 +│ ├────case_fun_handle.py 根据配置文件,从指定类型文件中读取用例数据,并调用生成用例文件方法,生成用例文件 +│ ├────case_data_analysis 分析用例数据是否符合规范 │ ├────data_handle.py 数据处理 │ ├────request_data_handle.py 针对用例数据进行请求前后的处理 │ ├────get_results_handle.py 从pytest-html/allure测试报告中获取测试结果 @@ -171,7 +164,7 @@ pip install pipenv 1)在目录`data`下新建一个YAML/Excel文件,按照要求编写测试用例数据 2)在test_case.test_manual_case下新建一个以"test"开头的测试方法,进行测试用例方法编写。 -### 6. 用例中相关字段的介绍 +### 5. 用例中相关字段的介绍 ```yaml - case_common :公共参数 @@ -186,13 +179,36 @@ pip install pipenv - method:请求方式,例如:GET, POST, DELETE, PUT, PATCH等 - headers:请求头,注意如果在headers里面防止cookies,其值类型需要是字符串 - cookies:请求cookies,格式是:DICT, CookieJar对象 - - pk:请求数据类型:params, json, file, data + - request_type:请求数据类型:params, json, file, data - payload:请求参数 - files:上传附件接口所需的文件绝对路径 - extract:后置提取参数 - assert_response:响应断言 - assert_sql:数据库断言 ``` +### 6. Excel用例单独说明 +框架支持excel多表单自动生成测试用例,每一个表单作为一个测试用例模块。 +例如: +excel表格名称是:test_demo.xlsx +excel表单1名称是:GitLink-登录模块 +excel表单2名称是:示例模块 + + +生成规则: +- 如果excel表单中存在"-",我们将取"-"后面的部分的首字母拼接excel文件名称作为测试用例模块/测试用例类/测试用例方法名称 +- 如果excel表单中不存在"-",我们将直接获取表单名称首字母拼接excel文件名称作为测试用例模块/测试用例类/测试用例方法名称 +- 测试用例模块/测试用例类/测试用例方法名称同时也将遵循python语法规则进行适当调整 + +基于上述规则: +- excel第一个表单生成的测试用例 +测试用例模块:test_demo_dlmk.py +测试用例类:TestDemoDlmkAuto +测试用例方法:test_demo_dlmk_auto + +- excel第二个表单生成的测试用例 +测试用例模块:test_demo_slmk.py +测试用例类:TestDemoSlmkAuto +测试用例方法:test_demo_slmk_auto ## 六、运行自动化测试 ### 1. 激活已存在的虚拟环境 diff --git a/case_utils/case_data_analysis.py b/case_utils/case_data_analysis.py new file mode 100644 index 0000000..ea201c7 --- /dev/null +++ b/case_utils/case_data_analysis.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# @Time : 2023/6/7 16:37 +# @Author : chenyinhua +# @File : case_data_analysis.py +# @Software: PyCharm +# @Desc: 分析用例数据是否符合规范 + +from typing import Text +from config.models import TestCase, TestCaseEnum, Method, RequestType + + +class CaseDataCheck: + """ + 用例数据解析, 判断数据填写是否符合规范 + """ + + def __init__(self): + self.case_data = None + self.case_id = None + + @property + def get_method(self) -> Text: + + return self.check_params_right( + Method, + self.case_data.get(TestCaseEnum.METHOD.value[0]) + ) + + @property + def get_request_type(self): + return self.check_params_right( + RequestType, + self.case_data.get(TestCaseEnum.REQUEST_TYPE.value[0]) + ) + + def check_case_data_attr(self, attr: Text): + assert attr in self.case_data.keys(), ( + f"用例ID为 {self.case_id} 的用例中缺少 {attr} 参数,请确认用例内容是否编写规范." + ) + + def check_params_exit(self): + """ + 遍历一个枚举类中所有成员,并检查与每个成员对应的实例属性是否存在。 + 如果属性存在,则什么也不做,如果不存在,则抛出异常或执行其他操作 + """ + for enum in list(TestCaseEnum._value2member_map_.keys()): + if enum[1]: + self.check_case_data_attr(enum[0]) + + def check_params_right(self, enum_name, attr): + """ + 检查参数值是否正确,符合要求规范 + """ + _member_names_ = enum_name._member_names_ + assert attr.upper() in _member_names_, ( + f"用例ID为 {self.case_id} 的用例中 {enum_name}: {attr} 填写不正确," + f"当前框架中只支持 {_member_names_} 类型." + f"如需新增 method 类型,请联系管理员." + ) + return attr.upper() + + @property + def get_assert_response(self): + _assert_data = self.case_data.get(TestCaseEnum.ASSERT_RESPONSE.value[0]) + assert _assert_data is not None, ( + f"用例ID 为 {self.case_id} 未添加断言" + ) + return _assert_data + + def case_process(self, cases): + case_list = [] + for key, values in cases.items(): + # 公共配置中的数据,与用例数据不同,需要单独处理 + if key != 'case_common': + # 检查用例数据,去除用例数据中的空格 + for k, v in values.items(): + values[k] = v.strip() if isinstance(v, str) else v + self.case_data = values + self.case_id = key + self.check_params_exit() + case_data = { + 'feature': self.case_data.get(TestCaseEnum.FEATURE.value[0]), + 'title': self.case_data.get(TestCaseEnum.TITLE.value[0]), + 'url': self.case_data.get(TestCaseEnum.URL.value[0]), + 'method': self.get_method, + 'run': self.case_data.get(TestCaseEnum.RUN.value[0]), + 'headers': self.case_data.get(TestCaseEnum.HEADERS.value[0]), + 'cookies': self.case_data.get(TestCaseEnum.COOKIES.value[0]), + 'request_type': self.get_request_type, + 'payload': self.case_data.get(TestCaseEnum.PAYLOAD.value[0]), + 'extract': self.case_data.get(TestCaseEnum.EXTRACT.value[0]), + "assert_response": self.get_assert_response, + "assert_sql": self.case_data.get(TestCaseEnum.ASSERT_SQL.value[0]), + } + case_list.append(TestCase(**case_data).dict()) + + return case_list diff --git a/case_utils/case_fun_handle.py b/case_utils/case_fun_handle.py new file mode 100644 index 0000000..1cb0f80 --- /dev/null +++ b/case_utils/case_fun_handle.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# @Time : 2023/6/7 10:07 +# @Author : chenyinhua +# @File : case_fun_handle.py +# @Software: PyCharm +# @Desc: + +from config.path_config import DATA_DIR, CASE_TEMPLATE_DIR, AUTO_CASE_DIR +from common_utils.excel_handle import ExcelHandle +from common_utils.yaml_handle import YamlHandle +from config.models import CaseFileType +from config.settings import CASE_FILE_TYPE +from loguru import logger +from common_utils.files_handle import get_files +from case_utils.case_data_analysis import CaseDataCheck +import os +from config.path_config import AUTO_CASE_DIR +from string import Template +import datetime +from xpinyin import Pinyin # 纯 Python 编写的中文字符串拼音转换模块,不需要依赖外部程序和词库。 + +""" +主要步骤: +1. 从用例数据文件(EXCEL/YAML)中获取用例数据 +2. 分析用例数据是否符合规范 +3. 确认符合规范后,获取所有用例数据,自动生成测试用例方法(PY) +""" + + +def generate_case_from_excel(files: list): + """ + 读取excel用例数据生成测试用例 + """ + for file in files: + # 读取excel文件中的用例数据,存储到data中 + if os.path.isfile(file): + # 读取xlsx/xls文件中的用例数据,存储到data中 + data = ExcelHandle(file).read() + # logger.debug(f"从{file}中读取到的用例数据是:{data}") + for index, v in enumerate(data): + excel_case = {} + # 将excel读取到的用例数据,适配allure格式 + """ + 表单名称以短横线隔开的情况下,左边部分作为allure_epic, 右边部分作为allure_feature以及allure_story。 + 否则,表单名称全部作为allure_epic,allure_feature,allure_story + """ + if "-" in v["sheet_name"]: + excel_case["case_common"] = { + "allure_epic": v["sheet_name"].split("-")[0], + "allure_feature": v["sheet_name"].split("-")[1], + "allure_story": v["sheet_name"].split("-")[1] + } + else: + excel_case["case_common"] = { + "allure_epic": v["sheet_name"], + "allure_feature": v["sheet_name"], + "allure_story": v["sheet_name"] + } + # 处理excel用例数据 + for case in v["data"]: + excel_case[case["id"]] = case + # 检查用例数据是否符合规范 + tested_case = CaseDataCheck().case_process(excel_case) + # 生成测试方法 + """ + 由于excel涉及到多个表单,每一个表单都会生成一个测试方法。因此会将表单名称的首字母拼接到测试方法上。 + excel名称:test_demo + 例如表单名称:"GitLink-登录模块" 或 "登录模块",都是取关键字"登录模块"首字母 + 测试文件:test_demo_dl.py + 测试方法名称: TestDemoDl.test_demo_dl + """ + pin_yin = Pinyin() + _name = pin_yin.get_initials(excel_case["case_common"]["allure_feature"], "").lower() + gen_case_file(filename=os.path.splitext(os.path.basename(file))[0] + "_" + _name, + case_template_path=CASE_TEMPLATE_DIR, + case_common=excel_case["case_common"], case_data=tested_case, + target_case_path=AUTO_CASE_DIR) + else: + logger.error(f"{file}不是一个正确的文件路径!") + + +def generate_case_from_yaml(files: list): + """ + 读取yaml用例数据生成测试用例 + """ + for file in files: + # 从yaml/yml中读取用例数据 + if os.path.isfile(file): + # 读取yaml/yml文件中的用例数据,存储到data中 + yaml_data = YamlHandle(file).read_yaml + # logger.debug(f"从{file}中读取到的用例数据是:{yaml_data}") + # 检查用例数据是否符合规范 + tested_case = CaseDataCheck().case_process(yaml_data) + # 生成测试方法 + gen_case_file(filename=os.path.splitext(os.path.basename(file))[0], case_template_path=CASE_TEMPLATE_DIR, + case_common=yaml_data["case_common"], case_data=tested_case, + target_case_path=AUTO_CASE_DIR) + else: + logger.error(f"{file}不是一个正确的文件路径!") + + +def generate_cases(): + """ + 根据配置文件,从指定类型文件中读取所有用例数据,并自动生成测试用例 + """ + # 判断配置文件里面CASE_DATA_TYPE,判断用例数据是从excel还是yaml文件中读取 + # 从excel中读取用例数据 + if CASE_FILE_TYPE == CaseFileType.EXCEL.value: + # 在用例数据"DATA_DIR"目录中寻找后缀是xlsx, xls的文件 + files = get_files(target=DATA_DIR, start="test_", end=".xlsx") \ + + get_files(target=DATA_DIR, start="test_", end=".xls") + # 自动生成测试用例 + generate_case_from_excel(files) + # 从yaml中读取用例数据 + elif CASE_FILE_TYPE == CaseFileType.YAML.value: + # 在用例数据"DATA_DIR"目录中寻找后缀是yaml, yml的文件 + files = get_files(target=DATA_DIR, start="test_", end=".yaml") \ + + get_files(target=DATA_DIR, start="test_", end=".yml") + # 自动生成测试用例 + generate_case_from_yaml(files) + else: + # 在用例数据"DATA_DIR"目录中寻找后缀是xlsx,xls, yaml, yml的文件 + excel_files = get_files(target=DATA_DIR, start="test_", end=".xlsx") \ + + get_files(target=DATA_DIR, start="test_", end=".xls") + yaml_files = get_files(target=DATA_DIR, start="test_", end=".yaml") \ + + get_files(target=DATA_DIR, start="test_", end=".yml") + # 自动生成测试用例 + generate_case_from_excel(excel_files) + generate_case_from_yaml(yaml_files) + + +def gen_case_file(filename, case_template_path, case_common, case_data, target_case_path): + """ + 根据测试用例文件(yaml/yml/xlsx/xls),以及事先定义的测试用例模板,实际用例数据,生成测试用例方法(.py) + :param filename: 测试用例文件(yaml/yml/xlsx/xls)的名称,用作生成测试用例类名,方法名 + :param case_template_path: 测试用例模板的绝对路径 + :param case_common: 用例公共参数 + :param case_data: 实际用例数据 + :param target_case_path: 测试用例方法(.py)的绝对路径 + """ + # 如果自动生成用例的目录不存在则自动创建一个 + if not os.path.exists(AUTO_CASE_DIR): + os.makedirs(AUTO_CASE_DIR) + """ + string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。 + """ + # 将用例数据的名称作为测试用例文件名称, 如test_login_demo + func_name = filename + # 方法名test_demo的类名是TestDemo + class_name = "".join([word.capitalize() for word in func_name.split("_")]) + # 定义生成的测试用例的模板 + with open(file=case_template_path, mode="r", encoding="utf-8") as f: + case_template = f.read() + # 根据模板,生成测试用例方法 + my_case = Template(case_template).safe_substitute({"allure_epic": case_common["allure_epic"], + "allure_feature": case_common["allure_feature"], + "allure_story": case_common["allure_story"], + "case_data": case_data, + "func_title": func_name, + "class_title": class_name, + "now": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}) + # 将测试用例方法写入py文件中 + with open(os.path.join(target_case_path, func_name + '.py'), "w", encoding="utf-8") as fp: + fp.write(my_case) + + +if __name__ == '__main__': + generate_cases() diff --git a/case_utils/request_data_handle.py b/case_utils/request_data_handle.py index 15fd81b..bbd4a95 100644 --- a/case_utils/request_data_handle.py +++ b/case_utils/request_data_handle.py @@ -30,7 +30,7 @@ class RequestPreDataHandle: f"请求方式: {type(request_data.get('method', None))} || {request_data.get('method', None)}\n" \ f"请求头: {type(request_data.get('headers', None))} || {request_data.get('headers', None)}\n" \ f"请求cookies: {type(request_data.get('cookies', None))} || {request_data.get('cookies', None)}\n" \ - f"请求类型: {type(request_data.get('pk', None))} || {request_data.get('pk', None)}\n" \ + f"请求类型: {type(request_data.get('request_type', None))} || {request_data.get('request_type', None)}\n" \ f"请求内容: {type(request_data.get('payload', None))} || {request_data.get('payload', None)}\n" \ f"请求文件: {type(request_data.get('files', None))} || {request_data.get('files', None)}\n" \ f"后置提取参数: {type(request_data.get('extract', None))} || {request_data.get('extract', None)}\n" \ @@ -58,7 +58,7 @@ class RequestPreDataHandle: f"请求方式: {type(self.request_data.get('method', None))} || {self.request_data.get('method', None)}\n" \ f"请求头: {type(self.request_data.get('headers', None))} || {self.request_data.get('headers', None)}\n" \ f"请求cookies: {type(self.request_data.get('cookies', None))} || {self.request_data.get('cookies', None)}\n" \ - f"请求类型: {type(self.request_data.get('pk', None))} || {self.request_data.get('pk', None)}\n" \ + f"请求类型: {type(self.request_data.get('request_type', None))} || {self.request_data.get('request_type', None)}\n" \ f"请求内容: {type(self.request_data.get('payload', None))} || {self.request_data.get('payload', None)}\n" \ f"请求文件: {type(self.request_data.get('files', None))} || {self.request_data.get('files', None)}\n" \ f"后置提取参数: {type(self.request_data.get('extract', None))} || {self.request_data.get('extract', None)}\n" \ @@ -204,7 +204,7 @@ class RequestHandle: f"请求方式: {type(self.case_data.get('method', None))} || {self.case_data.get('method', None)}\n" \ f"请求头: {type(self.case_data.get('headers', None))} || {self.case_data.get('headers', None)}\n" \ f"请求cookies: {type(self.case_data.get('cookies', None))} || {self.case_data.get('cookies', None)}\n" \ - f"请求类型: {type(self.case_data.get('pk', None))} || {self.case_data.get('pk', None)}\n" \ + f"请求类型: {type(self.case_data.get('request_type', None))} || {self.case_data.get('request_type', None)}\n" \ f"请求内容: {type(self.case_data.get('payload', None))} || {self.case_data.get('payload', None)}\n" \ f"请求文件: {type(self.case_data.get('files', None))} || {self.case_data.get('files', None)}\n" \ f"请求响应数据: {response.text}\n" \ diff --git a/common_utils/base_request.py b/common_utils/base_request.py index 5aca1f1..fae99c5 100644 --- a/common_utils/base_request.py +++ b/common_utils/base_request.py @@ -37,7 +37,7 @@ class BaseRequest: f"请求方式: {req_data.get('method', None)}\n" \ f"请求头: {req_data.get('headers', None)}\n" \ f"请求Cookies: {req_data.get('cookies', None)}\n" \ - f"请求关键字: {req_data.get('pk', None)}\n" \ + f"请求关键字: {req_data.get('request_type', None)}\n" \ f"请求内容: {req_data.get('payload', None)}\n" \ f"请求文件: {req_data.get('files', None)}\n" \ "=====================================================") @@ -48,14 +48,14 @@ class BaseRequest: f"请求方式: {req_data.get('method', None)}\n" \ f"请求头: {req_data.get('headers', None)}\n" \ f"请求Cookies: {req_data.get('cookies', None)}\n" \ - f"请求关键字: {req_data.get('pk', None)}\n" \ + f"请求关键字: {req_data.get('request_type', None)}\n" \ f"请求内容: {req_data.get('payload', None)}\n" \ f"请求文件: {req_data.get('files', None)}\n" \ "=====================================================") res = cls.send_api_request( url=req_data.get("url"), method=req_data.get("method").lower(), - pk=req_data.get("pk", None), + request_type=req_data.get("request_type", None), header=req_data.get("headers", None), payload=req_data.get("payload", None), files=req_data.get("files", None), @@ -79,13 +79,13 @@ class BaseRequest: return res @classmethod - def send_api_request(cls, url: str, method: str, pk: str, header: Dict[str, str] = None, payload=None, + def send_api_request(cls, url: str, method: str, request_type: str, header: Dict[str, str] = None, payload=None, files=None, cookies=None) -> Response: """ 发送请求 :param method: 请求方法 :param url: 请求url - :param pk: 请求参数类型,可选值为params,json,data + :param request_type: 请求参数类型,可选值为params,json,data :param payload: 请求数据,对于不同请求类型,可以为dict,MultipartEncoder等 :param files: 请求上传的文件 :param header: 请求头 @@ -95,9 +95,9 @@ class BaseRequest: headers = header or {} session = cls.get_session() - if pk and pk.lower() == 'params': + if request_type and request_type.lower() == 'params': res = session.request(method=method, url=url, params=payload, headers=headers, cookies=cookies, timeout=5) - elif pk and pk.lower() == 'data': + elif request_type and request_type.lower() == 'data': if files: if not isinstance(files, dict): raise ValueError('data参数必须为dict') @@ -108,7 +108,7 @@ class BaseRequest: else: headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' res = session.request(method=method, url=url, data=payload, headers=headers, cookies=cookies, timeout=5) - elif pk and pk.lower() == 'json': + elif request_type and request_type.lower() == 'json': if files: if not isinstance(files, dict): raise ValueError('json参数必须为dict') @@ -120,8 +120,8 @@ class BaseRequest: headers['Content-Type'] = 'application/json' res = session.request(method=method, url=url, json=payload, headers=headers, cookies=cookies, timeout=5) else: - logger.error('pk可选关键字为params, json, data') - print('pk可选关键字为params, json, data') - raise ValueError('pk可选关键字为params, json, data') + logger.error('request_type可选关键字为params, json, data') + print('request_type可选关键字为params, json, data') + raise ValueError('request_type可选关键字为params, json, data') return res diff --git a/common_utils/data_handle.py b/common_utils/data_handle.py index 9fd0122..8df11b4 100644 --- a/common_utils/data_handle.py +++ b/common_utils/data_handle.py @@ -26,6 +26,7 @@ def data_replace(content, source): return None logger.debug("\n======================================================\n" \ "-------------Start:数据替换--------------------\n" + f"替换源: {source}\n" \ f"初始字符串: {content}\n" \ "=====================================================") print(f"-----Start-----数据替换: 初始字符串为:{content}") diff --git a/common_utils/func_handle.py b/common_utils/func_handle.py index a5df01e..dbf8257 100644 --- a/common_utils/func_handle.py +++ b/common_utils/func_handle.py @@ -15,4 +15,18 @@ def add_docstring(docstring): func.__doc__ = docstring return func - return decorator \ No newline at end of file + return decorator + + +class AddCLassDocstring: + """ + 类装饰器,它接受一个字符串参数docstring, + 并返回一个装饰器函数。装饰器函数接受一个函数参数func, + 并将func的__doc__属性设置为docstring。 + """ + def __init__(self, docstring): + self.docstring = docstring + + def __call__(self, func): + func.__doc__ = self.docstring + return func \ No newline at end of file diff --git a/config/case_template.txt b/config/case_template.txt index ce0ac4a..f44cb2e 100644 --- a/config/case_template.txt +++ b/config/case_template.txt @@ -1,31 +1,33 @@ +# -*- coding: utf-8 -*- +# @Time : ${now} + import pytest from case_utils.assert_handle import assert_response, assert_sql from loguru import logger from case_utils.request_data_handle import RequestPreDataHandle, RequestHandle, after_request_extract from pytest_html import extras # 往pytest-html报告中填写额外的内容 -from common_utils.func_handle import add_docstring from case_utils.allure_handle import allure_title import allure from config.settings import db_info from config.global_vars import GLOBAL_VARS - # 用例数据 cases = ${case_data} -@allure.story(f'{cases["case_common"]["allure_story"]}') -@pytest.mark.${func_title} -@pytest.mark.auto -@pytest.mark.parametrize("case", cases.get("case_info")) -def ${func_title}_auto(case, extra): - logger.info("-----------------------------START-开始执行用例-----------------------------") - logger.debug(f"当前执行的用例数据:{case}") - # 给当前测试方法添加文档注释 - add_docstring(case.get("title", ""))(${func_title}_auto) - # 添加用例标题作为allure中显示的用例标题 - allure_title(case.get("title", "")) - if case.get("run", None): +@allure.epic("${allure_epic}") +@allure.feature("${allure_feature}") +class ${class_title}Auto: + + @allure.story("${allure_story}") + @pytest.mark.${func_title} + @pytest.mark.auto + @pytest.mark.parametrize("case", cases, ids=["{}".format(case["title"]) for case in cases]) + def ${func_title}_auto(self, case, extra): + logger.info("-----------------------------START-开始执行用例-----------------------------") + logger.debug(f"当前执行的用例数据:{case}") + # 添加用例标题作为allure中显示的用例标题 + allure_title(case.get("title", "")) # 处理请求前的用例数据 case_data = RequestPreDataHandle(case).request_data_handle() # 将用例数据显示在pytest-html报告中 @@ -40,9 +42,5 @@ def ${func_title}_auto(case, extra): assert_sql(db_info[GLOBAL_VARS["env_key"]], case_data["assert_sql"]) # 断言成功后进行参数提取 after_request_extract(response, case_data.get("extract", None)) - else: - reason = f"标记了该用例为false,不执行\\n" - logger.warning(f"{reason}") - pytest.skip(reason) - logger.info("-----------------------------END-用例执行结束-----------------------------") + logger.info("-----------------------------END-用例执行结束-----------------------------") diff --git a/config/models.py b/config/models.py index 7a6661c..a73ab73 100644 --- a/config/models.py +++ b/config/models.py @@ -7,6 +7,8 @@ # @Desc: 全局变量 from enum import Enum, unique # python 3.x版本才能使用 +from typing import Text, Dict, Callable, Union, Optional, List, Any +from pydantic import BaseModel class CaseFileType(Enum): @@ -55,3 +57,57 @@ class AllureAttachmentType(Enum): WEBM = "webm" PDF = "pdf" + + +class TestCaseEnum(Enum): + FEATURE = ("feature", False) + TITLE = ("title", True) + URL = ("url", True) + METHOD = ("method", True) + HEADERS = ("headers", True) + COOKIES = ("cookies", False) + RUN = ("run", False) + REQUEST_TYPE = ("request_type", True) + PAYLOAD = ("payload", False) + FILES = ("files", False) + EXTRACT = ("extract", False) + ASSERT_RESPONSE = ("assert_response", True) + ASSERT_SQL = ("assert_sql", False) + + +class TestCase(BaseModel): + feature: Union[None, Text] = None + title: Text + url: Text + method: Text + headers: Union[None, Dict, Text] = {} + cookies: Union[None, Dict, Text] + request_type: Text + run: Union[None, bool, Text] = None + payload: Any = None + files: Any = None + extract: Union[None, Dict, Text] = None + assert_response: Union[None, Dict, Text] + assert_sql: Union[None, Dict, Text] = None + + +class Method(Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELETE = "DELETE" + HEAD = "HEAD" + OPTION = "OPTION" + + +class RequestType(Enum): + """ + request请求发送,请求参数的数据类型 + """ + JSON = "JSON" + PARAMS = "PARAMS" + DATA = "DATA" + FILE = 'FILE' + EXPORT = "EXPORT" + NONE = "NONE" diff --git a/config/settings.py b/config/settings.py index 755e85a..21d0add 100644 --- a/config/settings.py +++ b/config/settings.py @@ -8,7 +8,7 @@ # ------------------------------------ 配置信息 ----------------------------------------------------# # 0代表执行Excel和yaml两种格式的用例, 1 代表 yaml文件,2 用例代表Excel用例 -CASE_FILE_TYPE = 1 +CASE_FILE_TYPE = 0 # 0表示默认不发送任何通知, 1代表钉钉通知,2代表企业微信通知, 3代表邮件通知, 4代表所有途径都发送通知 SEND_RESULT_TYPE = 0 diff --git a/conftest.py b/conftest.py index 3d3b174..716e238 100644 --- a/conftest.py +++ b/conftest.py @@ -11,16 +11,10 @@ from config.global_vars import ENV_VARS, GLOBAL_VARS import pytest from py._xmlgen import html # 安装pytest-html,版本最好是2.1.1 from time import strftime +import re # ------------------------------------- START: 报告处理 ---------------------------------------# -def pytest_collection_modifyitems(items): - """# 测试用例执行收集完成时,将收集到的item的name和nodeid的中文显示在控制台上""" - for item in items: - item.name = item.name.encode("utf-8").decode("unicode-escape") - item._nodeid = item._nodeid.encode("utf-8").decode("unicode_escape") - - @pytest.mark.hookwrapper def pytest_runtest_makereport(item, call): """设置列"用例描述"的值为用例的标题title""" @@ -28,9 +22,10 @@ def pytest_runtest_makereport(item, call): # 获取调用结果的测试报告,返回一个report对象 # report对象的属性包括when(steup, call, teardown三个值)、nodeid(测试用例的名字)、outcome(用例的执行结果,passed,failed) report = outcome.get_result() - # 将测试方法的文档注释作为结果表的Description的值,如果文档注释为空,则测试方法名作为结果表的Description的值 - report.description = str(item.function.__doc__) - report.nodeid = report.nodeid.encode("utf-8").decode("unicode_escape") + # 将测试用例的title作为测试报告"用例描述"列的值。 + # 注意参数传递时需要这样写:@pytest.mark.parametrize("case", cases, ids=["{}".format(case["title"]) for case in cases]) + report.description = re.findall('\[(.*?)\]', report.nodeid)[0] + report.func = report.nodeid.split("[")[0] def pytest_html_report_title(report): @@ -73,10 +68,13 @@ def pytest_html_results_table_header(cells): """ 修改结果表的表头 """ + cells.pop(1) # 移除 "Test" 列 # 往表格中增加一列"用例描述",并且给"用例描述"增加排序 cells.insert(0, html.th('用例描述', class_="sortable", col="name")) + # 往表格中增加一列"用例方法",并且给"用例方法"增加排序 + cells.insert(1, html.th('用例方法', class_="sortable", col="name")) # 往表格中增加一列"执行时间",并且给"执行时间"增加排序 - cells.insert(1, html.th('执行时间', class_="sortable time", col="time")) + cells.insert(2, html.th('执行时间', class_="sortable time", col="time")) @pytest.mark.optionalhook @@ -84,10 +82,13 @@ def pytest_html_results_table_row(report, cells): """ 修改结果表的表头后给对应的行增加值 """ + cells.pop(1) # 移除 "Test" 列 # 往列"用例描述"插入每行的值 cells.insert(0, html.td(report.description)) + # 往列"用例方法"插入每行的值 + cells.insert(1, html.td(report.func)) # 往列"执行时间"插入每行的值 - cells.insert(1, html.td(strftime("%Y-%m-%d %H:%M:%S"), class_="col-time")) + cells.insert(2, html.td(strftime("%Y-%m-%d %H:%M:%S"), class_="col-time")) def pytest_html_results_table_html(report, data): diff --git a/data/test_login_demo.yaml b/data/login_demo.yaml similarity index 84% rename from data/test_login_demo.yaml rename to data/login_demo.yaml index 35c9a20..66c7a08 100644 --- a/data/test_login_demo.yaml +++ b/data/login_demo.yaml @@ -1,24 +1,24 @@ # 公共参数 case_common: - allure_epic: GitLink接口 # 敏捷里面的概念,定义史诗,相当于module级的标签, 往下是 feature + allure_epic: GitLink接口(手动编写用例) # 敏捷里面的概念,定义史诗,相当于module级的标签, 往下是 feature allure_feature: 登录模块 # 功能点的描述,相当于class级的标签, 理解成模块往下是 story allure_story: 登录接口 # 故事,可以理解为场景,相当于method级的标签, 往下是 title # 用例数据 -case_info: -- feature: 登录 +case_login_demo_01: + feature: 登录 title: 用户名密码正确,登录成功(不校验数据库) + run: True url: /api/accounts/login.json method: POST headers: {"Content-Type": "application/json; charset=utf-8;"} cookies: - pk: json + request_type: json payload: { "login": "${login}","password": "${password}","autologin": 1 } files: extract: nickname: $.username login: $.login user_id: $.user_id - run: False assert_response: eq: http_code: 200 @@ -32,20 +32,22 @@ case_info: not: $.user_id: 85390 assert_sql: -- feature: 登录 + +case_login_demo_02: + feature: 登录 title: 用户名密码正确,登录成功(校验数据库) + run: False url: /api/accounts/login.json method: POST headers: {"Content-Type": "application/json; charset=utf-8;"} cookies: - pk: json + request_type: json payload: { "login": "${login}","password": "${password}","autologin": 1 } files: extract: nickname: $.username login: $.login user_id: $.user_id - run: False assert_response: eq: http_code: 200 @@ -62,19 +64,20 @@ case_info: eq: sql: select count(*) from tokens where user_id=${user_id}; len: 1 -- feature: 登录 +case_login_demo_03: + feature: 登录 title: 用户名正确,密码错误,登录失败 + run: False url: /api/accounts/login.json method: POST headers: {"Content-Type": "application/json; charset=utf-8;"} cookies: - pk: json + request_type: json payload: { "login": "chytest10","password": "password111","autologin": 1 } files: extract: - run: False assert_response: eq: http_code: 200 $.status: -2 - assert_sql: \ No newline at end of file + assert_sql: diff --git a/data/test_gitlink_demo.xlsx b/data/test_gitlink_demo.xlsx new file mode 100644 index 0000000..2c98df4 Binary files /dev/null and b/data/test_gitlink_demo.xlsx differ diff --git a/data/test_login_excel_demo.xlsx b/data/test_login_excel_demo.xlsx deleted file mode 100644 index b6613f4..0000000 Binary files a/data/test_login_excel_demo.xlsx and /dev/null differ diff --git a/data/test_new_project_demo.yaml b/data/test_new_project_demo.yaml index ccc6184..8266515 100644 --- a/data/test_new_project_demo.yaml +++ b/data/test_new_project_demo.yaml @@ -1,19 +1,21 @@ # 公共参数 case_common: - allure_epic: GitLink接口 # 敏捷里面的概念,定义史诗,相当于module级的标签, 往下是 feature + allure_epic: GitLink接口(自动生成用例) # 敏捷里面的概念,定义史诗,相当于module级的标签, 往下是 feature allure_feature: 开源项目模块 # 功能点的描述,相当于class级的标签, 理解成模块往下是 story allure_story: 新建项目接口 # 故事,可以理解为场景,相当于method级的标签, 往下是 title + # 用例数据 -case_info: -- feature: 新建项目 +case_new_project_demo_01: + feature: 新建项目 title: 正确输入各项必填参数,新建项目成功(不校验数据库, header里面传cookies) + run: True url: /api/projects.json method: POST headers: Content-Type: application/json; charset=utf-8; cookies: ${login_cookie} cookies: - pk: json + request_type: json payload: "user_id": ${user_id} "name": ${faker.name().replace(" ", "").replace(".", "")} @@ -23,20 +25,22 @@ case_info: project_id: $.id project_name: $.name project_identifier: $.identifier - run: True assert_response: eq: http_code: 200 $.login: ${login} assert_sql: -- feature: 新建项目 + +case_new_project_demo_02: + feature: 新建项目 title: 正确输入各项必填参数,新建项目成功(不校验数据库,单独传cookies) + run: True url: /api/projects.json method: POST headers: Content-Type: application/json; charset=utf-8; cookies: ${login_cookie} - pk: json + request_type: json payload: "user_id": ${user_id} "name": ${faker.name().replace(" ", "").replace(".", "")} @@ -46,19 +50,21 @@ case_info: project_id: $.id project_name: $.name project_identifier: $.identifier - run: True assert_response: eq: http_code: 200 $.login: ${login} assert_sql: -- feature: 新建项目 + +case_new_project_demo_03: + feature: 新建项目 title: 正确输入各项必填参数,新建项目成功(校验数据库) + run: False url: /api/projects.json method: POST headers: {"Content-Type": "application/json; charset=utf-8;"} cookies: - pk: json + request_type: json payload: "user_id": ${user_id} "name": ${faker.name().replace(" ", "").replace(".", "")} @@ -68,14 +74,13 @@ case_info: project_id: $.id project_name: $.name project_identifier: $.identifier - run: False assert_response: eq: http_code: 200 $.login: ${login} assert_sql: eq: - sql: select id, `name`, identifier from projects where user_id=${user_id} ORDER BY created_on DESC; + sql: select id,`name`, identifier from projects where user_id=${user_id} ORDER BY created_on DESC; $.id: ${project_id} $.name: ${project_name} $.identifier: ${project_identifier} \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index c58b57d..b2b6fd0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,7 +4,8 @@ addopts = -s --cache-clear --capture=sys --self-contained-html --reruns=0 --reru disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True markers = test_login_demo: login - test_login_excel_demo: login excel + test_gitlink_demo_dlmkexcel: login excel + test_gitlink_demo_xjxmmkexcel: new project excel test_new_project_demo: new_project - test_softbot: softbot - auto: auto generate case \ No newline at end of file + auto: auto generate case + test_demo: demo case \ No newline at end of file diff --git a/run.py b/run.py index 1f6a5b7..c69b0a9 100644 --- a/run.py +++ b/run.py @@ -20,7 +20,7 @@ import shutil import pytest from config.path_config import REPORT_DIR, LOG_DIR, AUTO_CASE_DIR, CONF_DIR, LIB_DIR, ALLURE_RESULTS_DIR, \ ALLURE_HTML_DIR -from case_utils.case_handle import get_case_data +from case_utils.case_fun_handle import generate_cases from loguru import logger import click from config.settings import LOG_LEVEL @@ -55,6 +55,11 @@ def run(env, m, report): |_| Starting ... ... ... """) + # ------------------------ 设置全局变量 ------------------------ + # 根据指定的环境参数,将运行环境所需相关配置数据保存到GLOBAL_VARS + GLOBAL_VARS["env_key"] = env.lower() + for k, v in ENV_VARS[env.lower()].items(): + GLOBAL_VARS[k] = v # ------------------------ 自动生成测试用例 ------------------------ # 删除原有的测试用例,以便生成新的测试用例 if os.path.exists(AUTO_CASE_DIR): @@ -62,7 +67,7 @@ def run(env, m, report): shutil.rmtree(AUTO_CASE_DIR) # 根据data里面的yaml/excel文件,自动生成测试用例 - get_case_data() + generate_cases() # ------------------------ pytest执行测试用例 ------------------------ """ @@ -80,10 +85,6 @@ def run(env, m, report): "--reruns=3", "--reruns-delay=2" """ arg_list = [] - # 根据指定的环境参数,将运行环境所需相关配置数据保存到GLOBAL_VARS - GLOBAL_VARS["env_key"] = env.lower() - for k, v in ENV_VARS[env.lower()].items(): - GLOBAL_VARS[k] = v # 执行指定测试用例 if m is not None: arg_list.append(f"-m {m}") diff --git a/test_case/conftest.py b/test_case/conftest.py index 05c9194..043930b 100644 --- a/test_case/conftest.py +++ b/test_case/conftest.py @@ -13,6 +13,18 @@ from loguru import logger from common_utils.base_request import BaseRequest +@pytest.fixture(scope="function", autouse=True) +def case_skip(request): + """处理跳过用例""" + # 使用 request.getfixturevalue() 方法来获取测试用例函数的参数值 + # 注意这里的"case"需要与@pytest.mark.parametrize("case", cases)中传递的保持一致 + case = request.getfixturevalue("case") + if case.get("run") is None or case.get("run") is False: + reason = f"{case.get('title')}: 标记了该用例为false,不执行\\n" + logger.warning(f"{reason}") + pytest.skip(reason) + + @pytest.fixture(scope="session", autouse=True) def login_init(): """ @@ -28,7 +40,7 @@ def login_init(): req_data = { "url": host + "/api/accounts/login.json", "method": "POST", - "pk": "json", + "request_type": "json", "headers": {"Content-Type": "application/json; charset=utf-8;"}, "payload": {"login": login, "password": password, "autologin": 1} } diff --git a/test_case/test_manual_case/test_demo.py b/test_case/test_manual_case/test_demo.py index 487091b..6d405c8 100644 --- a/test_case/test_manual_case/test_demo.py +++ b/test_case/test_manual_case/test_demo.py @@ -10,33 +10,25 @@ import pytest from loguru import logger from pytest_html import extras # 往pytest-html报告中填写额外的内容 -from common_utils.func_handle import add_docstring import allure from case_utils.allure_handle import allure_title, allure_step # 读取用例数据 -cases = [{"title": "demo case 01", "user": "flora1", "age": 17, "run": False}, - {"title": "demo case 02", "user": "lucy", "age": 17, "run": False}] +cases = [{"title": "demo用例01", "user": "flora", "age": 17, "run": True}, + {"title": "demo用例02", "user": "lucy", "age": 17, "run": False}] -@allure.story("demo模块") -@pytest.mark.test_login_demo -@pytest.mark.parametrize("case", cases) +@allure.story("demo模块(手动用例)") +@pytest.mark.test_demo +@pytest.mark.parametrize("case", cases, ids=["{}".format(case["title"]) for case in cases]) def test_demo(case, extra): logger.info("-----------------------------START-开始执行用例-----------------------------") logger.debug(f"当前执行的用例数据:{case}") - # 给当前测试方法添加文档注释 - add_docstring(case.get("title", ""))(test_demo) # 添加用例标题作为allure中显示的用例标题 allure_title(case.get("title", "")) - if case.get("run", None): - # 将用例数据显示在pytest-html报告中 - extra.append(extras.json(case, name="用例数据")) - # 在allure报告中显示请求的用例数据 - allure_step(step_title="用例数据", content=f"{case}") - assert case["user"] == "flora" - else: - reason = f"标记了该用例为false,不执行\\n" - logger.warning(f"{reason}") - pytest.skip(reason) + # 将用例数据显示在pytest-html报告中 + extra.append(extras.json(case, name="用例数据")) + # 在allure报告中显示请求的用例数据 + allure_step(step_title="用例数据", content=f"{case}") + assert case["user"] == "flora" logger.info("-----------------------------END-用例执行结束-----------------------------") diff --git a/test_case/test_manual_case/test_login_demo.py b/test_case/test_manual_case/test_login_demo.py index 47b2c1d..0a0235c 100644 --- a/test_case/test_manual_case/test_login_demo.py +++ b/test_case/test_manual_case/test_login_demo.py @@ -15,27 +15,33 @@ from case_utils.assert_handle import assert_response, assert_sql from loguru import logger from case_utils.request_data_handle import RequestPreDataHandle, RequestHandle, after_request_extract from pytest_html import extras # 往pytest-html报告中填写额外的内容 -from common_utils.func_handle import add_docstring from case_utils.allure_handle import allure_title import allure from config.settings import db_info from config.global_vars import GLOBAL_VARS # 读取用例数据 -cases = YamlHandle(filename=os.path.join(DATA_DIR, "test_login_demo.yaml")).read_yaml +yaml_data = YamlHandle(filename=os.path.join(DATA_DIR, "login_demo.yaml")).read_yaml +case_common = yaml_data["case_common"] +cases = [] +for k, v in yaml_data.items(): + if k != "case_common": + cases.append(v) -@allure.story(f'{cases["case_common"]["allure_story"]}') -@pytest.mark.test_login_demo -@pytest.mark.parametrize("case", cases.get("case_info")) -def test_login_demo(case, extra): - logger.info("-----------------------------START-开始执行用例-----------------------------") - logger.debug(f"当前执行的用例数据:{case}") - # 给当前测试方法添加文档注释 - add_docstring(case.get("title", ""))(test_login_demo) - # 添加用例标题作为allure中显示的用例标题 - allure_title(case.get("title", "")) - if case.get("run", None): +@allure.epic(case_common["allure_epic"]) +@allure.feature(case_common["allure_feature"]) +class TestLoginDemo: + + @allure.story(case_common["allure_story"]) + @pytest.mark.test_login_demo + @pytest.mark.auto + @pytest.mark.parametrize("case", cases, ids=["{}".format(case["title"]) for case in cases]) + def test_login_demo_auto(self, case, extra): + logger.info("-----------------------------START-开始执行用例-----------------------------") + logger.debug(f"当前执行的用例数据:{case}") + # 添加用例标题作为allure中显示的用例标题 + allure_title(case.get("title", "")) # 处理请求前的用例数据 case_data = RequestPreDataHandle(case).request_data_handle() # 将用例数据显示在pytest-html报告中 @@ -50,8 +56,4 @@ def test_login_demo(case, extra): assert_sql(db_info[GLOBAL_VARS["env_key"]], case_data["assert_sql"]) # 断言成功后进行参数提取 after_request_extract(response, case_data.get("extract", None)) - else: - reason = f"标记了该用例为false,不执行\\n" - logger.warning(f"{reason}") - pytest.skip(reason) - logger.info("-----------------------------END-用例执行结束-----------------------------") + logger.info("-----------------------------END-用例执行结束-----------------------------")