├── case_data.xls ├── requirements.txt ├── hooks.py ├── LICENSE ├── config.yaml ├── run.py ├── README.md ├── .gitignore ├── test_main.py ├── recording.py └── core.py /case_data.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zy7y/apiAutoTest/HEAD/case_data.xls -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | allure-pytest==2.9.45 2 | jsonpath==0.82 3 | loguru==0.6.0 4 | paramiko==2.9.2 5 | pytest==7.0.1 6 | PyYAML==6.0 7 | requests==2.27.1 8 | xlrd==2.0.1 9 | xlwt==1.3.0 10 | yagmail==0.15.277 11 | mitmproxy==7.0.4 12 | pymysql==1.0.2 13 | -------------------------------------------------------------------------------- /hooks.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def get_current_highest(): 5 | """获取当前时间戳""" 6 | return int(time.time()) 7 | 8 | 9 | def sum_data(a, b): 10 | """计算函数""" 11 | return a + b 12 | 13 | 14 | def set_token(token: str): 15 | """设置token,直接返回字典""" 16 | return {"Authorization": token} 17 | 18 | 19 | def skip(): 20 | return True 21 | 22 | 23 | def skip_if(user_id): 24 | if user_id > 300: 25 | return True 26 | else: 27 | return False 28 | 29 | 30 | def sql(): 31 | return "select * from sp_goods;" 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 zy7y 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | # 本地接口服务 3 | test: http://127.0.0.1:8888/ 4 | dev: http://127.0.0.1:8888/api/private/v1/ 5 | 6 | # 基准的请求头信息 7 | request_headers: 8 | Accept-Encoding: gzip, deflate 9 | Accept-Language: zh-CN,zh;q=0.9 10 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36 11 | 12 | file_path: 13 | test_case: case_data.xls 14 | report: report/ 15 | log: logs/run{time}.log 16 | 17 | email: 18 | serve: 19 | # 发件人邮箱USER 20 | user: 123456@163.com 21 | # 发件人邮箱授权码 22 | password: xxxx 23 | # 邮箱host 24 | host: smtp.163.com 25 | context: 26 | contents: 解压apiAutoReport.zip(接口测试报告)后,请使用已安装Live Server 插件的VsCode,打开解压目录下的index.html查看报告 27 | # 收件人邮箱 28 | to: ["396667207@qq.com"] 29 | subject: 接口自动化测试报告(见附件) 30 | # 附件 31 | attachments: report.zip 32 | 33 | # 数据库校验- mysql 34 | database: 35 | host: localhost 36 | port: 3306 37 | user: root 38 | # 不用''会被解析成int类型数据 39 | password: '123456' 40 | db: mydb 41 | 42 | # 数据库所在的服务器配置 43 | ssh_server: 44 | port: 22 45 | username: root 46 | password: '123456' 47 | # 私有密钥文件路径 48 | private_key_file: 49 | # 私钥密码 50 | private_password: 51 | # 如果使用的docker容器部署mysql服务,需要传入mysql的容器id/name 52 | mysql_container: mysql8 53 | # 数据库备份文件导出的本地路径, 需要保证存在该文件夹 54 | sql_data_file: backup_sql/ 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | """ 2 | @project: apiAutoTest 3 | @author: zy7y 4 | @file: run.py 5 | @ide: PyCharm 6 | @time: 2022/02/27 7 | @github: https://github.com/zy7y 8 | @desc: 运行文件 9 | """ 10 | 11 | import os 12 | import shutil 13 | from loguru import logger 14 | 15 | from test_main import rfc 16 | from test_main import pytest 17 | 18 | 19 | def run(email: bool = False, web: bool = False): 20 | """ 21 | 启动测试 22 | :param email: 是否发送邮件 23 | :param web: 是否已服务形式打开报告(将忽略邮件服务) 24 | :return: 25 | """ 26 | if os.path.exists("report/"): 27 | shutil.rmtree(path="report/") 28 | 29 | # 解决 issues 句柄无效 30 | logger.remove() 31 | file_path = rfc.get_config("$.file_path").current 32 | logger.add(file_path["log"], enqueue=True, encoding="utf-8") 33 | logger.info( 34 | """ 35 | _ _ _ _____ _ 36 | __ _ _ __ (_) / \\ _ _| |_ __|_ _|__ ___| |_ 37 | / _` | '_ \\| | / _ \\| | | | __/ _ \\| |/ _ \\/ __| __| 38 | | (_| | |_) | |/ ___ \\ |_| | || (_) | | __/\\__ \\ |_ 39 | \\__,_| .__/|_/_/ \\_\\__,_|\\__\\___/|_|\\___||___/\\__| 40 | |_| 41 | Starting ... ... ... 42 | """ 43 | ) 44 | pytest.main(args=[f'--alluredir={file_path["report"]}/data']) 45 | 46 | if web: 47 | # 自动以服务形式打开报告 48 | os.system(f'allure serve {file_path["report"]}/data') 49 | else: 50 | # 本地生成报告 51 | os.system( 52 | f'allure generate {file_path["report"]}/data -o {file_path["report"]}/html --clean' 53 | ) 54 | logger.success("报告已生成") 55 | 56 | if email: 57 | from core import EmailServe 58 | 59 | EmailServe(rfc).serve() 60 | 61 | 62 | if __name__ == "__main__": 63 | run() 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![](https://gitee.com/zy7y/apiAutoTest/badge/star.svg)](https://gitee.com/zy7y/apiAutoTest) 3 | [![](https://gitee.com/zy7y/apiAutoTest/badge/fork.svg)](https://gitee.com/zy7y/apiAutoTest) 4 | [![](https://img.shields.io/github/license/zy7y/apiAutoTest)](https://gitee.com/zy7y/apiAutoTest/blob/master/LICENSE) 5 | [![](https://img.shields.io/github/stars/zy7y/apiAutoTest)](https://github.com/zy7y/apiAutoTest) 6 | [![](https://img.shields.io/github/forks/zy7y/apiAutoTest)](https://github.com/zy7y/apiAutoTest) 7 | [![](https://img.shields.io/github/repo-size/zy7y/apiAutoTest?style=social)](https://github.com/zy7y/apiAutoTest) 8 | 9 | 10 | > 使用Python语言 + Python第三方库 实现的接口自动化测试工具,使用该工具 Python版本 >= 3.8 11 | 12 | [![IsXMnO.png](https://z3.ax1x.com/2021/11/13/IsXMnO.png)](https://imgtu.com/i/IsXMnO) 13 | 14 | ## 配套资源(点击即可跳转) 15 | - [x] [项目使用手册](https://zy7y.github.io/apiAutoTest/) 16 | - [x] [B站视频解析](https://www.bilibili.com/video/BV1jt4y1J7Nw) 17 | - [x] [示例项目接口文档](https://gitee.com/zy7y/apiAutoTest/tree/v1.0/%E9%A1%B9%E7%9B%AE%E5%AE%9E%E6%88%98%E6%8E%A5%E5%8F%A3%E6%96%87%E6%A1%A3.md) 18 | - [x] [Jenkins集成示例](https://www.cnblogs.com/zy7y/p/13448102.html) 19 | ## 实现功能 20 | - 测试数据隔离: 测试前后进行数据库备份/还原 21 | - 接口间数据依赖: 需要B接口使用A接口响应中的某个字段作为参数 22 | - 自定义扩展方法: 在用例中使用自定义方法(如:获取当前时间戳...)的返回值 23 | - 接口录制:录制指定包含url的接口,生成用例数据 24 | - 用例跳过:支持表达式、内置函数、调用变量实现条件跳过用例 25 | - 动态多断言: 可(多个)动态提取实际预期结果与指定的预期结果进行比较断言操作 26 | - 对接数据库: 讲数据库的查询结果可直接用于断言操作 27 | - 邮件发送:将allure报告压缩后已附件形式发送 28 | 29 | ## 所用依赖库 30 | ``` 31 | allure-pytest==2.9.45 # allure报告 32 | jsonpath==0.82 # json解析库 33 | loguru==0.6.0 # 日志库 34 | pytest==7.0.1 # 参数化 35 | PyYAML==6.0 # 读取ymal 36 | requests==2.27.1 # 请求HTTP/HTTPS 37 | xlrd==1.2.0 # 读取excel 38 | yagmail==0.11.224 # 发送邮件 39 | PyMySQL==0.10.1 # 连接mysql数据库 40 | paramiko==2.9.2 # SSH2 连接 41 | xlwt==1.3.0 # 写excel 用例文件 42 | mitmproxy==7.0.4 # 抓包工具 43 | ``` 44 | 45 | ## 联系 46 | QQ群:**`930902996`** 47 | 48 | 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | .dmypy.json 112 | dmypy.json 113 | 114 | # Pyre type checker 115 | .pyre/ 116 | 117 | # pycahrm 118 | .idea 119 | 120 | # pytest 121 | .pytest_cache 122 | logs/ 123 | report/ 124 | backup_sql -------------------------------------------------------------------------------- /test_main.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from core import DataBaseMysql 4 | from core import DataProcess 5 | from core import ReadFileClass 6 | from core import ReportStyle 7 | from core import HttpRequest 8 | 9 | rfc = ReadFileClass("config.yaml") 10 | http_client = HttpRequest() 11 | data_process = DataProcess(rfc) 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def data_clearing(): 16 | """数据清理""" 17 | from core import DataClear 18 | 19 | with DataClear(rfc) as dc: 20 | dc.backup() 21 | yield 22 | dc.recovery() 23 | 24 | 25 | @pytest.fixture(scope="session") 26 | def get_db(): 27 | """数据库对象""" 28 | with DataBaseMysql(rfc) as db: 29 | yield db 30 | 31 | 32 | @pytest.fixture(params=rfc.get_case()) 33 | def case(request): 34 | """用例数据,测试方法参数入参该方法名 cases即可,实现同样的参数化 35 | 目前来看相较于@pytest.mark.parametrize 更简洁。 36 | """ 37 | return request.param 38 | 39 | 40 | @pytest.fixture(scope="session") 41 | def clear_db(data_clearing): 42 | """同时使用数据清洗 和 数据库操作功能""" 43 | with DataBaseMysql(rfc) as db: 44 | yield db 45 | 46 | 47 | # def test_start(case, get_db): # 只使用数据库操作 48 | # def test_start(case, clear_db): # 使用数据库操作 及 数据清洗 49 | def test_start(case): # 不使用数据库操作 50 | """ 51 | 测试启动方法, 当需要使用 数据清洗和 数据库操作命令时将 get_db 换成 clear_db 52 | 如只需要使用 数据库操作 则添加get_db 参数, 如一个都不需要则不添加 53 | :param case: 用例 54 | :return: 55 | """ 56 | # 前置处理 57 | title, skip, header, path, method, data_type, file, data, extra, sql, expect = case 58 | ReportStyle.title(title) 59 | data_process.handle_case(path, header, skip, data, file) 60 | 61 | # 发送请求 62 | http_client.send_request( 63 | data_type, 64 | method, 65 | data_process.path, 66 | data_process.headers, 67 | data_process.body, 68 | data_process.files, 69 | ) 70 | # 后置处理 71 | DataProcess.handle_extra(extra, http_client.response.json()) 72 | 73 | data_process.sql = sql 74 | # data_process.handle_sql(get_db) # 使用数据库 75 | # data_process.handle_sql(clear_db) # 使用数据清洗及 数据库 76 | 77 | # 断言 78 | DataProcess.assert_result(http_client.response.json(), expect) 79 | -------------------------------------------------------------------------------- /recording.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env/ python3 2 | # -*- coding:utf-8 -*- 3 | """ 4 | @Project: apiAutoTest 5 | @File :recording.py 6 | @Author:zy7y 7 | @Date :2021/5/21 22:07 8 | @Desc : 录制接口,生成用例文件 9 | 基于mitmproxy实现,会包含css/html/png等后缀链接 10 | 参考资料: 11 | https://blog.wolfogre.com/posts/usage-of-mitmproxy/ 12 | https://www.cnblogs.com/liuwanqiu/p/10697373.html 13 | """ 14 | 15 | import json 16 | 17 | import mitmproxy.http 18 | import xlwt 19 | 20 | # 上传文件接口不能录入文件参数 , excel单元格限制: Exception: String longer than 32767 characters 21 | from mitmproxy import ctx 22 | 23 | 24 | class Counter: 25 | def __init__(self, filter_url: str, filename: str = "case_data1.xls"): 26 | """ 27 | 基于mitmproxy抓包生成用例数据 28 | :param filter_url: 需要过滤的url 29 | :param filename: 生成用例文件路径 30 | """ 31 | self.url = filter_url 32 | self.excel_row = [ 33 | "用例标题", 34 | "是否执行", 35 | "请求头", 36 | "接口地址", 37 | "请求方式", 38 | "入参关键字", 39 | "上传文件", 40 | "请求数据", 41 | "提取参数", 42 | "后置sql", 43 | "预期结果", 44 | ] 45 | self.cases = [self.excel_row] 46 | self.counter = 1 47 | self.file = filename 48 | 49 | def response(self, flow: mitmproxy.http.HTTPFlow): 50 | """ 51 | mitmproxy抓包处理响应,在这里汇总需要数据, 过滤 包含指定url,并且响应格式是 json的 52 | :param flow: 53 | :return: 54 | """ 55 | if ( 56 | self.url in flow.request.url 57 | and "json" in flow.response.headers["Content-Type"] 58 | ): 59 | # 标题 60 | title = "mitmproxy录制接口" + str(self.counter) 61 | try: 62 | token = flow.request.headers["Authorization"] 63 | except KeyError: 64 | token = "" 65 | header = json.dumps({"Authorization": token}) 66 | data = flow.request.text 67 | # 请求地址,config.yaml 里面基准环境地址 写 空字符串 68 | method = flow.request.method.lower() 69 | url = flow.request.url 70 | try: 71 | content_type = flow.request.headers["Content-Type"] 72 | except KeyError: 73 | content_type = "" 74 | if "form" in content_type: 75 | data_type = "data" 76 | elif "json" in content_type: 77 | data_type = "json" 78 | else: 79 | data_type = "params" 80 | if "?" in url: 81 | data = url.split("?")[1] 82 | data = Counter.handle_form(data) 83 | # 预期结果 84 | try: 85 | expect = json.dumps( 86 | {".": json.loads(flow.response.text)}, ensure_ascii=False 87 | ) 88 | except Exception as e: 89 | ctx.log.error(e) 90 | expect = "{}" 91 | # 日志 92 | ctx.log.info(url) 93 | ctx.log.info(header) 94 | ctx.log.info(content_type) 95 | ctx.log.info(method) 96 | ctx.log.info(data) 97 | ctx.log.info(flow.response.text) 98 | case = [ 99 | title, 100 | "FALSE", 101 | header, 102 | url.split("?")[0], 103 | method, 104 | data_type, 105 | "", 106 | data, 107 | "", 108 | "", 109 | expect, 110 | ] 111 | self.cases.append(case) 112 | self.counter += 1 113 | # 文件末尾追加 114 | self.excel_cases() 115 | 116 | def excel_cases(self): 117 | """ 118 | 对二维列表cases进行循环并将内容写入单元格中 119 | :return: 120 | """ 121 | workbook = xlwt.Workbook() 122 | worksheet = workbook.add_sheet("用例数据") 123 | for x in range(len(self.cases)): 124 | for y in range(len(self.cases[x])): 125 | worksheet.write(x, y, self.cases[x][y]) 126 | try: 127 | workbook.save(self.file) 128 | except Exception as e: 129 | print(e) 130 | 131 | @classmethod 132 | def handle_form(cls, data: str): 133 | """ 134 | 处理 Content-Type: application/x-www-form-urlencoded 135 | 默认生成的数据 username=admin&password=123456 136 | :param data: 获取的data 类似这样 username=admin&password=123456 137 | :return: 138 | """ 139 | data_dict = {} 140 | if data.startswith("{") and data.endswith("}"): 141 | return data 142 | try: 143 | for i in data.split("&"): 144 | data_dict[i.split("=")[0]] = i.split("=")[1] 145 | return json.dumps(data_dict) 146 | except IndexError: 147 | return "" 148 | 149 | 150 | addons = [Counter("https://gitee.com/zy7y/apiAutoTest")] 151 | 152 | """ 153 | 154 | mitmweb -s recording.py 启动 155 | ctrl + C 停止 并生成完整用例 156 | """ 157 | -------------------------------------------------------------------------------- /core.py: -------------------------------------------------------------------------------- 1 | """插件类""" 2 | import json 3 | import os 4 | import re 5 | from copy import deepcopy 6 | from string import Template 7 | from zipfile import ZipFile 8 | from zipfile import ZIP_DEFLATED 9 | from datetime import datetime 10 | from typing import Optional 11 | from typing import Dict 12 | from typing import Any 13 | from typing import Union 14 | from decimal import Decimal 15 | 16 | import allure 17 | import paramiko 18 | import pymysql 19 | import xlrd 20 | import yagmail 21 | import yaml 22 | from jsonpath import jsonpath 23 | from loguru import logger 24 | from requests import Session 25 | from requests import Response 26 | from _pytest.outcomes import Skipped 27 | 28 | from hooks import * 29 | 30 | 31 | class ReadFileClass: 32 | """文件读取类""" 33 | 34 | def __init__(self, path: str, case_expr: str = "$.file_path.test_case"): 35 | self.path = path 36 | self.current: Optional[Union[Dict[str, Any], str]] = None 37 | self._config: Optional[Dict[str, Any]] = None 38 | self.case_expr = case_expr 39 | 40 | @property 41 | def config(self): 42 | if self._config is None: 43 | self.read() 44 | return self._config 45 | 46 | @config.setter 47 | def config(self, value): 48 | self._config = value 49 | 50 | def read(self): 51 | with open(self.path, "r", encoding="utf-8") as file: 52 | self.config = yaml.load(file.read(), Loader=yaml.FullLoader) 53 | 54 | def get_config(self, expr: str): 55 | """获取配置项,传入jsonpath表达式""" 56 | try: 57 | self.current = jsonpath(self.config, expr)[0] 58 | except IndexError: 59 | self.current = jsonpath(self.config, expr) 60 | return self 61 | 62 | def get_case(self): 63 | self.get_config(self.case_expr) 64 | book = xlrd.open_workbook(self.current) 65 | # 读取第一个sheet页 66 | table = book.sheet_by_index(0) 67 | for norw in range(1, table.nrows): 68 | yield table.row_values(norw) 69 | 70 | 71 | class DataBaseMysql: 72 | """mysql 操作类""" 73 | 74 | def __init__(self, config: ReadFileClass): 75 | mysql_conf = config.get_config("$.database").current 76 | self._result = None 77 | if "ssh_server" in mysql_conf.keys(): 78 | del mysql_conf["ssh_server"] 79 | self.con = pymysql.connect( 80 | **mysql_conf, cursorclass=pymysql.cursors.DictCursor, charset="utf8mb4" 81 | ) 82 | 83 | @property 84 | def result(self): 85 | return self._result 86 | 87 | @result.setter 88 | def result(self, value): 89 | try: 90 | json.dumps(value) 91 | except TypeError: 92 | for k, v in value.items(): 93 | if isinstance( 94 | v, 95 | ( 96 | datetime, 97 | Decimal, 98 | ), 99 | ): 100 | value[k] = str(v) 101 | self._result = value 102 | 103 | def __enter__(self): 104 | logger.success("数据库连接成功") 105 | return self 106 | 107 | def __exit__(self, exc_type, exc_val, exc_tb): 108 | logger.success("数据库关闭成功") 109 | self.con.close() 110 | 111 | def execute_sql(self, sql_str: str): 112 | with self.con.cursor() as csr: 113 | csr.execute(sql_str) 114 | self.result = csr.fetchone() 115 | self.con.commit() 116 | logger.debug(f"执行SQL: {sql_str}, {self.result}") 117 | 118 | 119 | class EmailServe: 120 | """邮件服务类""" 121 | 122 | def __init__(self, config: ReadFileClass): 123 | self.email_conf = config.get_config("$.email").current 124 | self.zip_conf = config.get_config("$.file_path.report").current 125 | self.zip_name = "report.zip" 126 | 127 | def email(self): 128 | """邮件服务""" 129 | with yagmail.SMTP(**self.email_conf["serve"]) as yag: 130 | yag.send(**self.email_conf["context"]) 131 | 132 | def zip(self): 133 | """压缩报告""" 134 | with ZipFile(self.zip_name, "w", ZIP_DEFLATED) as zp: 135 | for path, _, filenames in os.walk(self.zip_conf): 136 | # 去掉目标跟路径,只对目标文件夹下边的文件及文件夹进行压缩 137 | fpath = path.replace(self.zip_conf, "") 138 | 139 | for filename in filenames: 140 | zp.write( 141 | os.path.join(path, filename), os.path.join(fpath, filename) 142 | ) 143 | 144 | def serve(self): 145 | logger.info("报告压缩中...") 146 | self.zip() 147 | self.email() 148 | os.remove(self.zip_name) 149 | logger.success("邮件已发送...") 150 | 151 | 152 | class RemoteServe: 153 | """远程服务器""" 154 | 155 | def __init__( 156 | self, 157 | host: str, 158 | port: int = 22, 159 | username: str = "root", 160 | password: str = None, 161 | private_key_file: str = None, 162 | private_password: str = None, 163 | ): 164 | # 进行SSH连接 165 | self.trans = paramiko.Transport((host, port)) 166 | self.host = host 167 | if password is None: 168 | self.trans.connect( 169 | username=username, 170 | pkey=paramiko.RSAKey.from_private_key_file( 171 | private_key_file, private_password 172 | ), 173 | ) 174 | else: 175 | self.trans.connect(username=username, password=password) 176 | # 将sshclient的对象的transport指定为以上的trans 177 | self.ssh = paramiko.SSHClient() 178 | logger.success("SSH客户端创建成功.") 179 | self.ssh._transport = self.trans 180 | # 创建SFTP客户端 181 | self.ftp_client = paramiko.SFTPClient.from_transport(self.trans) 182 | logger.success("SFTP客户端创建成功.") 183 | 184 | def execute_cmd(self, cmd: str): 185 | """ 186 | :param cmd: 服务器下对应的命令 187 | """ 188 | stdin, stdout, stderr = self.ssh.exec_command(cmd) 189 | error = stderr.read().decode() 190 | logger.info(f"输入命令: {cmd} -> 输出结果: {stdout.read().decode()}") 191 | logger.warning(f"异常信息: {error}") 192 | return error 193 | 194 | def files_action( 195 | self, post: bool, local_path: str = os.getcwd(), remote_path: str = "/root" 196 | ): 197 | """ 198 | :param post: 动作 为 True 就是上传, False就是下载 199 | :param local_path: 本地的文件路径, 默认当前脚本所在的工作目录 200 | :param remote_path: 服务器上的文件路径,默认在/root目录下 201 | """ 202 | if post: # 上传文件 203 | self.execute_cmd("mkdir backup_sql") 204 | self.ftp_client.put( 205 | localpath=local_path, 206 | remotepath=f"{remote_path}{os.path.split(local_path)[1]}", 207 | ) 208 | logger.info( 209 | f"文件上传成功: {local_path} -> {self.host}:{remote_path}{os.path.split(local_path)[1]}" 210 | ) 211 | else: # 下载文件 212 | if not os.path.exists(local_path): 213 | os.mkdir(local_path) 214 | file_path = local_path + os.path.split(remote_path)[1] 215 | self.ftp_client.get(remotepath=remote_path, localpath=file_path) 216 | logger.info(f"文件下载成功: {self.host}:{remote_path} -> {file_path}") 217 | 218 | def ssh_close(self): 219 | """关闭连接""" 220 | self.trans.close() 221 | logger.info("已关闭SSH连接...") 222 | 223 | 224 | class DataClear: 225 | """数据隔离实现""" 226 | 227 | def __init__(self, config: ReadFileClass): 228 | self.cfg = config.get_config("$.database").current 229 | self.server = None 230 | # 导出的sql文件名称及后缀 231 | self.file_name = ( 232 | f"{self.cfg.get('db')}_{datetime.now().strftime('%Y-%m-%dT%H_%M_%S')}.sql" 233 | ) 234 | 235 | self.c_name = self.cfg.get("ssh_server").get("mysql_container") 236 | self.mysql_user = self.cfg.get("user") 237 | self.mysql_passwd = self.cfg.get("password") 238 | self.mysql_db = self.cfg.get("db") 239 | 240 | self.local_backup = self.cfg.get("ssh_server").get("sql_data_file") 241 | self.remote_backup = "/root/backup_sql/" 242 | 243 | # mysql 备份命令 244 | self.backup_cmd = f"mysqldump -h127.0.0.1 -u{self.mysql_user} -p{self.mysql_passwd} {self.mysql_db}" 245 | # mysql 还原 246 | self.recovery_cmd = f"mysql -h127.0.0.1 -u{self.mysql_user} -p{self.mysql_passwd} {self.mysql_db}" 247 | 248 | def backup(self): 249 | """备份操作""" 250 | if self.c_name is None: 251 | cmd = f"{self.backup_cmd} > {self.file_name}" 252 | else: 253 | cmd = f"docker exec -i {self.c_name} {self.backup_cmd} > {self.remote_backup}{self.file_name}" 254 | self.server.execute_cmd(cmd) 255 | 256 | self.server.files_action( 257 | 0, f"{self.local_backup}", f"{self.remote_backup}{self.file_name}" 258 | ) 259 | logger.info("备份完成...") 260 | 261 | def recovery(self): 262 | """还原操作""" 263 | result = self.server.execute_cmd(f"ls -l {self.remote_backup}{self.file_name}") 264 | if "No such file or directory" in result: 265 | # 本地上传 266 | self.server.files_action( 267 | 1, f"{self.local_backup}{self.file_name}", self.remote_backup 268 | ) 269 | cmd = f"docker exec -i {self.c_name} {self.recovery_cmd} < {self.remote_backup}{self.file_name}" 270 | self.server.execute_cmd(cmd) 271 | logger.success("成功还原...") 272 | 273 | def __enter__(self): 274 | # 深拷贝 275 | ssh_cfg = deepcopy(self.cfg.get("ssh_server")) 276 | del ssh_cfg["mysql_container"] 277 | del ssh_cfg["sql_data_file"] 278 | self.server = RemoteServe(host=self.cfg.get("host"), **ssh_cfg) 279 | # 新建backup_sql文件夹在服务器上,存放导出的sql文件 280 | self.server.execute_cmd("mkdir backup_sql") 281 | return self 282 | 283 | def __exit__(self, exc_type, exc_val, exc_tb): 284 | self.server.ssh_close() 285 | 286 | 287 | class ReportStyle: 288 | """allure 报告样式""" 289 | 290 | @staticmethod 291 | def step(step: str, var: Optional[Union[str, Dict[str, Any]]] = None): 292 | with allure.step(step): 293 | allure.attach( 294 | json.dumps(var, ensure_ascii=False, indent=4), 295 | "附件内容", 296 | allure.attachment_type.JSON, 297 | ) 298 | 299 | @staticmethod 300 | def title(title: str): 301 | allure.dynamic.title(title) 302 | 303 | 304 | class DataProcess: 305 | """数据依赖实现""" 306 | 307 | extra_pool = {} 308 | 309 | def __init__(self, config: ReadFileClass): 310 | self.config = config 311 | self._headers = None 312 | self._path = None 313 | self._body = None 314 | self._sql = None 315 | self._files = None 316 | self._skip = None 317 | 318 | @property 319 | def skip(self): 320 | return self._skip 321 | 322 | @skip.setter 323 | def skip(self, value): 324 | if isinstance(value, int): 325 | if value: 326 | raise Skipped("跳过用例") 327 | elif eval(self.rep_expr(value).capitalize()): 328 | raise Skipped("跳过用例") 329 | 330 | @property 331 | def headers(self): 332 | return self._headers 333 | 334 | @headers.setter 335 | def headers(self, value): 336 | self._headers = self.config.get_config("$.request_headers").current 337 | if value != "": 338 | self._headers.update(DataProcess.handle_data(value)) 339 | 340 | @property 341 | def path(self): 342 | return self._path 343 | 344 | @path.setter 345 | def path(self, value): 346 | self.config.get_config("$.server.dev") 347 | self._path = f"{self.config.current}{DataProcess.rep_expr(value)}" 348 | 349 | @property 350 | def body(self): 351 | return self._body 352 | 353 | @body.setter 354 | def body(self, value): 355 | if self._body != "": 356 | self._body = DataProcess.handle_data(value) 357 | 358 | @property 359 | def files(self): 360 | return self._files 361 | 362 | @files.setter 363 | def files(self, value): 364 | if value != "": 365 | for k, v in DataProcess.handle_data(value).items(): 366 | # 多文件上传 367 | if isinstance(v, list): 368 | self._files = [(k, (open(path, "rb"))) for path in v] 369 | else: 370 | # 单文件上传 371 | self._files = {k: open(v, "rb")} 372 | else: 373 | self._files = None 374 | 375 | @property 376 | def sql(self): 377 | return self._sql 378 | 379 | @sql.setter 380 | def sql(self, value): 381 | self._sql = DataProcess.rep_expr(value) 382 | 383 | @classmethod 384 | def handle_data(cls, value: str) -> Optional[Dict[str, Any]]: 385 | """处理数据的方法""" 386 | if value == "": 387 | return 388 | try: 389 | return json.loads(DataProcess.rep_expr(value)) 390 | except json.decoder.JSONDecodeError: 391 | return eval(DataProcess.rep_expr(value)) 392 | 393 | @classmethod 394 | def rep_expr(cls, content: str): 395 | content = Template(content).safe_substitute(DataProcess.extra_pool) 396 | for func in re.findall("\\${(.*?)}", content): 397 | try: 398 | content = content.replace("${%s}" % func, DataProcess.exec_func(func)) 399 | except Exception as e: 400 | logger.error(e) 401 | return content 402 | 403 | def handle_case(self, path, header, skip_expr, data, file): 404 | self.skip = skip_expr 405 | self.path = path 406 | self.headers = header 407 | self.body = data 408 | self.files = file 409 | 410 | def handle_sql(self, db_session: DataBaseMysql): 411 | for sql_str in self.sql.split(";"): 412 | sql_str = sql_str.strip() 413 | if sql_str == "": 414 | continue 415 | # 查后置sql 416 | db_session.execute_sql(sql_str) 417 | ReportStyle.step(f"执行sql: {sql_str}", db_session.result) 418 | logger.info(f"执行sql: {sql_str} \n 结果: {db_session.result}") 419 | if db_session.result is not None: 420 | # 将查询结果添加到响应字典里面,作用在,接口响应的内容某个字段 直接和数据库某个字段比对,在预期结果中 421 | # 使用同样的语法提取即可 422 | DataProcess.extra_pool.update(db_session.result) 423 | 424 | @staticmethod 425 | def extractor(obj: dict, expr: str = ".") -> Any: 426 | """ 427 | 根据表达式提取字典中的value,表达式, . 提取字典所有内容, $.case 提取一级字典case, $.case.data 提取case字典下的data 428 | :param obj :json/dict类型数据 429 | :param expr: 表达式, . 提取字典所有内容, $.case 提取一级字典case, $.case.data 提取case字典下的data 430 | $.0.1 提取字典中的第一个列表中的第二个的值 431 | """ 432 | try: 433 | result = jsonpath(obj, expr)[0] 434 | except Exception as e: 435 | logger.error(f"{expr} - 提取不到内容,丢给你一个错误!{e}") 436 | result = expr 437 | return result 438 | 439 | @classmethod 440 | def handle_extra(cls, extra_str: str, response: dict): 441 | """ 442 | 处理提取参数栏 443 | :param extra_str: excel中 提取参数栏内容,需要是 {"参数名": "jsonpath提取式"} 可以有多个 444 | :param response: 当前用例的响应结果字典 445 | """ 446 | if extra_str != "": 447 | extra_dict = json.loads(extra_str) 448 | for k, v in extra_dict.items(): 449 | DataProcess.extra_pool[k] = DataProcess.extractor(response, v) 450 | logger.info(f"加入依赖字典,key: {k}, 对应value: {v}") 451 | 452 | @classmethod 453 | def assert_result(cls, response: dict, expect_str: str): 454 | """预期结果实际结果断言方法 455 | :param response: 实际响应结果 456 | :param expect_str: 预期响应内容,从excel中读取 457 | return None 458 | """ 459 | # 后置sql变量转换 460 | ReportStyle.step("当前可用参数池", DataProcess.extra_pool) 461 | index = 0 462 | for k, v in DataProcess.handle_data(expect_str).items(): 463 | # 获取需要断言的实际结果部分 464 | actual = DataProcess.extractor(response, k) 465 | index += 1 466 | assert_info = {"提取实际结果": k, "实际结果": actual, "预期结果": v, "测试结果": actual == v} 467 | logger.info(f"断言{index}: {assert_info}") 468 | ReportStyle.step(f"断言{index}", assert_info) 469 | assert actual == v 470 | 471 | @staticmethod 472 | def exec_func(func: str) -> str: 473 | """执行函数(exec可以执行Python代码) 474 | :params func 字符的形式调用函数 475 | : return 返回的将是个str类型的结果 476 | """ 477 | # 得到一个局部的变量字典,来修正exec函数中的变量,在其他函数内部使用不到的问题 478 | loc = locals() 479 | exec(f"result = {func}") 480 | return str(loc["result"]) 481 | 482 | 483 | class HttpRequest(Session): 484 | """请求类实现""" 485 | 486 | data_type_list = ["params", "data", "json"] 487 | 488 | def __init__(self): 489 | self._last_response = None 490 | super().__init__() 491 | 492 | @property 493 | def response(self) -> Response: 494 | return self._last_response 495 | 496 | @response.setter 497 | def response(self, value): 498 | self._last_response = value 499 | 500 | def send_request( 501 | self, data_type: str, method, url, header=None, data=None, file=None, **kwargs 502 | ): 503 | if data_type.lower() in HttpRequest.data_type_list: 504 | extra_args = {data_type: data} 505 | else: 506 | raise ValueError("可选关键字为params, json, data") 507 | self.response = self.request( 508 | method=method, url=url, files=file, headers=header, **extra_args, **kwargs 509 | ) 510 | req_info = { 511 | "请求地址": url, 512 | "请求方法": method, 513 | "请求头": header, 514 | "请求数据": data, 515 | "上传文件": str(file), 516 | } 517 | ReportStyle.step("Request Info", req_info) 518 | logger.info(req_info) 519 | rep_info = { 520 | "响应耗时(ms)": self.response.elapsed.total_seconds() * 1000, 521 | "状态码": self.response.status_code, 522 | "响应数据": self.response.json(), 523 | } 524 | logger.info(rep_info) 525 | ReportStyle.step("Response Info", rep_info) 526 | --------------------------------------------------------------------------------