diff --git a/Pipfile b/Pipfile index 10d3a87..847f07a 100644 --- a/Pipfile +++ b/Pipfile @@ -1,22 +1,24 @@ [[source]] url = "https://pypi.tuna.tsinghua.edu.cn/simple" -verify_ssl = true -name = "pypi" +verify_ssl = false +name = "pip_conf_index_global" [packages] -jsonpath = "==0.82" -openpyxl = "==3.0.9" -pytest = "==6.2.5" -pyyaml = "==6.0" -requests = "==2.26.0" -loguru = "*" -click = "*" -pytest-rerunfailures = "*" -pytest-html = "==2.1.1" -faker = "*" -deepdiff = "*" pymysql = "*" +loguru = "*" +requests-toolbelt = "*" +beautifulsoup4 = "*" +requests = "*" +openpyxl = "*" +sshtunnel = "*" yagmail = "*" +pyyaml = "*" +click = "*" +faker = "*" +jsonpath = "*" +pytest = "==6.2.5" +pytest-html = "==2.1.1" +pytest-rerunfailures = "*" [dev-packages] diff --git a/README.md b/README.md index cf195e2..3ac6e7b 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,123 @@ -# python + request + pytest+ pytest-html集成的API自动化测试框架 -**对于框架任何问题,欢迎联系我!** +## 一、框架介绍 -#### 一、框架功能说明 +本框架主要是基于 Python + pytest + pytest-html + loguru + 邮件通知/企业微信通知/钉钉通知 实现的接口自动化框架。 -**解决痛点:** +* git地址: [https://www.gitlink.org.cn/floraachy/apiautotest](https://www.gitlink.org.cn/floraachy/apiautotest) +* 项目参与者: floraachy +* 技术支持邮箱: 1622042529@qq.com +* 个人博客地址: [https://blog.csdn.net/FloraCHY](https://blog.csdn.net/FloraCHY) -1. 通过**session会话方式**,解决了登录之后**cookie关联**处理 +对于框架任何问题,欢迎联系我! +## 二、前言 + + 公司突然要求你做自动化,但是没有代码基础不知道怎么做?或者有自动化基础,但是不知道如何系统性的做自动化, 放在yaml文件中维护,不知道如何处理多业务依赖的逻辑? + + 那么本自动化框架,将为你解决这些问题。 + - 框架主要使用 python 语言编写,结合 pytest 进行二次开发,用户仅需要在 yaml 或者 excel 文件中编写测试用例, 编写成功之后,会自动生成测试用例代码,零基础代码小白,也可以操作。 + - 如果是具备代码基础的,也可以直接通过 py 文件编写测试用例。 + - 使用 pytest-html 生成报告,并针对测试报告样式进行了调整,使得报告更加美观; + - 测试完成后,支持发送 企业微信通知/ 钉钉通知/ 邮箱通知,灵活配置。 + +## 三、实现功能 + +* 通过session会话方式,解决了登录之后cookie关联处理 +* 框架天然支持接口动态传参、关联灵活处理 +* 测试数据隔离, 实现数据驱动 +* 自动生成用例代码: 测试人员在yaml/excel文件中填写好测试用例, 程序可以直接生成用例代码,纯小白也能使用 +* 动态多断言: 支持响应断言和数据库断言 +* 日志模块: 采用loguru管理日志,可以输出更为优雅,简洁的日志 +* 钉钉、企业微信通知: 支持多种通知场景,执行成功之后,可选择发送钉钉、或者企业微信、邮箱通知 +* 执行环境一键切换,解决多环境相互影响问题 +* 使用pipenv管理虚拟环境和依赖文件,提供了一系列命令和选项来帮助你实现各种依赖和环境管理相关的操作。 + + +## 四、目录结构 ``` -模块: case_utils.requests_handle -相关代码:cls.session = requests.Session() +├────case_utils/ 测试框架相关工具类 +│ ├────__init__.py +│ ├────assert_handle.py 断言处理, 包括响应断言和数据库断言 +│ ├────case_handle.py 根据配置文件,从指定类型文件中读取用例数据,并调用生成用例文件方法,生成用例文件 +│ ├────data_handle.py 数据处理 +│ ├────request_data_handle.py 针对用例数据进行请求前后的处理 +│ └────send_result_handle.py 根据配置文件,从html测试报告中获取测试结果,发送指定类型的通知 +├────common_utils/ 公共的工具类 +│ ├────__init__.py +│ ├────base_request.py 封装的requests请求 +│ ├────bs4_handle.py bs4(BeautifulSoup4)是Python中的第三方库。可以从HTML或XML文件中提取数据的Python库。 +│ ├────dingding_handle.py 封装的钉钉机器人 +│ ├────excel_handle.py 处理excel +│ ├────files_handle.py 处理文件相关操作 +│ ├────func_handle.py 函数装饰器 +│ ├────webchat_handle.py 封装企业微信机器人 +│ ├────yagmail_handle.py 封装通过yagmail发送邮件的方法 +│ └────yaml_handle.py 处理yaml文件 +├────config/ +│ ├────__init__.py +│ ├────case_template.txt 自动生成的测试用例文件模板 +│ ├────global_vars.py 保存的一些全局变量 +│ ├────project_path.py 项目路径管理 +│ ├────report.css 优化html测试报告的样式文件 +│ └────settings.py 配置文件 +├────conftest.py +├────data/ 测试用例数据 +│ ├────test_login_demo.yaml +│ ├────test_login_excel_demo.xlsx +│ └────test_new_project_demo.yaml +├────outputs/ +│ └────report/ 保存测试报告的目录 +│ └────log/ 保存日志文件的目录 +├────Pipfile +├────pytest.ini +├────README.md +├────run.py 运行入口 +└────test_case/ 测试用例 +│ ├────conftest.py +│ ├────test_auto_case/ +│ │ ├────test_login_demo.py +│ │ ├────test_login_excel_demo.py +│ │ └────test_new_project_demo.py +│ └────test_manual_case/ +│ │ ├────__init__.py +│ │ ├────test_demo.py +│ │ └────test_login_demo.py + ``` + +## 五、依赖库 +``` +python_version = "3.9" +pymysql = "*" +loguru = "*" +requests-toolbelt = "*" +beautifulsoup4 = "*" +requests = "*" +openpyxl = "*" +sshtunnel = "*" +yagmail = "*" +pyyaml = "*" +click = "*" +pytest-html = "==2.1.1" +faker = "*" +pytest-rerunfailures = "*" +jsonpath = "*" +pytest = "==6.2.5" ``` -2. 框架天然支持接口**动态传参、关联**灵活处理 +## 六、安装教程 + +1. 通过Git工具clone代码到本地 或者 直接下载压缩包ZIP +2. 本地电脑搭建好 python环境,我使用的python版本是3.9 +3. 安装pipenv: pip install pipenv(必须在项目根目录下) +4. 使用pipenv管理安装环境依赖包:pipenv shell (必须在项目根目录下执行) ``` -1) 通过全局变量替换用例数据值 -用例数据中,存在如下格式 data: { "user_id": "${user_id}"} -全局变量GLOBAL_VAR中存在user_id=84522,通过替换后,最终变为:{ "user_id": 84522} - -2) 通过执行函数替换用例数据值 -用例数据中,存在如下格式 "repository_name": ${faker.name().replace(" ", "").replace(".", "")}。 -通过代码替换后,最终变为:"repository_name": "Mike" - + 注意:使用pipenv install会自动安装Pipfile里面的依赖包,该依赖包仅安装在虚拟环境里,不安装在测试机。 ``` - -3. 支持**Excel、Yaml文件**格式编写接口用例,通过简单配置框架自动读取并执行 - -``` -通读取配置文件 config.settings.py中的CASE_FILE_TYPE决定是运行excel/yaml用例,也可都读取 -``` - -4. 执行环境**一键切换**,解决**多环境**相互影响问题 - -``` -1) 通过pytest_addoption将命令行参数--env添加到pytest配置对象中 -2) 通过get_config去配置文件config.settings.py中读取不同环境的配置信息,包括域名,测试账号 -3) 在主运行文件run.py中通过click模块,读取输入的-env的值 -4) 最后在运行时输入 python run.py -env=test可以指定运行的环境 -``` - -5. 支持**http/https协议各种请求、传参类型**接口 - - -6. 响应数据格式支持**json、str类型**的提取操作 - -``` -通过request_handle.after_extract根据响应数据进行提取 -``` - -7. 断言方式支持**等于、包含、大于、小于、不等于**等方法 - -``` -用例数据中通过字段validate进行断言。通过断言的关键字eq等,决定断言是等于、包含、大于、小于、不等于。 -具体断言逻辑见case_utils.assert_util -``` - -8. 框架可以直接交给**不懂代码的功能测试人员使用**,只需要安装规范编写接口用例就行 - -``` -只需要按照yaml或者excel格式正确编写测试用例,即可自动生成测试用例文件,运行测试用例 -``` - -9. 框架也可以交给**懂代码的功能测试人员使用**,可以在test_case目录下通过python编写脚本 - -``` -脚本的编写规范符合pytest要求即可。 -注意:框架默认在pytest.ini中配置了只运行test_auto_case目录下的用例,如果需要运行其他符合pytest要求的用例,需要注释掉该配置:testpaths = ./test_auto_case -``` - -10. 采用luguru管理日志,可以输出更为优雅,简洁的日志 - -#### 二、框架使用说明 - -1. 拉取代码到本地 - -2. 使用pipenv管理安装环境。 - -``` -python版本要求:3.9.5 -安装pipenv: pip install pipenv(必须在项目根目录下) -创建虚拟环境:pipenv install (必须在项目根目录下执行) -激活已存在的虚拟环境(如果不存在会创建一个):pipenv shell (必须在项目根目录下执行) -查看项目虚拟环境路径: pipenv --venv -退出虚拟环境:exit - -注意:使用pipenv install会自动安装Pipfile里面的依赖包,该依赖包仅安装在虚拟环境里,不安装在测试机。 - -注意检查一下pip的安装源(位置:Pipfile) -以下安装源均可: -pip默认的镜像地址是:https://pypi.org/simple -清华大学:https://pypi.tuna.tsinghua.edu.cn/simple 清华大学的pip源是官网pypi的镜像,每隔5分钟同步一次,重点推荐!!! - -阿里云:http://mirrors.aliyun.com/pypi/simple/ - -中国科技大学 https://pypi.mirrors.ustc.edu.cn/simple/ - -华中理工大学:http://pypi.hustunique.com/ - -山东理工大学:http://pypi.sdutlinux.org/ - -豆瓣:http://pypi.douban.com/simple/ -``` - -3. 更改配置文件config.settings.py,修改用例文件读取来源CASE_FILE_TYPE,以及配置test和live环境及测试账号 - -4. 在data目录下新建测试用例数据文件,编写测试用例 (Excel或者Yaml)或者在test_case目录下通过python语言编写用例 - -5. 框架主入口为 run.py文件 +6. 激活已存在的虚拟环境(如果不存在会创建一个):pipenv shell (必须在项目根目录下执行) +7. 更改配置文件config.settings.py,适配你需要测试的环境 +8. 在data目录下新建测试用例数据文件,编写测试用例 (Excel或者Yaml)或者在test_case目录下通过python语言编写用例 +9. 框架主入口为 run.py文件 ``` 必须在项目根目录下,输入命令运行(如果依赖包是安装在虚拟环境中,需要先启动虚拟环境)。 注意:本机环境中没有安装依赖包的情况下,不要直接在run.py中右键直接run @@ -117,18 +126,19 @@ pip默认的镜像地址是:https://pypi.org/simple > python run.py -env=test 在test环境运行测试用例 ``` -#### 三、框架使用过程中遇到的问题 -##### 测试机安装的是python3.7,但是本框架要求3.9.5,怎么办? +### 初始化项目可能遇到的问题 +#### 1. 测试机安装的是python3.7,但是本框架要求3.9.5,怎么办? 方法一:建议采纳此方法 1)首先在项目根目录下打开命令窗口,移除虚拟环境:pipenv --rm 2)安装虚拟环境时忽略锁定的版本号,同时安装依赖包:pipenv install --skip-lock 如果使用上述命令报错:Warning: Python 3.9 was not found on your system... Neither 'pyenv' nor 'asdf' could be found to install Python. -请使用如下命令:pipenv install --pyhon 3.7 --skip-lock (注意:这里的版本号,如果你的是3.8,就应该如下写命令:pipenv install --python 3.8 --skip-lock) +请使用如下命令:pipenv install --python 3.7 --skip-lock (注意:这里的版本号,如果你的是3.8,就应该如下写命令:pipenv install --python 3.8 --skip-lock) 3)激活虚拟环境:pipenv shell 4)运行框架:python run.py +
方法二: 1)首先在项目根目录下打开命令窗口,移除虚拟环境:pipenv --rm @@ -149,4 +159,22 @@ python_version = "3.7" 5)激活虚拟环境:pipenv shell -6)运行框架:python run.py \ No newline at end of file +6)运行框架:python run.py + +#### 2. 无法安装依赖包或者安装很慢,怎么办? +检查一下Pipfile文件中的pip的安装源(位置:Pipfile) +以下安装源均可: +``` +pip默认的镜像地址是:https://pypi.org/simple +清华大学:https://pypi.tuna.tsinghua.edu.cn/simple 清华大学的pip源是官网pypi的镜像,每隔5分钟同步一次,重点推荐!!! + +阿里云:http://mirrors.aliyun.com/pypi/simple/ + +中国科技大学 https://pypi.mirrors.ustc.edu.cn/simple/ + +华中理工大学:http://pypi.hustunique.com/ + +山东理工大学:http://pypi.sdutlinux.org/ + +豆瓣:http://pypi.douban.com/simple/ +``` \ No newline at end of file diff --git a/case_utils/assert_handle.py b/case_utils/assert_handle.py index 8c6a92d..b2d794f 100644 --- a/case_utils/assert_handle.py +++ b/case_utils/assert_handle.py @@ -96,9 +96,13 @@ def assert_sql(env, expected: dict): 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: + # 查询数据库,获取查询结果 + sql_result = MysqlServer(**db).query_one(_v) + logger.info(f'数据库响应断言 -|- SQL:{_v} || 查询结果:{sql_result}') + except Exception as e: + logger.error(f'数据库服务报错:{e}') + raise AssertionError(f"数据库服务报错:{e}") try: if k == "eq": # 预期结果 = 实际结果 if _k == "len": diff --git a/case_utils/request_data_handle.py b/case_utils/request_data_handle.py index 92e1ed4..5957135 100644 --- a/case_utils/request_data_handle.py +++ b/case_utils/request_data_handle.py @@ -29,8 +29,10 @@ class RequestPreDataHandle: 针对用例数据进行处理,识别用例数据中的关键字${xxxx},使用全局变量进行替换或者执行关键字中的方法替换为具体值 """ self.url_handle() + self.method_handle() self.headers_handle() self.payload_handle() + self.files_handle() self.extract_handle() self.assert_handle() return self.request_data @@ -72,6 +74,10 @@ class RequestPreDataHandle: logger.info( f"-----End-----处理完成后的headers: {type(self.request_data['headers'])} || {self.request_data['headers']}") + def method_handle(self): + # 从用例数据中获取method, 处理method + logger.info(f"请求头:{self.request_data.get('method')}") + def payload_handle(self): # 处理请求参数payload logger.info( @@ -81,6 +87,12 @@ class RequestPreDataHandle: logger.info( f"-----End-----处理完成后的payload: {type(self.request_data['payload'])} || {self.request_data['payload']}") + def files_handle(self): + # 处理文件 + logger.info( + f"-----Start-----处理前的files: {type(self.request_data.get('files', None))} || {self.request_data.get('files', None)}") + # TODO 暂时还没想好怎么处理 + def extract_handle(self): # 处理后置提取参 logger.info( diff --git a/common_utils/base_request.py b/common_utils/base_request.py index 9617f30..3f7ef8d 100644 --- a/common_utils/base_request.py +++ b/common_utils/base_request.py @@ -1,6 +1,6 @@ import requests from requests import Response -from requests_toolbelt import MultipartEncoder +from requests_toolbelt import MultipartEncoder # 第三方模块:pip install requests_toolbelt from typing import Dict, Union import time from loguru import logger diff --git a/common_utils/bs4_handle.py b/common_utils/bs4_handle.py index 43ced28..30a5ab8 100644 --- a/common_utils/bs4_handle.py +++ b/common_utils/bs4_handle.py @@ -6,7 +6,7 @@ # @Desc: bs4(BeautifulSoup4)是Python中的第三方库。可以从HTML或XML文件中提取数据的Python库。 -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup # pip install beautifulsoup4 class SoupAPI: diff --git a/common_utils/handle_eval_data.py b/common_utils/handle_eval_data.py deleted file mode 100644 index 5ec69f4..0000000 --- a/common_utils/handle_eval_data.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -# @Version: Python 3.9 -# @Time : 2023/1/9 16:41 -# @Author : chenyinhua -# @File : handle_eval_data.py -# @Software: PyCharm -# @Desc: 将内容eval一下 - -def eval_data(data): - """ - 执行一个字符串表达式,并返回其表达式的值 - """ - try: - if hasattr(eval(data), "__call__"): - return data - else: - return eval(data) - except Exception: - return data - - -def eval_data_process(data): - """ - 将数据中的字符串表达式处理后更新其值为表达式 - """ - # 如果目标数据是字符串,直接尝试eval - if isinstance(data, str): - return eval_data(data) - # 如果目标数据是列表,遍历列表的每一个数据,再用递归的方法处理每一个item - elif isinstance(data, list): - for index, item in enumerate(data): - data[index] = eval_data_process(eval_data(item)) - # 如果目标数据是字典,遍历字典的每一个值,再用递归的方法处理每一个value - elif isinstance(data, dict): - for key, value in data.items(): - data[key] = eval_data_process(eval_data(value)) - return data diff --git a/common_utils/mysql_handle.py b/common_utils/mysql_handle.py index c36f590..9ddc053 100644 --- a/common_utils/mysql_handle.py +++ b/common_utils/mysql_handle.py @@ -8,7 +8,7 @@ import pymysql from typing import Union import json from datetime import datetime -from sshtunnel import SSHTunnelForwarder +from sshtunnel import SSHTunnelForwarder # pip install sshtunnel from loguru import logger @@ -23,28 +23,31 @@ class MysqlServer: 初始化方法中, 连接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() + try: + 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() + except Exception as e: + logger.error(f"数据库连接失败:{e}") def query_all(self, sql): """ diff --git a/common_utils/webchat_handle.py b/common_utils/webchat_handle.py index 8a126cd..9355c82 100644 --- a/common_utils/webchat_handle.py +++ b/common_utils/webchat_handle.py @@ -5,7 +5,6 @@ # @Software: PyCharm # @Desc: 企业微信机器人 import os - from requests import request from loguru import logger import base64 diff --git a/common_utils/yaml_handle.py b/common_utils/yaml_handle.py index 8d081c2..502e63c 100644 --- a/common_utils/yaml_handle.py +++ b/common_utils/yaml_handle.py @@ -14,7 +14,7 @@ class HandleYaml: def __init__(self, filename): """ 初始化用例文件 - :param filename: 文件绝对路径,如:D:\test\test.xlsx + :param filename: 文件绝对路径,如:D:\test\test.yaml """ self.filename = filename diff --git a/test_case/test_manual_case/test_demo.py b/test_case/test_manual_case/test_demo.py new file mode 100644 index 0000000..bbb46a5 --- /dev/null +++ b/test_case/test_manual_case/test_demo.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# @Version: Python 3.9 +# @Time : 2023/1/9 16:41 +# @Author : chenyinhua +# @File : test_login_demo.py +# @Software: PyCharm +# @Desc: python脚本编写的测试用例文件 + + +import pytest +from loguru import logger +from pytest_html import extras # 往pytest-html报告中填写额外的内容 +from common_utils.func_handle import add_docstring + +# 读取用例数据 +cases = [{"title": "demo cese_01", "user": "flora", "age": 17, "run": False}] + + +@pytest.mark.test_login_demo +@pytest.mark.parametrize("case", cases) +def test_demo(case, extra): + logger.info("-----------------------------START-开始执行用例-----------------------------") + logger.debug(f"当前执行的用例数据:{case}") + # 给当前测试方法添加文档注释 + add_docstring(case.get("title", ""))(test_demo) + if case.get("run", None): + assert case["user"] == "flora" + # 将用例数据显示在pytest-html报告中 + extra.append(extras.json(case, name="用例数据")) + else: + reason = f"标记了该用例为false,不执行\\n" + logger.warning(f"{reason}") + pytest.skip(reason) + 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 156cdb5..af76b86 100644 --- a/test_case/test_manual_case/test_login_demo.py +++ b/test_case/test_manual_case/test_login_demo.py @@ -31,7 +31,7 @@ def test_login_demo(case, extra, request): # 获取命令行参数,判断当前处于哪个环境 env = request.config.getoption("--env") # 给当前测试方法添加文档注释 - add_docstring(case.get("title", ""))(test_login) + add_docstring(case.get("title", ""))(test_login_demo) if case.get("run", None): # 处理请求前的用例数据 case_data = RequestPreDataHandle(case).request_data_handle()