一、项目简介
本项目是基于Python语言开发的接口自动化测试框架,采用pytest作为测试执行引擎,结合yaml文件实现数据驱动测试。框架设计注重模块化、灵活性和易维护性,支持丰富的断言方式、动态参数处理及测试报告生成。
二、项目结构
pythonproject/
├── base/ # 核心业务逻辑模块
│ ├── apiutil.py # 核心接口请求与断言执行
│ ├── apiutil_business.py# 业务场景接口处理
│ ├── generateId.py # ID生成工具
│ └── removefile.py # 文件删除工具
├── common/ # 公共功能模块
│ ├── assertions.py # 断言封装
│ ├── connection.py # 数据库及服务连接
│ ├── debugtalk.py # 动态参数与提取
│ ├── readyaml.py # yaml文件读取
│ ├── recordlog.py # 日志管理
│ ├── sendrequest.py # 请求发送封装
├── conf/ # 配置文件
│ ├── setting.py # 项目配置
│ ├── operationConfig.py # 运行配置
│ └── config.ini # ini配置
├── testcase/ # 测试用例目录
│ ├── Single interface/ # 单接口测试
│ ├── Business interface/# 业务场景测试
├── report/ # 测试报告
│ ├── allureReport/ # Allure报告
├── logs/ # 日志文件
├── conftest.py # fixture
├── run.py # 测试启动脚本
└── pytest.ini # pytest配置
三、核心代码解析
1. 程序入口 run.py
import shutil
import pytest
import os
import webbrowser
from conf.setting import REPORT_TYPE
if __name__ == '__main__':
if REPORT_TYPE == 'allure':
pytest.main(
['-s', '-v', '--alluredir=./report/temp', './testcase', '--clean-alluredir',
'--junitxml=./report/results.xml'])
shutil.copy('./environment.xml', './report/temp')
os.system(f'allure serve ./report/temp')
elif REPORT_TYPE == 'tm':
pytest.main(['-vs', '--pytest-tmreport-name=testReport.html', '--pytest-tmreport-path=./report/tmreport'])
webbrowser.open_new_tab(os.getcwd() + '/report/tmreport/testReport.html')
allure报告
if REPORT_TYPE == 'allure':
pytest.main(
['-s', '-v', '--alluredir=./report/temp', './testcase', '--clean-alluredir',
'--junitxml=./report/results.xml'])
shutil.copy('./environment.xml', './report/temp')
os.system(f'allure serve ./report/temp')
参数说明:
- -s:显示控制台输出。
- -v:显示更详细的测试信息。
- --alluredir=./report/temp:将 allure 报告数据文件输出到 ./report/temp 目录。
- ./testcase:指定测试用例目录。
- --clean-alluredir:运行前清理临时报告目录。
- --junitxml=./report/results.xml:同时生成一个 junit xml 格式的测试结果文件。
2. 测试用例示例
获取商品列表
import allure
import pytest
from base.generateId import m_id, c_id
from base.apiutil import RequestBase
from common.readyaml import get_testcase_yaml
@allure.feature(next(m_id) + '商品管理(单接口)')
class TestLogin:
@allure.story(next(c_id) + "获取商品列表")
@pytest.mark.run(order=1)
@pytest.mark.parametrize('base_info,testcase', get_testcase_yaml('./testcase/ProductManager/getProductList.yaml'))
def test_get_product_list(self, base_info, testcase):
allure.dynamic.title(testcase['case_name'])
RequestBase().specification_yaml(base_info, testcase)
代码说明
- next(m_id):调用生成器获取一个新的模块id。
- next(c_id):从用例 ID 生成器里拿一个id拼上 获取商品列表 。
- @allure.feature():用来给当前类标记一个 功能点,在报告里是一级标题。
- @allure.story():给用例打上 子功能标签,在报告里是二级标题。
- @pytest.mark.run(order=1):指定执行顺序,这个用例在本类里最先执行。
- @pytest.mark.parametrize():参数化运行,每条yaml里的一组数据会单独生成一条用例。
id生成器
def generate_module_id():
"""
生成测试模块编号,为了保证allure报告的顺序与pytest设定的执行顺序保持一致
"""
for i in range(1, 1000):
module_id = 'M' + str(i).zfill(2) + '_'
yield module_id
def generate_testcase_id():
"""
生成测试用例编号
"""
for i in range(1, 10000):
case_id = 'C' + str(i).zfill(2) + '_'
yield case_id
m_id = generate_module_id()
c_id = generate_testcase_id()
3. YAML文件读取
yaml文件读取,支持异常处理和多用例读取。
import yaml
import traceback
import os
from common.recordlog import logs
from conf.operationConfig import OperationConfig
from conf.setting import FILE_PATH
from yaml.scanner import ScannerError
def get_testcase_yaml(file):
testcase_list = []
try:
with open(file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
if len(data) <= 1:
yam_data = data[0]
base_info = yam_data.get('baseInfo')
for ts in yam_data.get('testCase'):
param = [base_info, ts]
testcase_list.append(param)
return testcase_list
else:
return data
except UnicodeDecodeError:
logs.error(f"[{file}]文件编码格式错误,--尝试使用utf-8编码解码YAML文件时发生了错误,请确保你的yaml文件是UTF-8格式!")
except FileNotFoundError:
logs.error(f'[{file}]文件未找到,请检查路径是否正确')
except Exception as e:
logs.error(f'获取【{file}】文件数据时出现未知错误: {str(e)}')
class ReadYamlData:
def __init__(self, yaml_file=None):
if yaml_file is not None:
self.yaml_file = yaml_file
else:
pass
self.conf = OperationConfig()
self.yaml_data = None
@property
def get_yaml_data(self):
try:
with open(self.yaml_file, 'r', encoding='utf-8') as f:
self.yaml_data = yaml.safe_load(f)
return self.yaml_data
except Exception:
logs.error(str(traceback.format_exc()))
def write_yaml_data(self, value):
file = None
file_path = FILE_PATH['EXTRACT']
if not os.path.exists(file_path):
os.system(file_path)
try:
file = open(file_path, 'a', encoding='utf-8')
if isinstance(value, dict):
write_data = yaml.dump(value, allow_unicode=True, sort_keys=False)
file.write(write_data)
else:
logs.info('写入[extract.yaml]的数据必须为dict格式')
except Exception:
logs.error(str(traceback.format_exc()))
finally:
file.close()
def clear_yaml_data(self):
with open(FILE_PATH['EXTRACT'], 'w') as f:
f.truncate()
def get_extract_yaml(self, node_name, second_node_name=None):
if os.path.exists(FILE_PATH['EXTRACT']):
pass
else:
logs.error('extract.yaml不存在')
file = open(FILE_PATH['EXTRACT'], 'w')
file.close()
logs.info('extract.yaml创建成功!')
try:
with open(FILE_PATH['EXTRACT'], 'r', encoding='utf-8') as rf:
ext_data = yaml.safe_load(rf)
if second_node_name is None:
return ext_data[node_name]
else:
return ext_data[node_name][second_node_name]
except Exception as e:
logs.error(f"【extract.yaml】没有找到:{node_name},--%s" % e)
def get_testCase_baseInfo(self, case_info):
pass
def get_method(self):
yal_data = self.get_yaml_data()
metd = yal_data[0].get('method')
return metd
def get_request_parame(self):
data_list = []
yaml_data = self.get_yaml_data()
del yaml_data[0]
for da in yaml_data:
data_list.append(da)
return data_list
代码说明
- get_testcase_yaml:读取测试用例 YAML 文件
- get_yaml_data:读取当前对象的 yaml_file 内容并返回
- write_yaml_data:向固定路径 extract.yaml 追加写入一段 YAML 格式的内容。
- clear_yaml_data:清空 extract.yaml 文件内容
- get_extract_yaml:从 extract.yaml 中读取某个节点值
- get_method(self): 获取测试方法类型
- get_request_parame(self):获取请求参数列表
4. 核心执行方法
解析yaml测试用例,发送请求,执行断言,提取参数。
import json
import re
from json.decoder import JSONDecodeError
import allure
import jsonpath
from common.assertions import Assertions
from common.debugtalk import DebugTalk
from common.readyaml import get_testcase_yaml, ReadYamlData
from common.recordlog import logs
from common.sendrequest import SendRequest
from conf.operationConfig import OperationConfig
from conf.setting import FILE_PATH
class RequestBase:
def __init__(self):
self.run = SendRequest()
self.conf = OperationConfig()
self.read = ReadYamlData()
self.asserts = Assertions()
def replace_load(self, data):
str_data = data
if not isinstance(data, str):
str_data = json.dumps(data, ensure_ascii=False)
for i in range(str_data.count('${')):
if '${' in str_data and '}' in str_data:
start_index = str_data.index('$')
end_index = str_data.index('}', start_index)
ref_all_params = str_data[start_index:end_index + 1]
func_name = ref_all_params[2:ref_all_params.index("(")]
func_params = ref_all_params[ref_all_params.index("(") + 1:ref_all_params.index(")")]
extract_data = getattr(DebugTalk(), func_name)(*func_params.split(',') if func_params else "")
if extract_data and isinstance(extract_data, list):
extract_data = ','.join(e for e in extract_data)
str_data = str_data.replace(ref_all_params, str(extract_data))
if data and isinstance(data, dict):
data = json.loads(str_data)
else:
data = str_data
return data
def specification_yaml(self, base_info, test_case):
try:
params_type = ['data', 'json', 'params']
url_host = self.conf.get_section_for_data('api_envi', 'host')
api_name = base_info['api_name']
allure.attach(api_name, f'接口名称:{api_name}', allure.attachment_type.TEXT)
url = url_host + base_info['url']
allure.attach(api_name, f'接口地址:{url}', allure.attachment_type.TEXT)
method = base_info['method']
allure.attach(api_name, f'请求方法:{method}', allure.attachment_type.TEXT)
header = self.replace_load(base_info['header'])
allure.attach(api_name, f'请求头:{header}', allure.attachment_type.TEXT)
cookie = None
if base_info.get('cookies') is not None:
cookie = eval(self.replace_load(base_info['cookies']))
case_name = test_case.pop('case_name')
allure.attach(api_name, f'测试用例名称:{case_name}', allure.attachment_type.TEXT)
val = self.replace_load(test_case.get('validation'))
test_case['validation'] = val
validation = eval(test_case.pop('validation'))
extract = test_case.pop('extract', None)
extract_list = test_case.pop('extract_list', None)
for key, value in test_case.items():
if key in params_type:
test_case[key] = self.replace_load(value)
file, files = test_case.pop('files', None), None
if file is not None:
for fk, fv in file.items():
allure.attach(json.dumps(file), '导入文件')
files = {fk: open(fv, mode='rb')}
res = self.run.run_main(name=api_name, url=url, case_name=case_name, header=header, method=method,
file=files, cookies=cookie, **test_case)
status_code = res.status_code
allure.attach(self.allure_attach_response(res.json()), '接口响应信息', allure.attachment_type.TEXT)
try:
res_json = json.loads(res.text)
if extract is not None:
self.extract_data(extract, res.text)
if extract_list is not None:
self.extract_data_list(extract_list, res.text)
self.asserts.assert_result(validation, res_json, status_code)
except JSONDecodeError as js:
logs.error('系统异常或接口未请求!')
raise js
except Exception as e:
logs.error(e)
raise e
except Exception as e:
raise e
@classmethod
def allure_attach_response(cls, response):
if isinstance(response, dict):
allure_response = json.dumps(response, ensure_ascii=False, indent=4)
else:
allure_response = response
return allure_response
def extract_data(self, testcase_extarct, response):
try:
pattern_lst = ['(.*?)', '(.+?)', r'(\d)', r'(\d*)']
for key, value in testcase_extarct.items():
for pat in pattern_lst:
if pat in value:
ext_lst = re.search(value, response)
if pat in [r'(\d+)', r'(\d*)']:
extract_data = {key: int(ext_lst.group(1))}
else:
extract_data = {key: ext_lst.group(1)}
self.read.write_yaml_data(extract_data)
if '$' in value:
ext_json = jsonpath.jsonpath(json.loads(response), value)[0]
if ext_json:
extarct_data = {key: ext_json}
logs.info('提取接口的返回值:', extarct_data)
else:
extarct_data = {key: '未提取到数据,请检查接口返回值是否为空!'}
self.read.write_yaml_data(extarct_data)
except Exception as e:
logs.error(e)
def extract_data_list(self, testcase_extract_list, response):
try:
for key, value in testcase_extract_list.items():
if "(.+?)" in value or "(.*?)" in value:
ext_list = re.findall(value, response, re.S)
if ext_list:
extract_date = {key: ext_list}
logs.info('正则提取到的参数:%s' % extract_date)
self.read.write_yaml_data(extract_date)
if "$" in value:
# 增加提取判断,有些返回结果为空提取不到,给一个默认值
ext_json = jsonpath.jsonpath(json.loads(response), value)
if ext_json:
extract_date = {key: ext_json}
else:
extract_date = {key: "未提取到数据,该接口返回结果可能为空"}
logs.info('json提取到参数:%s' % extract_date)
self.read.write_yaml_data(extract_date)
except:
logs.error('接口返回值提取异常,请检查yaml文件extract_list表达式是否正确!')
if __name__ == '__main__':
case_info = get_testcase_yaml(FILE_PATH['YAML'] + '/LoginAPI/login.yaml')[0]
req = RequestBase()
res = req.specification_yaml(case_info)
print(res)
流程
读取 YAML -> 替换动态变量 -> 发送请求 -> 校验断言 -> 提取数据 -> 生成 Allure 报告
5.断言
断言方法封装,支持字符串包含、相等、不等、正则、数据库断言等。
import traceback
import allure
import jsonpath
import operator
from common.recordlog import logs
from common.connection import ConnectMysql
class Assertions:
def contains_assert(self, value, response, status_code):
flag = 0
for assert_key, assert_value in value.items():
if assert_key == "status_code":
if assert_value != status_code:
flag += 1
allure.attach(f"预期结果:{assert_value}\n实际结果:{status_code}", '响应代码断言结果:失败',
attachment_type=allure.attachment_type.TEXT)
logs.error("contains断言失败:接口返回码【%s】不等于【%s】" % (status_code, assert_value))
else:
resp_list = jsonpath.jsonpath(response, "$..%s" % assert_key)
if isinstance(resp_list[0], str):
resp_list = ''.join(resp_list)
if resp_list:
assert_value = None if assert_value.upper() == 'NONE' else assert_value
if assert_value in resp_list:
logs.info("字符串包含断言成功:预期结果【%s】,实际结果【%s】" % (assert_value, resp_list))
else:
flag = flag + 1
allure.attach(f"预期结果:{assert_value}\n实际结果:{resp_list}", '响应文本断言结果:失败',
attachment_type=allure.attachment_type.TEXT)
logs.error("响应文本断言失败:预期结果为【%s】,实际结果为【%s】" % (assert_value, resp_list))
return flag
def equal_assert(self, expected_results, actual_results, statuc_code=None):
flag = 0
if isinstance(actual_results, dict) and isinstance(expected_results, dict):
common_keys = list(expected_results.keys() & actual_results.keys())[0]
new_actual_results = {common_keys: actual_results[common_keys]}
eq_assert = operator.eq(new_actual_results, expected_results)
if eq_assert:
logs.info(f"相等断言成功:接口实际结果:{new_actual_results},等于预期结果:" + str(expected_results))
allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '相等断言结果:成功',
attachment_type=allure.attachment_type.TEXT)
else:
flag += 1
logs.error(f"相等断言失败:接口实际结果{new_actual_results},不等于预期结果:" + str(expected_results))
allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '相等断言结果:失败',
attachment_type=allure.attachment_type.TEXT)
else:
raise TypeError('相等断言--类型错误,预期结果和接口实际响应结果必须为字典类型!')
return flag
def not_equal_assert(self, expected_results, actual_results, statuc_code=None):
flag = 0
if isinstance(actual_results, dict) and isinstance(expected_results, dict):
common_keys = list(expected_results.keys() & actual_results.keys())[0]
new_actual_results = {common_keys: actual_results[common_keys]}
eq_assert = operator.ne(new_actual_results, expected_results)
if eq_assert:
logs.info(f"不相等断言成功:接口实际结果:{new_actual_results},不等于预期结果:" + str(expected_results))
allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '不相等断言结果:成功',
attachment_type=allure.attachment_type.TEXT)
else:
flag += 1
logs.error(f"不相等断言失败:接口实际结果{new_actual_results},等于预期结果:" + str(expected_results))
allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '不相等断言结果:失败',
attachment_type=allure.attachment_type.TEXT)
else:
raise TypeError('不相等断言--类型错误,预期结果和接口实际响应结果必须为字典类型!')
return flag
def assert_response_any(self, actual_results, expected_results):
flag = 0
try:
exp_key = list(expected_results.keys())[0]
if exp_key in actual_results:
act_value = actual_results[exp_key]
rv_assert = operator.eq(act_value, list(expected_results.values())[0])
if rv_assert:
logs.info("响应结果任意值断言成功")
else:
flag += 1
logs.error("响应结果任意值断言失败")
except Exception as e:
logs.error(e)
raise
return flag
def assert_response_time(self, res_time, exp_time):
try:
assert res_time < exp_time
return True
except Exception as e:
logs.error('接口响应时间[%ss]大于预期时间[%ss]' % (res_time, exp_time))
raise
def assert_mysql_data(self, expected_results):
flag = 0
conn = ConnectMysql()
db_value = conn.query_all(expected_results)
if db_value is not None:
logs.info("数据库断言成功")
else:
flag += 1
logs.error("数据库断言失败,请检查数据库是否存在该数据!")
return flag
def assert_result(self, expected, response, status_code):
all_flag = 0
try:
logs.info("yaml文件预期结果:%s" % expected)
for yq in expected:
for key, value in yq.items():
if key == "contains":
flag = self.contains_assert(value, response, status_code)
all_flag = all_flag + flag
elif key == "eq":
flag = self.equal_assert(value, response)
all_flag = all_flag + flag
elif key == 'ne':
flag = self.not_equal_assert(value, response)
all_flag = all_flag + flag
elif key == 'rv':
flag = self.assert_response_any(actual_results=response, expected_results=value)
all_flag = all_flag + flag
elif key == 'db':
flag = self.assert_mysql_data(value)
all_flag = all_flag + flag
else:
logs.error("不支持此种断言方式")
except Exception as exceptions:
logs.error('接口断言异常,请检查yaml预期结果值是否正确填写!')
raise exceptions
if all_flag == 0:
logs.info("测试成功")
assert True
else:
logs.error("测试失败")
assert False
代码说明
- contains_assert:包含断言,检查接口返回值中是否“包含”某些关键字
- equal_assert:相等断言,检查实际返回的字典中指定字段值与预期值是否相等
- not_equal_assert:不相等断言,检查实际返回的字典中指定字段值与预期值是否不相等
- assert_response_any:任意字段值断言,断言某个 key 在响应里存在,并且值相等于预期值
- assert_response_time:响应耗时断言,检查接口响应时间 < 预期时间
- assert_mysql_data:根据 SQL 查询数据库,检查是否有返回值
- assert_result:接收yaml里定义的validation列表,循环每个断言规则,调用对应的断言方法,汇总各个断言的返回 flag,判断最终结果