基于Requests + Pytest + Yaml + Allure 实现Http协议接口自动化
Github地址 https://github.com/lixiaofeng1993/pytestProject
灵感来源
GitHub上的 pytestDemo 和 HttpRunner
目录结构
base ==>> requests请求,返回结果类,测试数据对象化封装
config ==>> 域名,固定变量,数据库链接
public ==>> 测试数据处理,全局变量替换,log,自定义异常类等公共方法
testcase ==>> 测试用例
data.yml ==>> 测试数据
parametrize_query.csv ==>> 参数化数据
用例设计
1.**局限于pytest参数化形式 `@pytest.mark.parametrize` ,每个测试用例只能对应一个参数化文件**
2.保证测试用例py文件的简洁,每个用例格式基本固定,代码量少
3.统一的YAML文件格式
4.参数化引用csv文件
用例基本格式
import pytest from public.send_request import SendRequest # 处理发送请求 from public.log import logger from public.sql_to_data import SqlToData # 处理测试数据 from public.help import get_data_path, os, fun_name, report_setting, report_step_setting, allure data_path = get_data_path(os.path.dirname(__file__)) # 返回当前 py 文件的绝对路径 test_params = SqlToData().yaml_db_query(data_path) # 返回对象化的测试数据 @allure.severity(allure.severity_level.TRIVIAL) # 测试类等级 @allure.epic(test_params.get("epic")) # allure报告一级目录 @allure.feature(test_params.get("feature")) # allure报告二级目录 class TestUsersCase: def setup_class(self): self.extract = {} # 全局变量
# 参数化用例格式 @pytest.mark.parametrize("data", test_params["test_register_user_case"].parametrize) # pytest参数化装饰器 def test_register_user_case(self, data): logger.info("*************** 开始执行用例 ***************") # 获取执行用例函数名 name = fun_name() # 报告展示的测试步骤 report_step_setting(test_params[name]) test_params[name].parametrize = data result, self.extract = SendRequest(test_params[name], self.extract).send_request() # 报告上展示的测试标题等 report_setting(test_params[name]) logger.info("*************** 结束执行用例 ***************\n")
# 有依赖的参数化用例格式
@pytest.mark.parametrize("data", test_params["test_one_user_case"].parametrize) def test_one_user_case(self, data): logger.info("*************** 开始执行用例 ***************") # 获取执行用例函数名 name = fun_name() # 报告展示的测试步骤 report_step_setting(test_params[name].case_step_1) # 登录接口 result, self.extract = SendRequest(test_params[name].case_step_1, self.extract).send_request() report_step_setting(test_params[name]) test_params[name].parametrize = data result, self.extract = SendRequest(test_params[name], self.extract).send_request() # 报告上展示的测试标题等 report_setting(test_params[name]) logger.info("*************** 结束执行用例 ***************\n")
# 非参数化用例格式 def test_all_user_case(self, test_data): logger.info("*************** 开始执行用例 ***************") # 报告展示的测试步骤 report_step_setting(test_data.case_step_1) # 登录接口 result, self.extract = SendRequest(test_data.case_step_1, self.extract).send_request() report_step_setting(test_data.case_step_2) result, self.extract = SendRequest(test_data.case_step_2, self.extract).send_request() # 报告上展示的测试标题等 report_setting(test_data.case_step_2) logger.info("*************** 结束执行用例 ***************\n")
单接口 YAML 文件参数化
test_register_user_case: path: /register method: post headers: validate: &validate - [ comparator: equal, check: msg, expect: "恭喜,注册成功!", jsonpath: "$.msg" ] validate_username: &validate_username - [ comparator: equal, check: msg, expect: "用户名/密码/手机号不能为空,请检查!!!", jsonpath: "$.msg" ] validate_username_exit: &validate_username_exit - [ comparator: contains, check: msg, expect: "用户名已存在", jsonpath: "$.msg" ] validate_phone: &validate_phone - [ comparator: contains, check: msg, expect: "手机号格式不正确", jsonpath: "$.msg" ] validate_sex: &validate_sex - [ comparator: contains, check: msg, expect: "输入的性别只能是 0(男) 或 1(女)", jsonpath: "$.msg" ] validate_phone_exit: &validate_phone_exit - [ comparator: contains, check: msg, expect: "手机号已被注册", jsonpath: "$.msg" ] parametrize: - [ username: __name, password: "123456", sex: "__random_int(0, 1)", telephone: __phone, address: __address, validate: *validate ] - [ username: "", password: "123456", sex: "__random_int(0, 1)", telephone: __phone, address: __address, validate: *validate_username ] - [ username: sql_one_user, password: "123456", sex: "__random_int(0, 1)", telephone: __phone, address: __address, validate: *validate_username_exit ] - [ username: __name, password: "123456", sex: "__random_int(0, 1)", telephone: __random_int, address: __address, validate: *validate_phone ] - [ username: __name, password: "123456", sex: "__random_int(2, 9)", telephone: __phone, address: __address, validate: *validate_sex ] - [ username: __name, password: "123456", sex: "__random_int(0, 1)", telephone: sql_one_phone, address: __address, validate: *validate_phone_exit ] params: upload: extract: story: 用例-注册接口 title: 注册接口 step: 注册接口测试 description: 该用例是针对 注册接口 的测试 sql: sql_one_user: SELECT u.username from `user` u LIMIT 1 sql_one_phone: SELECT u.telephone from `user` u LIMIT 1 epic: 用户数据测试 feature: 测试Demo
单接口 CSV 文件参数化
test_register_user_case:
path: /register
method: post
headers:
parametrize: ${parametrize_register.csv}
params:
upload:
extract:
story: 用例-注册接口
title: 注册接口
step: 注册接口测试
description: 该用例是针对 注册接口 的测试
sql:
sql_one_user: SELECT u.username from `user` u LIMIT 1
sql_one_phone: SELECT u.telephone from `user` u LIMIT 1
epic: 用户数据测试
feature: 测试Demo
parametrize_register.csv文件数据
case_name,username,password,sex,telephone,address,,msg,code
注册成功,__name,123456,"__random_int(0, 1)",__phone, __address,,"{""comparator"": ""equal"",""expect"": ""恭喜,注册成功!"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""0"",""jsonpath"":""""}"
用户名/密码/手机号不能为空,,123456,"__random_int(0, 1)",__phone, __address,,"{""comparator"": ""equal"",""expect"": ""用户名/密码/手机号不能为空,请检查!!!"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""2001"",""jsonpath"":""""}"
用户名已存在,sql_one_user,123456,"__random_int(0, 1)",__phone, __address,,"{""comparator"": ""contains"",""expect"": ""用户名已存在"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""2002"",""jsonpath"":""""}"
手机号格式不正确,__name,123456,"__random_int(0, 1)",__random_int, __address,,"{""comparator"": ""contains"",""expect"": ""手机号格式不正确"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""2004"",""jsonpath"":""""}"
输入的性别格式错误,__name,123456,"__random_int(2, 9)",__phone, __address,,"{""comparator"": ""contains"",""expect"": ""输入的性别只能是 0(男) 或 1(女)"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""2003"",""jsonpath"":""""}"
手机号已被注册,__name,123456,"__random_int(0, 1)",sql_one_phone, __address,,"{""comparator"": ""contains"",""expect"": ""手机号已被注册"",""jsonpath"":""""}","{""comparator"": ""equal"",""expect"": ""2005"",""jsonpath"":""""}"
有依赖的接口参数化
test_one_user_case: case_step_1: path: /login method: post headers: parametrize: params: json: username: sql_one_user password: "123456" upload: extract: token: $.login_info.token username: $.login_info.username validate: - [ comparator: equal, check: msg, expect: "恭喜,登录成功!", jsonpath: "$.msg" ] - [ comparator: equal, check: code, expect: 0, jsonpath: "$.code" ] story: 用例-登录接口 title: 登录接口 step: 登录接口测试 description: 该用例是针对 登录接口 的测试 path: /get/user method: get headers: token: $token username: $username validate: &validate - [ comparator: equal, check: msg, expect: "查询成功", jsonpath: "$.msg" ] - [ comparator: equal, check: code, expect: 0, jsonpath: "$.code" ] validate_username: &validate_username - [ comparator: equal, check: msg, expect: "查不到相关用户的信息", jsonpath: "$.msg" ] - [ comparator: equal, check: code, expect: 1004, jsonpath: "$.code" ] parametrize: - [ username: sql_one_user, validate: *validate ] - [ username: __name, validate: *validate_username ] params: upload: extract: story: 用例-查询指定用户信息接口 title: 查询指定用户信息接口 step: 查询指定用户信息接口测试 description: 该用例是针对 查询指定用户信息接口 的测试 sql: sql_one_user: SELECT u.username from `user` u LIMIT 1 epic: 用户数据测试 feature: 测试Demo
非参数化接口,存在依赖
test_all_user_case:
case_step_1:
path: /login
method: post
headers:
parametrize:
params:
json:
username: sql_one_user
password: "123456"
upload:
extract:
token: $.login_info.token
username: $.login_info.username
validate:
- [ comparator: equal, check: msg, expect: "恭喜,登录成功!", jsonpath: "$.msg" ]
- [ comparator: equal, check: code, expect: 0, jsonpath: "$.code" ]
story: 用例-登录接口
title: 登录接口
step: 登录接口测试
description: 该用例是针对 登录接口 的测试
case_step_2:
path: /users
method: get
headers:
token: $token
username: $username
parametrize:
params:
json:
upload:
validate:
- [ comparator: equal, check: msg, expect: "查询成功", jsonpath: "$.msg" ]
- [ comparator: equal, check: code, expect: 0, jsonpath: "$.code" ]
story: 用例-查询所有用户信息接口
title: 查询所有用户信息接口
step: 查询所有用户信息接口
description: 该用例是针对 查询所有用户信息接口 的测试
sql:
sql_one_user: SELECT u.username from `user` u LIMIT 1
epic: 用户数据测试
feature: 测试Demo
测试数据对象化封装
def object_data(test_data: dict, file_path: str, case_step_num=10): """ 封装测试数据为对象 :param test_data: 测试数据 :param file_path: 测试数据文件路径 :param case_step_num: 测试用例依赖接口数量 :return: 字典包含的数据对象 """ obj = dict() case_step_list = list() case_step_num = int(case_step_num) if str(case_step_num).isdigit() else 10 case_step_num = 10 if case_step_num < 10 else case_step_num for i in range(1, case_step_num + 1): case_step_list.append(f"case_step_{i}") for keys, values in test_data.items(): obj[keys] = ObjectData() if isinstance(values, dict): for key, value in values.items(): setattr(obj[keys], key, value) setattr(obj[keys], "file_path", file_path) if isinstance(value, dict): step = CaseStep() for k, v in value.items(): setattr(step, k, v) setattr(step, "file_path", file_path) if key in case_step_list: setattr(obj[keys], key, step) obj.update({ "epic": test_data.get("epic"), "feature": test_data.get("feature") }) return obj
allure报告
一、yml文件动态加载debug.py中的函数
1.render.py
# render.py import os import jinja2 import importlib import inspect def render(tpl_path, **kwargs): """渲染yml文件""" path, filename = os.path.split(tpl_path) return jinja2.Environment(loader=jinja2.FileSystemLoader(path or './') ).get_template(filename).render(**kwargs) def all_functions(): """加载debug.py模块""" debug_module = importlib.import_module("debug") all_function = inspect.getmembers(debug_module, inspect.isfunction) return dict(all_function)
2.修改读取yml文件的方法
class ReadFileData: def __init__(self): pass def load_yaml(self, file_path: str) -> dict: """ 加载yml文件数据 :param file_path: 文件路径 :return: """ # logger.info(f"加载 {file_path} 文件......") f = render(file_path, **all_functions()) data = yaml.safe_load(f) # with open(check(file_path), encoding='utf-8') as f: # try: # data = yaml.safe_load(f) # except yaml.YAMLError as ex: # raise exceptions.FileFormatError("file: {} error: {}".format(file_path, ex)) # logger.info(f"读到数据 ==>> {data} ") return data
二、前置获取token和后置销毁token
conftest.py
import json import pytest import requests import os from public.read_data import ReadFileData from public.sign import decrypt from public.log import loggerfrom public.exceptions import ResponseError @pytest.fixture(scope="session", autouse=True) def test_token(): """ 前置获取token,后置销毁token """ read = ReadFileData() host = read.get_host() login_url = host + "/users/login" login_data = { "username": "lixiaofeng", "password": "123456" } # 登录获取token login_res = requests.post(login_url, login_data) result = login_res.json().get("result") if not result: raise ResponseError(f"登录接口返回出现异常 -->> {login_res.json()}") data = json.loads(decrypt(result)) token = data.get("access_token") logger.info(f"登录接口 -->> token:{token}") os.environ["token"] = token yield # 退出登录 logout_url = host + "/users/logout" logout_headers = { "Authorization": f"Bearer {token}" } logout_res = requests.post(logout_url, headers=logout_headers) logger.info(f"退出登录接口 -->> 返回值: {logout_res.json()}")
三、处理接口返回值加密的情况
1.data.yml中增加变量 sign、sign_path
test_one_user_case:
path: /users/$user_id
method: get
headers:
Authorization: Bearer {{ token() }}
variable:
sign: true
sign_path: $.result
parametrize:
params:
upload:
extract:
validate:
- [ comparator: equal, check: user_id, expect: $user_id, jsonpath: "$.result.id" ]
story: 用例-获取指定用户信息接口
title: 获取指定用户信息接口
step: 获取指定用户信息接口
description: 该用例是针对 获取指定用户信息接口 的测试
2.获取接口返回值中的加密部分,并解密
def encrypted_result(result: dict, variables: dict) -> dict: """ 处理接口返回值加密的情况 :param result: :param variables: :return: """ sign_path = variables.get("sign_path") if not sign_path: raise exceptions.ResponseError(f"未设置sign_path变量!") try: sign_data = jsonpath(result, sign_path)[0] except Exception as error: raise exceptions.ResponseError(f"从返回值中提取加密字符串失败!==>> {error}") sign_text = eval(str(result).replace(sign_data, "$sign_data")) variables.update({"sign_data": decrypt(sign_data)}) result = recursion_handle(sign_text, variables) logger.info(f"解密后的数据 ==>> {str(result)[:500]}") return result
四、用例执行结果发送到钉钉
1.conftest.py 中定义HOOK函数
from public.send_ding import send_ding def pytest_terminal_summary(terminalreporter, exitstatus, config): """ 收集测试结果 """ send_ding(terminalreporter)
2.send_ding.py
import hmac import urllib.parse import hashlib import base64 import requests import urllib3 import time from public.log import logger from public.read_data import ReadFileData urllib3.disable_warnings() read = ReadFileData() def ding_sign(): """ 发送钉钉消息加密 :return: """ timestamp = str(round(time.time() * 1000)) secret = "xxxxx" secret_enc = secret.encode('utf-8') string_to_sign = '{}\n{}'.format(timestamp, secret) string_to_sign_enc = string_to_sign.encode('utf-8') hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest() sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) return timestamp, sign def send_ding(terminalreporter): """ 发送钉钉消息 :param plugin: :return: """ headers = {"Content-Type": "application/json"} access_token = "xxxxxxx" timestamp, sign = ding_sign() # 发送内容 total = terminalreporter._numcollected passed = len([i for i in terminalreporter.stats.get('passed', []) if i.when != 'teardown']) failed = len([i for i in terminalreporter.stats.get('failed', []) if i.when != 'teardown']) error = len([i for i in terminalreporter.stats.get('error', []) if i.when != 'teardown']) skipped = len([i for i in terminalreporter.stats.get('skipped', []) if i.when != 'teardown']) rate = passed / total * 100 # terminalreporter._sessionstarttime 会话开始时间 duration = time.time() - terminalreporter._sessionstarttime system = read.get_system() flag = read.get_flag() url = system.get("allure_test_url", None) if flag == "0" else system.get("allure_url", None) ding = system.get("ding", None) body = { "msgtype": "text", "text": { "content": f"接口测试报告 持续时长 {str(duration)[:-12]} 秒。\n 用例共 {total} 条,通过 {passed} 条," f"失败 {failed} 条,错误 {error} 条,跳过 {skipped} 条,成功率 {rate} %.\n 详情前往地址:{url} 查看。" } } if ding == "true": res = requests.post( "https://oapi.dingtalk.com/robot/send?access_token={}×tamp={}&sign={}".format( access_token, timestamp, sign), headers=headers, json=body, verify=False).json() if res["errcode"] == 0 and res["errmsg"] == "ok": logger.info("钉钉通知发送成功!info:{}".format(body["text"]["content"])) else: logger.error("钉钉通知发送失败!返回值:{}".format(res))
愿你走出半生,归来仍是少年!