(2023-03-29)1. 完成api自动化测试框架的搭建 2. 增加示例的测试用例,包括自动 生成以及手动编写

This commit is contained in:
floraachy
2023-05-17 17:00:38 +08:00
parent f80f054c53
commit 1f06223170
29 changed files with 1433 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
# generate by pycharm
.idea
.pytest_cache
__pycache__
*.pytest_cache
*__pycache__
# code related
outputs
Pipfile.lock
test_case/test_auto_case
+24
View File
@@ -0,0 +1,24 @@
[[source]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
verify_ssl = true
name = "pypi"
[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 = "*"
yagmail = "*"
[dev-packages]
[requires]
python_version = "3.9"
+151 -1
View File
@@ -1,2 +1,152 @@
# apiautotest # python + request + pytest+ pytest-html集成的API自动化测试框架
**对于框架任何问题,欢迎联系我!**
#### 一、框架功能说明
**解决痛点:**
1. 通过**session会话方式**,解决了登录之后**cookie关联**处理
```
模块: case_utils.requests_handle
相关代码:cls.session = requests.Session()
```
2. 框架天然支持接口**动态传参、关联**灵活处理
```
1) 通过全局变量替换用例数据值
用例数据中,存在如下格式 data: { "user_id": "${user_id}"}
全局变量GLOBAL_VAR中存在user_id=84522,通过替换后,最终变为:{ "user_id": 84522}
2) 通过执行函数替换用例数据值
用例数据中,存在如下格式 "repository_name": ${faker.name().replace(" ", "").replace(".", "")}。
通过代码替换后,最终变为:"repository_name": "Mike"
```
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文件
```
必须在项目根目录下,输入命令运行(如果依赖包是安装在虚拟环境中,需要先启动虚拟环境)。
注意:本机环境中没有安装依赖包的情况下,不要直接在run.py中右键直接run
> python run.py (默认在test环境运行测试用例)
> python run.py -env live 在live环境运行测试用例
> python run.py -env=test 在test环境运行测试用例
```
#### 三、框架使用过程中遇到的问题
##### 测试机安装的是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)
3)激活虚拟环境:pipenv shell
4)运行框架:python run.py
方法二:
1)首先在项目根目录下打开命令窗口,移除虚拟环境:pipenv --rm
2)更改项目根目录下的Pipfile文件
```
# 如下所示,3.9更改为3.7
[requires]
python_version = "3.7"
```
3)更改项目根目录下的Pipfile.lock文件
```
# 如下所示,3.9更改为3.7
"requires": {
"python_version": "3.7"
},
```
4)安装虚拟环境,同时安装依赖包:pipenv install
5)激活虚拟环境:pipenv shell
6)运行框架:python run.py
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:20
# @Author : chenyinhua
# @File : __init__.py.py
# @Software: PyCharm
# @Desc: 这是文件的描述信息
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/2/1 14:31
# @Author : chenyinhua
# @File : assert_handle.py
# @Software: PyCharm
# @Desc: 断言
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
def assert_result(response: Response, expected: dict) -> None:
""" 断言方法
:param response: 实际响应对象
:param expected: 预期响应内容,从excel中或者yaml读取、或者手动传入,格式如下:
{
'eq':
{'http_code': 200, '$.user_id': '85392'},
"in":
{'message': "success"}
}
return None
"""
if expected is None:
logger.info("当前用例无断言!")
return
logger.info(f"预期结果:{expected}, {type(expected)}")
index = 0
for k, v in expected.items():
# 获取需要断言的实际结果部分
for _k, _v in v.items():
if _k == "http_code":
actual = response.status_code
else:
logger.debug("根据响应类型的不同,从响应数据中提取实际结果")
if response_type(response) == "json":
# 如果响应数据是json格式
actual = json_extractor(response.json(), _k)
else:
# 响应数据不是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)}')
try:
if k == "eq": # 预期结果 = 实际结果
assert _v == actual
logger.info("断言成功!")
elif k == "in": # 实际结果 包含 预期结果
assert _v in actual
logger.info("断言成功!")
elif k == "gt": # 预期结果 > 实际结果 (值应该为数值型)
assert actual < _v
logger.info("断言成功!")
elif k == "lt": # 预期结果 < 实际结果 (值应该为数值型)
assert actual > _v
logger.info("断言成功!")
elif k == "not": # 预期结果 != 实际结果
assert actual != _v
logger.info("断言成功!")
else:
logger.error(f"判断关键字: {k} 错误!")
except AssertionError:
logger.error("断言失败")
raise AssertionError(f"{index}个断言 -|- 预期结果: {_v}, {type(_v)} {k} 实际结果: {actual},{type(actual)}")
+103
View File
@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/12 15:22
# @Author : chenyinhua
# @File : case_handle.py
# @Software: PyCharm
# @Desc: 生成测试用例文件并返回用例数据
import os
from config.project_path import CONF_DIR, DATA_DIR, AUTO_CASE_DIR
from common_utils.excel_handle import ReadExcel
from common_utils.yaml_handle import HandleYaml
from config.global_vars import CaseFileType
from config.settings import CASE_FILE_TYPE
from string import Template
from loguru import logger
# 定义生成的测试用例的模板
with open(file=os.path.join(CONF_DIR, "case_template.txt"), mode="r", encoding="utf-8") as f:
case_template = f.read()
def get_case_data():
"""
根据配置文件,从指定类型文件中读取用例数据,并调用生成用例文件方法,生成用例文件
"""
# 判断配置文件里面CASE_DATA_TYPE,判断用例数据是从excel还是yaml文件中读取
# 从excel中读取用例数据
if CASE_FILE_TYPE == CaseFileType.EXCEL.value:
cases = []
# 在用例数据data_path目录中寻找后缀是xlsx的文件
for file in [excel for excel in os.listdir(DATA_DIR) if os.path.splitext(excel)[1] in [".xlsx", "xls"]]:
# 判断只有以test_开头的excel才生成测试用例py文件
if file.startswith("test_"):
# 读取excel文件中的用例数据,存储到data中
data = ReadExcel(os.path.join(DATA_DIR, file)).read()
# 将用例数据的名称作为测试用例文件名称
func_name = os.path.splitext(file)[0]
# 测试用例test_demo.py的类名是TestDemo
class_name = func_name.split("_")[0].title() + func_name.split("_")[1].title()
# 调用gen_case方法生成测试用例test_demo.py
gen_case_file(func_name, data, class_name)
# 将excel中读取的用例数据放到cases列表中
cases.extend(data)
logger.debug(f"从excel中读取到的用例数据是:{cases}")
return cases
# 从yaml中读取用例数据
elif CASE_FILE_TYPE == CaseFileType.YAML.value:
cases = []
# 在用例数据data_path目录中寻找后缀是yaml/yml的文件
for file in [yaml for yaml in os.listdir(DATA_DIR) if
os.path.splitext(yaml)[1] in [".yaml", ".yml"]]:
# 判断只有以test_开头的yaml才生成测试用例py文件
if file.startswith("test_"):
# 读取yaml/yml文件中的用例数据,存储到data中
data = HandleYaml(os.path.join(DATA_DIR, file)).read_yaml
# 将用例数据的名称作为测试用例文件名称
func_name = os.path.splitext(file)[0]
# 测试用例test_demo.py的类名是TestDemo
class_name = func_name.split("_")[0].title() + func_name.split("_")[1].title()
# 调用gen_case方法生成测试用例test_demo.py
gen_case_file(func_name, data, class_name)
# 将excel中读取的用例数据放到cases列表中
cases.extend(data)
logger.debug(f"从yaml中读取到的用例数据是:{cases}")
return cases
else:
# 从excel以及yaml/yml文件中读取用例数据
cases = []
for file in [excel for excel in os.listdir(DATA_DIR) if
os.path.splitext(excel)[1] in [".yaml", ".yml", ".xlsx", "xls"]]:
# 判断只有以test_开头的yaml才生成测试用例py文件
if file.startswith("test_"):
if os.path.splitext(file)[1] == ".xlsx":
data = ReadExcel(os.path.join(DATA_DIR, file)).read()
func_name = os.path.splitext(file)[0]
cases.extend(data)
else:
data = HandleYaml(os.path.join(DATA_DIR, file)).read_yaml
func_name = os.path.splitext(file)[0]
cases.extend(data)
class_name = func_name.split("_")[0].title() + func_name.split("_")[1].title()
gen_case_file(func_name, data, class_name)
logger.debug(f"从excel以及yaml中读取到的用例数据是:{cases}")
return cases
def gen_case_file(func_name, case_data, class_name):
"""
根据定义的模板生成测试用例文件
"""
# 如果自动生成用例的目录不存在则自动创建一个
if not os.path.exists(AUTO_CASE_DIR):
os.makedirs(AUTO_CASE_DIR)
"""
string.Template是将一个string设置为模板,通过替换变量的方法,最终得到想要的string。
"""
my_case = Template(case_template).safe_substitute({"case_data": case_data,
"func_title": func_name,
"class_title": class_name})
with open(os.path.join(AUTO_CASE_DIR, func_name + '.py'), "w", encoding="utf-8") as fp:
fp.write(my_case)
+85
View File
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/2/1 14:16
# @Author : chenyinhua
# @File : data_handle.py
# @Software: PyCharm
# @Desc: 数据处理
from loguru import logger
from jsonpath import jsonpath
import re
from faker import Faker
faker = Faker()
def json_extractor(obj: dict, expr: str = '.'):
"""
:param obj :json/dict类型数据
:param expr: 表达式, . 提取字典所有内容, $.test_api_case 提取一级字典case $.test_api_case.data 提取case字典下的data
:return result: 提取的结果,未提取到返回 None
"""
try:
result = jsonpath(obj, expr)[0]
logger.debug("提取响应内容成功,提取表达式为: {} 提取值为 {}".format(expr, result))
except Exception as e:
logger.exception(e)
logger.exception(f'未提取到内容,请检查表达式是否错误!提取表达式为:{expr} 提取数据为 {obj}')
result = None
return result
def re_extract(obj: str, expr: str = '.'):
"""
:param obj : 字符串数据
:param expr: 正则表达式
:return result: 提取的结果,未提取到返回 None
"""
try:
result = re.findall(expr, obj)[0]
except Exception as e:
result = None
logger.exception(e)
logger.exception(f'未提取到内容,请检查表达式是否错误!提取表达式为:{expr} 提取数据为 {obj}')
return result
def exec_func(func) -> str:
"""
:params func 字符的形式调用函数
: return 返回的转换成函数执行的结果,已字符串格式返回
"""
result = eval(func)
return str(result)
# 将"[1,2,3]" 或者"{'k':'v'}" -> [1,2,3], {'k':'v'}
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):
data = eval_data(data)
# 如果目标数据是列表,遍历列表的每一个数据,再用递归的方法处理每一个item
if 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
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:31
# @Author : chenyinhua
# @File : request_data_handle.py
# @Software: PyCharm
# @Desc: 处理request请求前的用例数据
import re
from string import Template
from config.global_vars import GLOBAL_VARS
from loguru import logger
from case_utils.data_handle import exec_func
from case_utils.data_handle import eval_data_process
# ---------------------------------------- 请求前的数据处理----------------------------------------#
class RequestPreDataHandle:
"""
请求前处理用例数据
"""
def __init__(self, request_data):
self.request_data = request_data
def request_data_handle(self):
"""
针对用例数据进行处理,识别用例数据中的关键字${xxxx},使用全局变量进行替换或者执行关键字中的方法替换为具体值
"""
logger.info(f"-----Start-----初始获取的测试用例数据:----- {type(self.request_data)} || {self.request_data}")
# 从用例数据中获取url,如果键url不存在,则返回空字符串
url = self.request_data.get("url", "")
self.request_data["url"] = self.url_handle(url)
# 从用例数据中获取files,如果键files不存在,则返回None
files = self.request_data.get("files", None)
self.request_data["files"] = self.files_handle(files)
request_data = eval_data_process(case_data_replace(self.request_data))
logger.info(f"-----End-----处理完成后的测试用例数据:-----{type(request_data)} || {request_data}")
return request_data
def url_handle(self, url):
"""
用例数据中获取到的url(一般是不带host的,个别特殊的带有host,则不进行处理)
"""
host = GLOBAL_VARS.get("host", "")
# 从用例数据中获取url,如果键url不存在,则返回空字符串
# 如果url是以http开头的,则直接使用该url,不与host进行拼接
if url.lower().startswith("http"):
full_url = url
else:
# 如果host以/结尾 并且 url以/开头
if host.endswith("/") and url.startswith("/"):
full_url = host[0:len(host) - 1] + url
# 如果host以/结尾 并且 url不以/开头
elif host.endswith("/") and (not url.startswith("/")):
full_url = host + url
elif (not host.endswith("/")) and url.startswith("/"):
# 如果host不以/结尾 且 url以/开头,则将host和url拼接起来,组成新的url
full_url = host + url
else:
# 如果host不以/结尾 且 url不以/开头,则将host和url拼接起来的时候增加/,组成新的url
full_url = host + "/" + url
return full_url
def files_handle(self, files):
"""
格式:接口中文件参数的名称:"文件路径地址"/["文件地址1", "文件地址2"]
"""
if files is None:
return
if files != "" and files is not None:
files = eval(files)
for k, v in files.items():
# 多文件上传
if isinstance(v, list):
files = []
for path in v:
files.append((k, (open(path, 'rb'))))
else:
# 单文件上传
files = {k: open(v, 'rb')}
return files
def case_data_replace(content):
"""
用例数据替换的方法
:param content: 原始的字符串内容
return content 替换表达式后的字符串
"""
if content is None:
return None
if len(content) != 0:
logger.debug(f"开始进行字符串替换: 替换字符串为:{content}")
content = Template(str(content)).safe_substitute(GLOBAL_VARS)
logger.debug(f"使用模板函数Template替换字符串完成。 替换后的字符串如下:{content}")
for func in re.findall('\\${(.*?)}', content):
try:
content = content.replace('${%s}' % func, exec_func(func))
logger.debug(f"通过执行函数替换用例数据值 替换字符串后为:{content}")
except Exception as e:
logger.exception(e)
return content
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:31
# @Author : chenyinhua
# @File : requests_handle.py
# @Software: PyCharm
# @Desc: 处理request请求
import requests
from config.global_vars import GLOBAL_VARS
from requests import Response
from loguru import logger
from case_utils.data_handle import json_extractor, re_extract
# ---------------------------------------- 进行请求----------------------------------------#
class BaseRequest:
"""
进行请求
"""
session = None
@classmethod
def get_session(cls):
"""
单例模式保证测试过程中使用的都是一个session对象;
requests.session可以自动处理cookies,做状态保持。
"""
if cls.session is None:
cls.session = requests.Session()
return cls.session
@classmethod
def send_request(cls, req_data: dict) -> Response:
"""
处理case数据,转换成可用数据发送请求
:param case: 读取出来的每一行用例内容
return: 响应对象
"""
logger.info(f"-----Start-----进行接口请求,并获取接口响应数据,请求数据:{type(req_data)} || {req_data}-----")
# 进行接口请求,并获取接口响应数据
res = cls.send_api(
url=req_data["url"],
method=req_data["method"],
pk=req_data["pk"],
header=req_data.get("headers", None),
data=req_data.get("payload", None),
file=req_data.get("files", None)
)
logger.info(f"-----End-----接口请求结束,请求响应数据:{res.text}-----")
# 对用例数据中需要提取的后置参数,基于接口响应数据进行处理
after_extract(res, req_data.get("extract", None))
return res
@classmethod
def send_api(cls, url, method, pk, header=None, data=None, file=None) -> Response:
"""
根据pk参数的不同,决定请求参数是使用params,data还是json
:param method: 请求方法
:param url: 请求url
:param pk: 入参关键字, params(查询参数类型,明文传输,一般在url?参数名=参数值), data(一般用于form表单类型参数)
json(一般用于json类型请求参数)
:param data: 参数数据,默认等于None
:param file: 文件对象
:param header: 请求头
:return: 返回res对象
"""
session = cls.get_session()
pk = pk.lower()
if pk == 'params':
res = session.request(method=method, url=url, params=data, headers=header)
elif pk == 'data':
res = session.request(method=method, url=url, data=data, files=file, headers=header)
elif pk == 'json':
res = session.request(method=method, url=url, json=data, files=file, headers=header)
else:
raise ValueError('pk可选关键字为params, json, data')
return res
# ---------------------------------------- 请求后的参数提取处理----------------------------------------#
def after_extract(response: Response, extract: dict) -> None:
"""
从响应数据中提取请求后的参数,并保存到全局变量中
:param response: request 响应对象
:param extract: 需要提取的参数字典 '{"k1": "$.data"}''{"k1": "data:(.*?)$"}'
:return:
"""
logger.info("-----Start-----请求后的参数提取处理-----")
if extract:
if response_type(response) == "json":
# 如果响应数据是json格式,则将按照json方式对后置提取参数进行处理
res = response.json()
for k, v in extract.items():
GLOBAL_VARS[k] = json_extractor(res, v)
else:
# 如果响应数据是str格式,则将按照str方式对后置提取参数进行处理
res = response.text
for k, v in extract.items():
GLOBAL_VARS[k] = re_extract(res, v)
logger.info(f"-----End-----参数提取后,打印当前的全局变量:{GLOBAL_VARS}")
def response_type(response: Response) -> str:
"""
:param response: requests 返回
:return: 返回响应数据类型 json或者str
"""
try:
response.json()
return "json"
except:
return "str"
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:20
# @Author : chenyinhua
# @File : __init__.py.py
# @Software: PyCharm
# @Desc: 这是文件的描述信息
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/9 16:42
# @Author : chenyinhua
# @File : handle_excel.py
# @Software: PyCharm
# @Desc: 使用openpyxl对excel进行读写操作
import openpyxl
from loguru import logger
class ReadExcel:
def __init__(self, filename):
"""
初始化用例文件
:param filename: 文件绝对路径,如:D:\test\test.xlsx
"""
self.filename = filename
def create_excel(self):
"""
创建excel文件,需要指定excel文件的绝对路径,如D:\test\test.xlsx
"""
# 创建文件对象
wb = openpyxl.Workbook()
# 创建excel文件
wb.save(self.filename)
return self.filename
def read(self, sheet=None):
"""
读取excel数据并返回
:param sheet: 表单名称
:return: 存在传入的表单, 返回表单数据,不存在则返回空
"""
# 创建一个工作簿工作对象(excel文件已存在的情况)
workbook = openpyxl.open(self.filename)
# 跟上面那句一个意思 workbook = openpyxl.load_workbook(self.file)
# 获取excel当中所有的sheet,返回的是一个列表
sheets = workbook.sheetnames
# 如果sheet不为空,则取sheet等于指定sheet
if sheet:
sheet = sheet
# 如果sheet为空,则sheet为第一个表单
else:
sheet = sheets[0]
data = []
if sheet in sheets:
sheet = workbook[sheet]
all_values = list(sheet.values)
header = all_values[0]
for i in all_values[1:]:
data.append(dict(zip(header, i)))
# 关闭excel
workbook.close()
return data
else:
# 关闭excel
workbook.close()
logger.error(f"表单: 【{sheet}】文件不存在")
def write(self, row, column, data, sheet_name=None):
"""
往excel写入数据
:param sheet_name: 表单名称
:param row: 要写入的行
:param column: 要写入的列
:param data: 要写入的数据
:return: None
"""
workbook = openpyxl.open(self.filename)
# 获取excel当中所有的sheet,返回的是一个列表
sheets = workbook.sheetnames
if sheet_name in sheets:
sheet = workbook[sheet_name]
logger.info(f"往表单【{sheet_name}】中写入数据")
else:
# 如果表单为空,就默认使用第一个表单
sheet = workbook.active
logger.info(f"表单【{sheet_name}】不存在,默认往第一个表单中写入数据")
sheet.cell(row=row, column=column, value=data)
# 更上面写法效果一样 sheet.cell(row=row, column=column).value = data
# 保存并关闭文件
workbook.save(self.filename)
workbook.close()
+37
View File
@@ -0,0 +1,37 @@
# -*- 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
+20
View File
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 15:02
# @Author : chenyinhua
# @File : loguru_handle.py
# @Software: PyCharm
# @Desc: 配置loguru日志值生成错误日志的方法
from loguru import logger
def error_only(record):
"""
error日志判断
Args:
record:
Returns: 若日志级别为ERROR,输出True
"""
return record["level"].name == "ERROR"
+34
View File
@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 15:22
# @Author : chenyinhua
# @File : yaml_handle.py
# @Software: PyCharm
# @Desc: 从日志文件中提取响应数据
import yaml
class HandleYaml:
def __init__(self, filename):
"""
初始化用例文件
:param filename: 文件绝对路径,如:D:\test\test.xlsx
"""
self.filename = filename
@property
def read_yaml(self):
with open(file=self.filename, mode="r", encoding="utf-8") as fp:
return yaml.safe_load(fp.read())
def write(self, data, mode="a"):
"""
往yaml文件中写入数据,默认是追加写入
:param data: 要写入的数据
:param mode: 写入模式
:return:
"""
with open(self.filename, mode=mode, encoding="utf-8") as f:
yaml.dump(data, f)
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/12 15:22
# @Author : chenyinhua
# @File : __init__.py
# @Software: PyCharm
# @Desc: 这是文件的描述信息
+36
View File
@@ -0,0 +1,36 @@
import pytest
from case_utils.assert_handle import assert_result
from case_utils.requests_handle import BaseRequest
from loguru import logger
from case_utils.request_data_handle import RequestPreDataHandle
from config.global_vars import GLOBAL_VARS
@pytest.fixture(params=${case_data})
def case(request):
"""
收集用例数据
"""
return request.param
@pytest.mark.${func_title}
@pytest.mark.auto
class ${class_title}Auto:
def ${func_title}_auto(self, case):
logger.info("-----------------------------START-开始执行用例-----------------------------")
logger.debug(f"当前执行的用例数据:{case}")
# 将用例标题title作为全局变量,方便后续写入到测试报告report.description中
GLOBAL_VARS["title"] = case.get("title", "")
if case.get("run", None):
# 获取处理完成的用例数据
case_data = RequestPreDataHandle(case).request_data_handle()
# 发送请求
response = BaseRequest.send_request(case_data)
# 进行断言
assert_result(response, case_data["expected"])
else:
reason = f"标记了该用例为false,不执行\\n"
logger.warning(f"{reason}")
pytest.skip(reason)
logger.info("------------------------------------------用例执行结束------------------------------------------")
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 14:31
# @Author : chenyinhua
# @File : global_vars.py
# @Software: PyCharm
# @Desc: 全局变量
from enum import Enum
# 定义一个全局变量,作用于接口关联数据存储
GLOBAL_VARS = {}
class CaseFileType(Enum):
"""
用例数据可存储文件的类型枚举
"""
YAML = 1
EXCEL = 2
ALL = 0
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/31 17:27
# @Author : chenyinhua
# @File : project_path.py
# @Software: PyCharm
# @Desc: 项目相关路径
import os
# ------------------------------------ 项目路径 ----------------------------------------------------#
# 项目根目录
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 通用模块目录
COMMON_DIR = os.path.join(BASE_DIR, "common_utils")
# 配置模块目录
CONF_DIR = os.path.join(BASE_DIR, "config")
# 数据模块目录
DATA_DIR = os.path.join(BASE_DIR, "data")
# 日志/报告保存目录
OUT_DIR = os.path.join(BASE_DIR, "outputs")
if not os.path.exists(OUT_DIR):
os.mkdir(OUT_DIR)
# 报告保存目录
REPORT_DIR = os.path.join(OUT_DIR, "report")
if not os.path.exists(REPORT_DIR):
os.mkdir(REPORT_DIR)
# 报日志保存目录
LOG_DIR = os.path.join(OUT_DIR, "log")
if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR)
# 测试用例模块
CASE_DIR = os.path.join(BASE_DIR, "test_case")
# 手动生成测试用例模块
MANUAL_CASE_DIR = os.path.join(CASE_DIR, "test_manual_case")
if not os.path.exists(MANUAL_CASE_DIR):
os.mkdir(MANUAL_CASE_DIR)
# 自动生成测试用例模块
AUTO_CASE_DIR = os.path.join(CASE_DIR, "test_auto_case")
+54
View File
@@ -0,0 +1,54 @@
/* change pytest-html test report style */
@charset "UTF-8";
body {
font-size: 16px;
}
h1 {
text-align: center;
color: #2084D9;
font-size: 30px;
}
h2 {
font-size: 24px;
color: #2084D9;
}
a {
color: #466AFF;
}
span {
font-size: 20px;
}
#environment td {
padding: 10px;
}
#results-table {
font-size: 16px;
table-layout:fixed;
}
#results-table td{
word-break:break-all;
word-wrap:break-word;
}
#results-table th{
font-size: 20px;
background-color: #2084D9;
color: #FFFFFF;
}
td {
color: #000000;
}
+43
View File
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/9 17:08
# @Author : chenyinhua
# @File : settings.py
# @Software: PyCharm
# @Desc: 项目配置文件
# 0代表执行Excel和yaml两种格式的用例, 1 代表 yaml文件,2 用例代表Excel用例
CASE_FILE_TYPE = 0
# 测试报告的定制化信息展示
REPORT_TITLE = "自动化测试报告"
REPORT_NAME = f"apiautotest-report-"
PROJECT_NAME = "GitLink 确实开源"
TESTER = "测试人员:陈银花"
DEPARTMENT = "所属部门: 开源中心"
# 测试数据
test = [
{
# 示例测试环境及示例测试账号
"host": "https://testforgeplus.trustie.net/",
"login": "auotest",
"password": "12345678",
"nickname": "AutoTest",
"user_id": "84954",
"project_id": "",
"project": ""
}
]
live = [
{
"host": "https://www.gitlink.org.cn",
"login": "******",
"password": "******",
"nickname": "******",
"user_id": "******",
"project_id": "",
"project": ""
}
]
+149
View File
@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/2/2 16:05
# @Author : chenyinhua
# @File : conftest.py
# @Software: PyCharm
# @Desc: 这是文件的描述信息
from config.global_vars import GLOBAL_VARS
from loguru import logger
import pytest
from py._xmlgen import html # 安装pytest-html,版本最好是2.1.1
from time import strftime
from config.settings import test, live, REPORT_TITLE, PROJECT_NAME, TESTER, DEPARTMENT
# ------------------------------------- START: 配置运行环境 ---------------------------------------#
def pytest_addoption(parser):
"""
pytest_addoption 可以让用户注册一个自定义的命令行参数,方便用户将数据传递给 pytest;
这个 Hook 方法一般和 内置 fixture pytestconfig 配合使用,pytest_addoption 注册命令行参数,pytestconfig 通过配置对象读取参数的值;
:param parser:
:return:
"""
parser.addoption(
# action="store" 默认,只存储参数的值,可以存储任何类型的值,此时 default 也可以是任何类型的值,而且命令行参数多次使用也只能生效一个,最后一个值覆盖之前的值;
# action="append",将参数值存储为一个列表,用append模式将可以在pytest命令行方式执行测试用例的同时多次向程序内部传递自定义参数对应的参数值
"--env", action="store",
default="test",
choices=["test", "live"], # choices 只允许输入的值的范围
type=str,
help="将命令行参数--env添加到pytest配置对象中,通过--env设置当前运行的环境host"
)
@pytest.fixture(scope="session", autouse=True)
def get_config(request):
"""
从配置对象中读取自定义参数的值
"""
# 根据指定的环境,获取指定环境的域名以及用例数据文件类型
env = request.config.getoption("--env")
if env.lower() == "live":
config_data = live
else:
config_data = test
for item in config_data:
for k, v in item.items():
GLOBAL_VARS[k] = v
logger.info(f"当前环境变量为:{GLOBAL_VARS}")
# ------------------------------------- END: 配置运行环境 ---------------------------------------#
# ------------------------------------- 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"""
outcome = yield
# 获取调用结果的测试报告,返回一个report对象
# report对象的属性包括whensteup, call, teardown三个值)、nodeid(测试用例的名字)、outcome(用例的执行结果,passed,failed)
report = outcome.get_result()
# TODO:由于目前无法动态将用例数据中的title写入测试方法中的文档注释,因此该处理方法暂时搁置
# 将测试方法的文档注释作为结果表的Description的值,如果文档注释为空,则测试方法名作为结果表的Description的值
# if item.function.__doc__ is None:
# report.description = str(item.function.__doc__)
# else:
# report.description = str(item.function.__name__)
report.description = GLOBAL_VARS.get("title", "")
report.nodeid = report.nodeid.encode("utf-8").decode("unicode_escape")
def pytest_html_report_title(report):
"""
修改报告标题
"""
report.title = REPORT_TITLE
def pytest_configure(config):
"""
# 在测试运行前,修改Environment部分信息,配置测试报告环境信息
"""
# 给环境表 添加项目名称及开始时间
config._metadata["项目名称"] = PROJECT_NAME
config._metadata['开始时间'] = strftime('%Y-%m-%d %H:%M:%S')
# 给环境表 移除packages 及plugins
config._metadata.pop("Packages")
config._metadata.pop("Plugins")
@pytest.hookimpl(tryfirst=True)
def pytest_sessionfinish(session, exitstatus):
"""
在测试运行后,修改Environment部分信息
"""
# 给环境表 添加 项目环境
session.config._metadata['项目环境'] = GLOBAL_VARS.get("host", "")
def pytest_html_results_summary(prefix, summary, postfix):
"""
修改Summary部分的信息
"""
prefix.extend([html.p(TESTER)])
prefix.extend([html.p(DEPARTMENT)])
def pytest_html_results_table_header(cells):
"""
修改结果表的表头
"""
# 往表格中增加一列"用例描述",并且给"用例描述"增加排序
cells.insert(0, html.th('用例描述', class_="sortable", col="name"))
# 往表格中增加一列"执行时间",并且给"执行时间"增加排序
cells.insert(1, html.th('执行时间', class_="sortable time", col="time"))
# 移除表格最后一列
cells.pop()
@pytest.mark.optionalhook
def pytest_html_results_table_row(report, cells):
"""
修改结果表的表头后给对应的行增加值
"""
# 往列"用例描述"插入每行的值
cells.insert(0, html.td(report.description))
# 往列"执行时间"插入每行的值
cells.insert(1, html.td(strftime("%Y-%m-%d %H:%M:%S"), class_="col-time"))
# 移除表格最后一列
cells.pop()
def pytest_html_results_table_html(report, data):
"""如果测试通过,则显示这条用例通过啦!"""
if report.passed:
del data[:]
data.append(html.div("这条用例通过啦!", class_="empty log"))
# ------------------------------------- END: 报告处理 ---------------------------------------#
+41
View File
@@ -0,0 +1,41 @@
- feature: 登录
title: 用户名密码正确,登录成功
url: /api/accounts/login.json
method: POST
headers: {"Content-Type": "application/json; charset=utf-8;"}
cookies:
pk: json
payload: { "login": "${login}","password": "${password}","autologin": 1 }
files:
extract:
nickname: $.username
login: $.login
user_id: $.user_id
run: True
assert_response:
eq:
http_code: 200
$.user_id: ${user_id}
in:
$.login: ${login}
gt:
$.user_id: 84955
lt:
$.user_id: 84953
not:
$.user_id: 85390
- feature: 登录
title: 用户名正确,密码错误,登录失败
url: /api/accounts/login.json
method: POST
headers: {"Content-Type": "application/json; charset=utf-8;"}
cookies:
pk: json
payload: { "login": "chytest10","password": "password111","autologin": 1 }
files:
extract:
run: False
assert_response:
eq:
http_code: 200
$.status: -2
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
- feature: 新建项目
title: 正确输入各项必填参数,新建项目成功
url: /api/projects.json
method: POST
headers: {"Content-Type": "application/json; charset=utf-8;"}
cookies:
pk: json
payload:
"user_id": ${user_id}
"name": ${faker.name().replace(" ", "").replace(".", "")}
"repository_name": ${faker.name().replace(" ", "").replace(".", "")}
files:
extract:
project_id: $.id
project_name: $.name
project_identifier: $.identifier
run: True
assert_response:
eq:
http_code: 200
$.login: ${login}
+10
View File
@@ -0,0 +1,10 @@
[pytest]
# cmd params
addopts = -s --capture=sys --self-contained-html --reruns=0 --reruns-delay=5
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
markers =
test_login: login
test_login_excel: login excel
test_new_project: new_project
test_softbot: softbot
auto: auto generate case
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/9 17:09
# @Author : chenyinhua
# @File : run.py
# @Software: PyCharm
# @Desc: 框架主入口
"""
说明
1用例创建原则测试文件名必须以test开头测试函数必须以test开头
2运行方式
> python run.py (默认在test环境运行测试用例)
> python run.py -env live 在live环境运行测试用例
> python run.py -env=test 在test环境运行测试用例
"""
import os
import shutil
import pytest
from config.project_path import REPORT_DIR, LOG_DIR, AUTO_CASE_DIR, CONF_DIR
from case_utils.case_handle import get_case_data
from common_utils.loguru_handle import error_only
from loguru import logger
import click
from config.settings import REPORT_NAME
from datetime import datetime
@click.command()
@click.option("-env", default=None, help="输入运行环境:test 或 live")
@click.option("-m", default=None, help="选择需要运行的用例:python.ini配置的名称")
def run(env, m):
# 捕获所有日志
logger.add(os.path.join(LOG_DIR, "runtime_{time}_all.log"), enqueue=True, encoding="utf-8", rotation="00:00",
format="{time:YYYY-MM-DD HH:mm:ss} {level} From {module}.{function} : {message}")
# 仅捕获错误日志
logger.add(os.path.join(LOG_DIR, "runtime_{time}_error.log"), enqueue=True, encoding="utf-8", rotation="00:00",
filter=error_only, format="{time:YYYY-MM-DD HH:mm:ss} {level} From {module}.{function} : {message}")
logger.info("""
_ _ _ _____ _
__ _ _ __ (_) / \\ _ _| |_ __|_ _|__ ___| |_
/ _` | "_ \\| | / _ \\| | | | __/ _ \\| |/ _ \\/ __| __|
| (_| | |_) | |/ ___ \\ |_| | || (_) | | __/\\__ \\ |_
\\__,_| .__/|_/_/ \\_\\__,_|\\__\\___/|_|\\___||___/\\__|
|_|
Starting ... ... ...
""")
# 生成case在执行
if os.path.exists(AUTO_CASE_DIR):
# 删除文件夹
shutil.rmtree(AUTO_CASE_DIR)
if os.path.exists('outputs/report/'):
shutil.rmtree(path='outputs/report/')
# 获取用例
get_case_data()
# 执行用例
current_time = datetime.now().strftime("%Y-%m-%d %H_%M_%S")
report_name = os.path.join(REPORT_DIR, REPORT_NAME + str(current_time) + ".html")
report_css = os.path.join(CONF_DIR, "report.css")
arg_list = [f'--html={report_name}', f"--css={report_css}"]
if env == "live":
arg_list.append("--env=live")
# 执行指定测试用例
if m is not None:
arg_list.append(f"-m {m}")
pytest.main(args=arg_list)
if __name__ == '__main__':
run()
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# @Time : 2023/3/28 23:17
# @Author : Flora.Chen
# @File : conftest.py
# @Software: PyCharm
# @Desc:
+6
View File
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# @Time : 2023/3/28 23:17
# @Author : Flora.Chen
# @File : __init__.py.py
# @Software: PyCharm
# @Desc:
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# @Version: Python 3.9
# @Time : 2023/1/9 16:41
# @Author : chenyinhua
# @File : test_login.py
# @Software: PyCharm
# @Desc: python脚本编写的测试用例文件
import pytest
import os
from common_utils.yaml_handle import HandleYaml
from config.project_path import DATA_DIR
from case_utils.requests_handle import BaseRequest
from case_utils.assert_handle import assert_result
from loguru import logger
from case_utils.request_data_handle import RequestPreDataHandle
from config.global_vars import GLOBAL_VARS
# 读取用例数据
cases = HandleYaml(filename=os.path.join(DATA_DIR, "test_login.yaml")).read_yaml
@pytest.mark.test_login
class TestLogin:
"""
登录模块
"""
@pytest.mark.parametrize("case", cases)
def test_login(self, case):
logger.info("-----------------------------START-开始执行用例-----------------------------")
logger.debug(f"当前执行的用例数据:{case}")
# 将用例标题title作为全局变量,方便后续写入到测试报告report.description中
GLOBAL_VARS["title"] = case.get("title", "")
if case.get("run", None):
# 获取处理完成的用例数据
case_data = RequestPreDataHandle(case).request_data_handle()
# 发送请求
response = BaseRequest.send_request(case_data)
# 进行断言
assert_result(response, case_data["expected"])
else:
reason = f"标记了该用例为false,不执行\\n"
logger.warning(f"{reason}")
pytest.skip(reason)
logger.info("-----------------------------END-用例执行结束-----------------------------")