From 476420d3f4f9c86eb86ccbbf1e28a3cfb020bb3a Mon Sep 17 00:00:00 2001 From: floraachy <1622042529@qq.com> Date: Thu, 18 May 2023 08:52:20 +0800 Subject: [PATCH] =?UTF-8?q?(2023-05-13)=201.=20=E5=B0=81=E8=A3=85Python?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=9A=E4=BD=BF=E7=94=A8pymysql+sshtunnel?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87SSH=E9=9A=A7?= =?UTF-8?q?=E9=81=93=E6=96=B9=E5=BC=8F=E9=93=BE=E6=8E=A5mysql=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=202.=20=E8=B0=83=E6=95=B4=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=8C=E6=94=AF=E6=8C=81=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=96=AD=E8=A8=80=E3=80=82=E5=8F=AF=E4=BB=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87assert=5Fsql=E7=94=A8=E4=BE=8B=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- case_utils/assert_handle.py | 83 +++++++--- common_utils/mysql_handle.py | 152 ++++++++++++++++++ config/case_template.txt | 60 ++++--- config/settings.py | 22 +++ .../{test_login.yaml => test_login_demo.yaml} | 7 +- ..._excel.xlsx => test_login_excel_demo.xlsx} | Bin 9994 -> 10033 bytes ...roject.yaml => test_new_project_demo.yaml} | 8 +- test_case/test_manual_case/test_login.py | 47 ------ test_case/test_manual_case/test_login_demo.py | 60 +++++++ 9 files changed, 345 insertions(+), 94 deletions(-) create mode 100644 common_utils/mysql_handle.py rename data/{test_login.yaml => test_login_demo.yaml} (87%) rename data/{test_login_excel.xlsx => test_login_excel_demo.xlsx} (59%) rename data/{test_new_project.yaml => test_new_project_demo.yaml} (68%) delete mode 100644 test_case/test_manual_case/test_login.py create mode 100644 test_case/test_manual_case/test_login_demo.py diff --git a/case_utils/assert_handle.py b/case_utils/assert_handle.py index 47c768d..8c6a92d 100644 --- a/case_utils/assert_handle.py +++ b/case_utils/assert_handle.py @@ -10,11 +10,12 @@ from requests import Response from case_utils.data_handle import json_extractor, re_extract from loguru import logger -from case_utils.requests_handle import response_type -from case_utils.data_handle import eval_data_process +from case_utils.request_data_handle import response_type +from common_utils.mysql_handle import MysqlServer +from config.settings import db_info -def assert_result(response: Response, expected: dict) -> None: +def assert_response(response: Response, expected: dict) -> None: """ 断言方法 :param response: 实际响应对象 :param expected: 预期响应内容,从excel中或者yaml读取、或者手动传入,格式如下: @@ -27,9 +28,9 @@ def assert_result(response: Response, expected: dict) -> None: return None """ if expected is None: - logger.info("当前用例无断言!") + logger.info("当前用例无响应断言!") return - logger.info(f"预期结果:{expected}, {type(expected)}") + logger.debug(f"响应断言预期结果:{expected}, {type(expected)}") index = 0 for k, v in expected.items(): # 获取需要断言的实际结果部分 @@ -37,7 +38,6 @@ def assert_result(response: Response, expected: dict) -> None: if _k == "http_code": actual = response.status_code else: - logger.debug("根据响应类型的不同,从响应数据中提取实际结果") if response_type(response) == "json": # 如果响应数据是json格式 actual = json_extractor(response.json(), _k) @@ -45,27 +45,70 @@ def assert_result(response: Response, expected: dict) -> None: # 响应数据不是json格式 actual = re_extract(response.text, _k) index += 1 - # 对预期结果进行数据处理 - _v = eval_data_process(_v) - logger.info(f'第{index}个断言 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual},{type(actual)}') + logger.info(f'第{index}个响应断言 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual}, {type(actual)}') try: if k == "eq": # 预期结果 = 实际结果 assert _v == actual - logger.info("断言成功!") + logger.info(f"预期结果: {_v} == 实际结果: {actual}, 断言成功!") elif k == "in": # 实际结果 包含 预期结果 assert _v in actual - logger.info("断言成功!") + logger.info(f"预期结果: {_v} in 实际结果: {actual}, 断言成功!") elif k == "gt": # 预期结果 > 实际结果 (值应该为数值型) - assert actual < _v - logger.info("断言成功!") + assert _v > actual + logger.info(f"预期结果: {_v} > 实际结果: {actual}, 断言成功!") elif k == "lt": # 预期结果 < 实际结果 (值应该为数值型) - assert actual > _v - logger.info("断言成功!") + assert _v < actual + logger.info(f"预期结果: {_v} < 实际结果: {actual}, 断言成功!") elif k == "not": # 预期结果 != 实际结果 - assert actual != _v - logger.info("断言成功!") + assert _v != actual + logger.info(f"预期结果: {_v} != 实际结果: {actual}, 断言成功!") else: - logger.error(f"判断关键字: {k} 错误!") + logger.error(f"判断关键字: {k} 错误!, 目前仅支持如下关键字:eq, in, gt, lt, not") except AssertionError: - logger.error("断言失败") - raise AssertionError(f"第{index}个断言 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual},{type(actual)}") + logger.error(f"第{index}个响应断言失败 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual}, {type(actual)}") + raise AssertionError( + f"第{index}个响应断言失败 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual}, {type(actual)}") + + +def assert_sql(env, expected: dict): + """ + 数据库断言 + :param env: 当前所处环境 + :param expected: 预期结果,从excel中或者yaml读取、或者手动传入,格式如下: + { + 'eq': + {'sql': 'select count(*) from users where user_id=1;', 'len': '1'}, + 'eq': + {'sql': 'select * from users where user_id=1;', '$.username': '${username}'}, + } + """ + if expected is None: + logger.info("当前用例无数据库断言!") + return + try: + # 拿不到数据库配置,则不进行数据库断言 + db = db_info["test" if env.lower() == "test" else "live"] + db_host = db["db_host"] + except KeyError: + logger.error("当前环境无数据库配置,跳过数据库断言!") + return + for k, v in expected.items(): + sql_result = None + for _k, _v in v.items(): + if _k == "sql": + # 查询数据库,获取查询结果 + sql_result = MysqlServer(**db).query_one(_v) + logger.info(f'数据库响应断言 -|- SQL:{_v} || 查询结果:{sql_result}') + try: + if k == "eq": # 预期结果 = 实际结果 + if _k == "len": + assert _v == len(sql_result) + logger.info(f"预期结果: {_v} == 实际结果: {len(sql_result)}, 断言成功!") + # 如果时$.开头,则从数据库查询结果中提取相应的值作为实际结果 + elif _k.startswith("$."): + actual = json_extractor(sql_result, _k) + assert _v == actual + logger.info(f"预期结果: {_v} == 实际结果: {actual}, 断言成功!") + except AssertionError: + logger.error(f"数据库断言失败 -|- 预期结果:{_v} == 实际结果: {len(sql_result)}") + raise AssertionError(f"数据库断言失败 -|-预期结果: {_v} == 实际结果: {len(sql_result)}") diff --git a/common_utils/mysql_handle.py b/common_utils/mysql_handle.py new file mode 100644 index 0000000..c36f590 --- /dev/null +++ b/common_utils/mysql_handle.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# @Time : 2023/5/12 11:04 +# @Author : chenyinhua +# @File : mysql_handle.py +# @Software: PyCharm +# @Desc: 使用pymysql模块连接mysql数据库的公共方法 +import pymysql +from typing import Union +import json +from datetime import datetime +from sshtunnel import SSHTunnelForwarder +from loguru import logger + + +class MysqlServer: + """ + 初始化数据库连接(支持通过SSH隧道的方式连接),并指定查询的结果集以字典形式返回 + """ + + def __init__(self, db_host, db_port, db_user, db_pwd, db_database, ssh=False, + **kwargs): + """ + 初始化方法中, 连接mysql数据库, 根据ssh参数决定是否走SSH隧道方式连接mysql数据库 + """ + self.server = None + if ssh: + self.server = SSHTunnelForwarder( + ssh_address_or_host=(kwargs.get("ssh_host"), kwargs.get("ssh_port")), # ssh 目标服务器 ip 和 port + ssh_username=kwargs.get("ssh_user"), # ssh 目标服务器用户名 + ssh_password=kwargs.get("ssh_pwd"), # ssh 目标服务器用户密码 + remote_bind_address=(db_host, db_port), # mysql 服务ip 和 part + local_bind_address=('127.0.0.1', 5143), # ssh 目标服务器的用于连接 mysql 或 redis 的端口,该 ip 必须为 127.0.0.1 + ) + self.server.start() + db_host = self.server.local_bind_host # server.local_bind_host 是 参数 local_bind_address 的 ip + db_port = self.server.local_bind_port # server.local_bind_port 是 参数 local_bind_address 的 port + # 建立连接 + self.conn = pymysql.connect(host=db_host, + port=db_port, + user=db_user, + password=db_pwd, + database=db_database, + charset="utf8", + cursorclass=pymysql.cursors.DictCursor # 加上pymysql.cursors.DictCursor这个返回的就是字典 + ) + # 创建一个游标对象 + self.cursor = self.conn.cursor() + + def query_all(self, sql): + """ + 查询所有符合sql条件的数据 + :param sql: 执行的sql + :return: 查询结果 + """ + try: + self.conn.commit() + self.cursor.execute(sql) + data = self.cursor.fetchall() + # 关闭数据库链接和隧道 + self.close() + return self.verify(data) + except Exception as e: + logger.error(f"查询所有符合sql条件的数据报错: {e}") + raise e + + def query_one(self, sql): + """ + 查询符合sql条件的数据的第一条数据 + :param sql: 执行的sql + :return: 返回查询结果的第一条数据 + """ + try: + self.conn.commit() + self.cursor.execute(sql) + data = self.cursor.fetchone() + # 关闭数据库链接和隧道 + self.close() + return self.verify(data) + except Exception as e: + logger.error(f"{sql} --> 报错: {e}") + raise e + + def insert(self, sql): + """ + 插入数据 + :param sql: 执行的sql + """ + try: + self.cursor.execute(sql) + # 提交 只要数据库更新就要commit + self.conn.commit() + # 关闭数据库链接和隧道 + self.close() + except Exception as e: + logger.error(f"{sql} --> 报错: {e}") + raise e + + def update(self, sql): + """ + 更新数据 + :param sql: 执行的sql + """ + try: + self.cursor.execute(sql) + # 提交 只要数据库更新就要commit + self.conn.commit() + # 关闭数据库链接和隧道 + self.close() + except Exception as e: + logger.error(f"{sql} --> 报错: {e}") + raise e + + def query(self, sql, one=True): + """ + 根据传值决定查询一条数据还是所有 + :param sql: 查询的SQL语句 + :param one: 默认True. True查一条数据,否则查所有 + :return: + """ + try: + if one: + return self.query_one(sql) + else: + return self.query_all(sql) + except Exception as e: + logger.error(f"{sql} --> 报错: {e}") + raise e + + def close(self): + """ + 断开游标,关闭数据库 + 如果开启了SSH隧道,也关闭 + :return: + """ + # 关闭游标 + self.cursor.close() + # 关闭数据库链接 + self.conn.close() + # 如果开启了SSH隧道,则关闭 + if self.server: + self.server.close() + + def verify(self, result: dict) -> Union[dict, None]: + """验证结果能否被json.dumps序列化""" + # 尝试变成字符串,解决datetime 无法被json 序列化问题 + try: + json.dumps(result) + except TypeError: # TypeError: Object of type datetime is not JSON serializable + for k, v in result.items(): + if isinstance(v, datetime): + result[k] = str(v) + return result diff --git a/config/case_template.txt b/config/case_template.txt index b9e1417..9f3bf28 100644 --- a/config/case_template.txt +++ b/config/case_template.txt @@ -1,8 +1,8 @@ import pytest -from case_utils.assert_handle import assert_result -from case_utils.requests_handle import BaseRequest +from case_utils.assert_handle import assert_response, assert_sql +from common_utils.base_request import BaseRequest from loguru import logger -from case_utils.request_data_handle import RequestPreDataHandle +from case_utils.request_data_handle import RequestPreDataHandle, after_extract, case_data_replace,eval_data_process from pytest_html import extras # 往pytest-html报告中填写额外的内容 from common_utils.func_handle import add_docstring @@ -16,28 +16,38 @@ def case(request): @pytest.mark.${func_title} @pytest.mark.auto -def ${func_title}_auto(case, extra): - """ - - """ +def ${func_title}_auto(case, extra, request): logger.info("-----------------------------START-开始执行用例-----------------------------") logger.debug(f"当前执行的用例数据:{case}") - # 给当前测试方法添加文档注释 - add_docstring(case.get("title", ""))(${func_title}_auto) - if case.get("run", None): - # 获取处理完成的用例数据 - case_data = RequestPreDataHandle(case).request_data_handle() - # 将用例数据显示在pytest-html报告中 - extra.append(extras.text(str(case_data), name="用例数据")) - # 发送请求 - response = BaseRequest.send_request(case_data) - # 将响应数据显示在pytest-html报告中 - extra.append(extras.text(str(response.text), name="响应数据")) - # 进行断言 - assert_result(response, case_data["expected"]) - else: - reason = f"标记了该用例为false,不执行\\n" - logger.warning(f"{reason}") - pytest.skip(reason) - logger.info("------------------------------------------用例执行结束------------------------------------------") + try: + # 获取命令行参数,判断当前处于哪个环境 + env = request.config.getoption("--env") + # 给当前测试方法添加文档注释 + add_docstring(case.get("title", ""))(${func_title}_auto) + if case.get("run", None): + # 处理请求前的用例数据 + case_data = RequestPreDataHandle(case).request_data_handle() + # 将用例数据显示在pytest-html报告中 + extra.append(extras.text(str(case_data), name="用例数据")) + # 发送请求 + response = BaseRequest.send_request(case_data) + # 将响应数据显示在pytest-html报告中 + extra.append(extras.text(str(response.text), name="响应数据")) + # 请求后,提取后置参数作为全局变量 + after_extract(response, case_data["extract"]) + # 从全局变量中获取最新值,替换数据库断言中的参数 + case_data["assert_sql"] = eval_data_process(case_data_replace(case_data["assert_sql"])) + # 进行响应断言 + assert_response(response, case_data["assert_response"]) + # 进行数据库断言 + assert_sql(env, case_data["assert_sql"]) + else: + reason = f"标记了该用例为false,不执行\\n" + logger.warning(f"{reason}") + pytest.skip(reason) + except Exception as e: + logger.error(f"用例执行过程中报错:{e}") + raise e + finally: + logger.info("-----------------------------END-用例执行结束-----------------------------") diff --git a/config/settings.py b/config/settings.py index 6fa4ae2..e0a6e12 100644 --- a/config/settings.py +++ b/config/settings.py @@ -68,4 +68,26 @@ ding_talk = { # ------------------------------------ 企业微信相关配置 ----------------------------------------------------# wechat = { "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=********", +} + +# ------------------------------------ 数据库相关配置 ----------------------------------------------------# +db_info = { + "test": { + "db_host": "xx.xx.xx.xx", + "db_port": 3306, + "db_user": "root", + "db_pwd": "**********", + "db_database": "test**********", + "ssh": True, + "ssh_host": "xx.xx.xx.xx", + "ssh_port": 3306, + "ssh_user": "root", + "ssh_pwd": "**********" + + }, + "live": { + + + } + } \ No newline at end of file diff --git a/data/test_login.yaml b/data/test_login_demo.yaml similarity index 87% rename from data/test_login.yaml rename to data/test_login_demo.yaml index 77d0c5c..cb8b5a9 100644 --- a/data/test_login.yaml +++ b/data/test_login_demo.yaml @@ -24,6 +24,10 @@ $.user_id: 84953 not: $.user_id: 85390 + assert_sql: + eq: + sql: select count(*) from tokens where user_id=${user_id}; + len: 1 - feature: 登录 title: 用户名正确,密码错误,登录失败 url: /api/accounts/login.json @@ -38,4 +42,5 @@ assert_response: eq: http_code: 200 - $.status: -2 \ No newline at end of file + $.status: -2 + assert_sql: \ No newline at end of file diff --git a/data/test_login_excel.xlsx b/data/test_login_excel_demo.xlsx similarity index 59% rename from data/test_login_excel.xlsx rename to data/test_login_excel_demo.xlsx index f29e5a8754609f160daa45a7aace1a17b6b2b46b..b6613f4c31185c149c5118c76ab8b1a3bbe0d3f4 100644 GIT binary patch delta 3077 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 DbN`|k delta 3099 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