基于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={}&timestamp={}&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))
posted @ 2021-11-26 17:33  backlightズ  阅读(590)  评论(0编辑  收藏  举报