pytest+requests+Allure自动化框架

框架搭建思路:
使用python第三方requests库来处理接口的请求构建与响应结果获取
使用pytest来组建测试用例和执行测试用例脚本
使用yaml文件来编写请求数据和响应结果检查数据
使用pytest中的@pytest.mark.parameterize作为测试用例的加载引擎(参数化)
使用Allure作为结果的收集与展示

框架的大致执行流程:
读取yaml测试数据-->生成测试用例-->执行测试用例-->生成Allure报告
框架目录层级
image

1.yaml数据文件和配置文件
yaml数据文件格式如下:
image
配置文件config.ini定义了项目所有配置相关信息
image
config.py模块包含解析配置文件,添加和修改配置文件中的配置信息

点击查看代码
 """封装获取conf文件类"""
    CONFIG_BASE=os.path.dirname(os.path.abspath(__file__))
class Config:
    def __init__(self,filename):
        # 传入一个文件(ini格式)
        self.filename=filename
        # 创建自带的工具configparser(专门处理ini格式)来处理这个文件
        self.cf=configparser.ConfigParser()
        # 因为这个工具改变原来的大小写optionxform,所以要用这个工具自带的方法来不改变大小写
        self.cf.optionxform=str
        # 读取文件内容(要给文件路径,文件名)
        self.cf.read(CONFIG_BASE,filename)
    def get_runtime(self,section,option):
        """获取扇区内容-需要给扇区和选项,返回相应值"""
        self.cf.get(section,option)
    def set_runtime(self,section,option,value):
        """给指定的扇区设置对应的选项和值"""
        self.cf.set(section,option,value)
        # set方法的作用是:在内存中修改配置(还没写到文件里)
        # 直接操作文件就像「用记事本手动改配置」,容易出错且麻烦;
        # 而 set 方法配合 configparser 就像「用专门的配置编辑工具」,会自动处理格式、保留原有内容、应对各种特殊情况,让代码更简洁、可靠
        # 所以先用set方法,然后再对配置文件进行添加和修改
        with open(CONFIG_BASE,'w+') as f:
            self.cf.write(f)
    def add_section(self, section):
        """添加一个新扇区"""
        ## 向配置中添加一个新的section
        self.cf.add_section(section)
        #以读写的方式打开文件
        with open(self.log_path, 'w+') as f:
            #将配置写入文件返回值
            return self.cf.write(f)
    #self.cf.add_section(扇区):用于添加一个新的配置节 (扇区),比如在 INI 文件中添加 [Database] 这样的节
    #如果要设置键值对,必须先有对应的节,否则会报错
    #self.cf.set(section, option, value):用于在指定的节中设置键值对,比如在 [Database] 节中设置 host=localhost

    def get_runtime(self, option):
        return self.get_conf("runtime", option)

    def get_server(self, option):
        return self.get_conf("server", option)

    def get_db_test(self, option):
        return self.get_conf("db_test", option)

    def get_email(self, option):
        return self.get_conf("email", option)
2.common文件夹包含框架所用的基础模块信息: ![image](https://img2024.cnblogs.com/blog/3690937/202509/3690937-20250903164937309-1531659823.png)

consts.py模块,包含项目中所有的常量信息
image

datas.py模块包含获取yaml数据文件的函数
具体代码如下:

点击查看代码
"""读取yaml文件数据"""
import yaml
def get_yaml(file):
    """获取yaml文件内容"""
    with open(file,encoding='utf8') as f:
        yaml.safe_load(f)

def get_yaml_ids(file):
    """获取yaml文件里面的case_desc的值==ids"""
    #调用get_yaml()方法获取yaml里面的内容作为数据
    datas=get_yaml(file)
    #循环遍历数据里面的值,遍历一次返回一次case_desc的值
    for data in datas:
        #之所以使用yield,因为yield不会一次性返回所有case_desc的值,调用一次返回一次
        yield ['case_desc']
        #yield的作用是将函数变成一个装饰器,每迭代一次,返回一个值
def ids_data(file):
    """将返回的ids值处理成字典形式"""
    datas=get_yaml(file)
    ids=get_yaml_ids(file)
    return {"argvalues":datas,"ids":ids}
    #使用ids_data(file)方法,需要在用例里面给出文件地址,然后使用参数化形式将ids值传入
    #但是传入时需要解构(**ids_data(file)),这样ids的值就是ids,argvalues的值是datas
    #例如:file=os.path.join(文件地址,文件名),pytest.mark.parametrize(‘datas’,**ids_data(file))

log.py模块定义一个日志类,类中定义了日志内容的格式以及两种日志输出方式,一种是输出到文件,一种是输出到控制台
具体代码如下:

点击查看代码
"""
配置log输出格式 time - loglevel - file - func - line - msg
支持输出到log文件及屏幕
支持返回一个logger
"""

import time
import logging
import os
from common.consts import BASE_PATH as base_path
from config.config import Config


class Log:
    # 类方法。不需要实例化,可直接调用
    @classmethod
    # 不允许调用
    def _config(cls):
        # 读取配置文件方法Config()
        c = Config()
        #日志文件路径
        log_dir = os.path.join(base_path, c.get_runtime("log_dir"))
        #用当前时间年月日来命名
        now = time.strftime("%Y-%m-%d", time.localtime(time.time()))
        #拼接成完整的日志文件路径
        log_file = os.path.join(log_dir, now + ".log")

        # 获取一个标准的logger, 配置loglevel
        cls.logger = logging.getLogger()
        cls.logger.setLevel(eval("logging." + c.get_runtime("log_level").upper()))

        # 建立不同handler
        file_handler = logging.FileHandler(log_file, mode="a", encoding='utf-8')
        console_handler = logging.StreamHandler()

        # 定义输出格式
        format = logging.Formatter("%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s: %(message)s")
        file_handler.setFormatter(format)
        console_handler.setFormatter(format)

        # 把定制handler 添加到我们logger
        cls.logger.addHandler(file_handler)
        cls.logger.addHandler(console_handler)
     @classmethod
    #类方法,第一个参数通常命名为 cls,代表类本身(而不是实例)
    #可以通过类名直接调用(如 ClassName.get_logger()),也可以通过实例调用
    #提供一个统一的入口,获取已经配置好的日志记录器(logger)实例
    def get_logger(cls):
        cls._config()
        #返回类的 logger 属性,即已经配置好的日志记录器实例。
        #这里的 cls.logger 通常是类级别的属性(在类中定义,所有实例共享),用于统一管理日志操作。
        return cls.logger

mail.py模块定义了一个发送邮件的方法,邮件发送内容是将项目生成的报告打包当作邮件的附件发送出去
具体代码如下:

读取配置文件
c=Config()
获取已经配置好的日志记录器(logger)
logger=Log.get_logger()

发送邮件

点击查看代码
def send_email(report):
    msg=MIMEMultipart()
    msg['Subject']=c.get_email('subject')
    msg['From']=c.get_email('user')
    msg['To']=c.get_email('receiver')
    配置邮件正文
    content = c.get_server("report_url")
    mime_text = MIMEText(content, 'plain', 'utf-8')
    msg.attach(mime_text)

    report_path=os.path.join(REPORT_PATH,report)
    with open(report_path,"rb") as f:
        body=f.read()
        attach=MIMEBase('zip','zip',filename=report)
        attach.add_header('Content-Disposition','attachment',filename=('gb2312','',report))
        attach.set_payload(body)
        encoders.encode_base64(attach)
        msg.attach(attach)

    try:
    # 初始化SMTP对象(QQ邮箱需用SMTP_SSL,端口465)
            smtp = smtplib.SMTP_SSL(c.get_email( "server"), 465)  # 直接连接服务器+端口
            # 登录邮箱(用户+授权码)
            smtp.login(
                c.get_email( "user"),  # 发件人邮箱
                c.get_email("password")  # QQ邮箱需用授权码,不是登录密码
            )
            # 发送邮件(发件人、收件人、邮件内容)
            smtp.sendmail(
                msg["From"],
                msg["To"].split(','),  # 收件人转列表(支持多个)
                msg.as_string()
            )

    except Exception as e:
        print(e)
        print('发送失败')
        logger.error('邮件发送失败,请检查邮箱')
    else:
        print('发送成功')
        logger.info('邮件发送成功')
    finally:
        smtp.quit()

shell.py模块定义了一个执行windows命令的函数: 具体代码如下:
点击查看代码
"""
封装shell命令
"""

import subprocess


class Shell:
    @staticmethod
    def execute(cmd):
        output, errors = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
        out_msg = output.decode("gbk")
        return out_msg

tools.py模块包含一些项目用到的其他工具函数,包含md5加密、转换字符串、复制报告到服务器上

具体代码如下:

点击查看代码
def md5_hash(password):
    """md5加密"""
    return hashlib.md5(password.encode('utf-8')).hexdigest()

def read_yaml(filename):
    """获取yaml文件内容"""
    with open(filename, encoding='utf-8') as f:
        return yaml.safe_load(f)

def md5_code(passwd):
    """md5加密"""
    md5 = hashlib.md5()
    md5.update(passwd.encode("utf-8"))
    return md5.hexdigest()


def trans_str(s):
    """转换字符串"""
    return s.replace('{', '').replace('}', '').replace('": "', '=').replace('": ', '=').replace('":', '=').replace('"','').replace(',', ' ').strip()


def zip_dir(zip_file, start_dir=HTML_REPORT_PATH):
    # 压缩文件夹
    _zip_file = os.path.join(REPORT_PATH, zip_file)
    if os.path.exists(_zip_file):
        # 先删除
        os.remove(_zip_file)
    z = zipfile.ZipFile(_zip_file, 'w', zipfile.ZIP_DEFLATED)
    for dir_path, dir_names, file_names in os.walk(start_dir):
        fpath = dir_path.replace(start_dir, '')  # 不replace的话,就从根目录开始复制
        fpath = fpath and fpath + os.sep or ''
        for filename in file_names:
            z.write(os.path.join(dir_path, filename), fpath + filename)
    z.close()


def copy_report():
    """复制一份报告到服务器上"""
    source = HTML_REPORT_PATH
    target = Config().get_server('www')
    # 保证 target不存在
    if os.path.exists(target) and os.path.isdir(target):
        # 删除
        shutil.rmtree(target)
    # 开始拷贝
    shutil.copytree(source, target, copy_function=shutil.copy2)


if __name__ == '__main__':
    copy_report()

3.测试用例关联

很多时候,某一个接口要依赖接他接口,需要先执行其他接口(conftest.py模块里面的)后拿到返回值,再来调用新的接口,比如修改个人用户信息接口依赖登录后的token(使用fixture)
具体代码如下:
@pytest.fixture
def token():
"""拿到token,写成fixture,如果有用例需要使用token
直接在用例后面跟上拿取token的方法就行了,这就是脚手架,
不需要调用,只需要传入就可以使用"""
#准备数据
url = 'http://api.yesapi.net/api/App/User/Login'
method = 'post'
payload = {
"app_key": "704D5742A6416129CF6F25DFE61848DD",
"username": "ljj",
"password": "e10adc3949ba59abbe56e057f20f883e"
}
#发送请求
r=requests.get(url=url,params=payload)
#接收请求
#解析响应
#r.json()返回的是body里面的内容
body = r.json()
#r,status.code返回的是code状态码
code = r.status_code
#拿token
#token在返回值data里面,在body里面拿到data返回值,
#再从data返回值拿去token。使用 .get()方法拿取
body.get("data").get("token")

拿到token
接下来编写yaml测试用例信息
例如:
image

然后在cases文件夹编写以test_*.py命名的测试用例,用例中测试用例运行之前要先调用conftest.py模块中的token
fixture函数获取到token并完成接口请求

用例举例如下:

点击查看代码
data_file = os.path.join(DATA_PATH, "test_2005_vip_login.yaml")
class TestVipLogin:
    @pytest.mark.parametrize("datas", **datas_ids(data_file))
    def test_login(self, datas, token):
        url = datas.get('url')
        method = datas.get('method')
        payload = datas.get('payload')
        check = datas.get('check')

        # 如果配置需要token但未提供,则使用传入的token
        payload['token'] = token

        # 发送请求
        if method == 'GET':
            r = requests.get(url, params=payload)
        else:
            r = requests.post(url, data=payload)
        # 将响应转换为字符串
        response_text = r.text
        # 检查每个断言条件
        for condition in check:
            if '=' in condition:
                key, expected_value = condition.split('=', 1)

                # 构建JSON路径查找
                if key == 'err_msg':
                    # 特殊处理错误消息,可能在msg字段或data.err_msg字段
                    if f'"msg":"{expected_value}"' in response_text:
                        continue
                    if f'"err_msg":"{expected_value}"' in response_text:
                        continue
                    pytest.fail(f"未找到 {key}={expected_value},实际响应: {response_text}")
                else:
                    # 检查普通字段
                    if f'"{key}":{expected_value}' in response_text:
                        continue
                    if f'"{key}":"{expected_value}"' in response_text:
                        continue
                    pytest.fail(f"未找到 {key}={expected_value},实际响应: {response_text}")
            else:
                # 纯文本检查
                if condition not in response_text:
                    pytest.fail(f"未找到文本 '{condition}',实际响应: {response_text}")


if __name__ == '__main__':
    pytest.main(['-s', __file__])

4.执行模run.py

最后编写测试用例执行模块run.py,执行用例并生成报告,并将报告作为附件通过邮件发送出去

具体代码如下:

点击查看代码
if __name__ == '__main__':
    conf = config.Config()
    log = log.Log.get_logger()
    log.info('初始化配置文件, path=' + conf.log_path)

    shell = shell.Shell()
    xml_report_path = consts.XML_REPORT_PATH
    html_report_path = consts.HTML_REPORT_PATH
    cases_path = consts.CASE_PATH
    attach_file = 'report.zip'

    # 定义测试集
    args = ['-s', '-q', '--alluredir', xml_report_path, cases_path]
    pytest.main(args)

    cmd = 'allure generate {} -o {} --clean'.format(xml_report_path, html_report_path)

    try:
        xx = shell.execute(cmd)
    except Exception:
        log.error('执行用例失败,请检查环境配置')
        raise
    # 压缩report文件夹
    zip_dir(attach_file)
    # 准备服务,拷贝报告文件夹到wamp服务中。
    copy_report()
    try:
        mail = mail.send_email(attach_file)
    except Exception as e:
        log.error('发送邮件失败,请检查邮件配置')
        raise

posted @ 2025-09-03 19:15  木华9  阅读(26)  评论(0)    收藏  举报