【接口自动化】-7- 热加载和日志封装

一、实现用例标准化处理

新建main_util.py文件:

python 复制代码
def stand_case_flow(caseinfo):
    # 1. 校验用例数据
    case_obj = verify_yaml(caseinfo)
    
    # 2. 替换请求中的变量(如 ${access_token})
    new_request = eu.change(case_obj.request)
    
    # 3. 发送接口请求
    res = ru.send_all_request(**new_request)
    
    # 4. 从响应中提取变量,存入 extract.yaml
    if case_obj.extract:
        for key, value in case_obj.extract.items():
            eu.extract(res, key, *value)

这是一个标准化用例执行函数,不管你的用例是登录、支付还是文件上传,都能按这套流程执行,实现了 "一次封装,多次复用"。

1. 为什么要 "标准化流程"?
  • 减少重复代码:不管多少个用例,都用同一套流程执行,不用每个用例都写一遍 "校验→替换→发送→提取"。
  • 降低维护成本 :如果需要修改流程(如添加统一日志、全局断言 ),只需要改 stand_case_flow 这一个函数,所有用例自动生效。
  • 提升可读性:用例执行流程清晰明了,新人接手时能快速理解 "用例是怎么跑起来的"。
2. 如何扩展这套流程?
  • 添加全局日志 :在 stand_case_flow 里,发送请求前后记录日志(如 print(f"发送请求:{new_request}") )。
  • 添加统一断言 :在发送请求后,检查响应状态码(如 assert res.status_code == 200 )。
  • 支持更多协议 :如果需要测试 WebSocket,可以扩展 send_all_request,让它支持 WebSocket 请求。

对比之前的 create_testcase,新的 stand_case_flow 做了这些优化:

  1. 流程解耦
    把 "校验→替换→发送→提取" 的流程单独封装成函数,不再和 pytest 的用例生成逻辑混在一起,代码更清晰。
  2. 职责单一化
    create_testcase 只负责动态生成用例stand_case_flow 只负责执行用例,符合 "单一职责原则"。
  3. 可测试性提升
    stand_case_flow 可以单独调用(不需要依赖 pytest 运行 ),方便编写单元测试,确保流程逻辑正确。

二、热加载

⭐ 核心问题:为什么需要 "热加载替换"?

之前用 Template.safe_substitute 有 2 个致命缺陷

  1. 类型丢失 :如果变量是数字字符串(如 '123' ),替换后会变成整数 123,但接口可能需要字符串类型(如 '123' )。
  2. 无法二次处理:替换后的值不能直接加密、加随机数。

热加载替换 就是为了解决这些问题,让你可以在 YAML 里直接调用 Python 函数(如 ${read_yaml(access_token)} ),实现:

  • 保留字符串类型(如 '123' 不会变成 123 )。
  • 对变量进行二次处理(加密、生成随机数、拼接字符串 )。

⭐ 设计思想:"在 YAML 里写 Python 函数"

核心灵感来自 HttpRunner 框架 :允许在 YAML/JSON 用例中嵌入 Python 函数调用,语法是 ${函数名(参数)},比如:

  • ${read_yaml(access_token)}:调用 DebugTalk.read_yaml('access_token')
  • ${add(1,2)}:调用 DebugTalk.add(1,2)

这样做的好处:

  1. 零代码扩展:测试人员不用改 Python 代码,只需在 YAML 里写函数调用,就能实现复杂逻辑(加密、随机数 )。
  2. 类型可控 :Python 函数返回值类型由自己控制(如返回字符串 '123' 而不是整数 123 )。

⭐ 代码流程:热加载替换的完整链路

1. 核心文件关系
文件 作用 关键关联
extract_util.py 实现热加载替换逻辑 调用 DebugTalk 类的函数,处理 YAML 中的 ${函数()}
debug_talk.py 存放可在 YAML 中调用的 Python 函数 read_yaml(读 extract.yaml )、add(加法 )、get_random_number(生成随机数 )
extract.yaml 存储提取的变量(如 access_token debug_talk.pyread_yaml 函数读取

关系总结
extract_util.py 解析 YAML 中的 ${函数()},调用 debug_talk.py 的函数处理,读取 extract.yaml 的数据,最终替换 YAML 中的占位符。

2. 热加载替换完整流程
python 复制代码
def hotload_replace(self, data_str: str):
    # 1. 正则匹配 YAML 中的 `${函数名(参数)}`
    regexp = r"\$\{(.+?)\((.*?)\)\}"  # 匹配 `${read_yaml(access_token)}`
    fun_list = re.findall(regexp, data_str)  # 结果:[('read_yaml', 'access_token')]

    # 2. 遍历所有匹配的函数调用
    for func_name, params in fun_list:
        # 3. 调用 DebugTalk 类的函数
        # 3.1 无参数(如 `${get_random_number()}` )
        if not params:
            value = getattr(DebugTalk(), func_name)()
        # 3.2 有参数(如 `${read_yaml(access_token)}` )
        else:
            # 参数按逗号分割(如 `1,2` → [1,2] )
            params_list = params.split(',')  
            value = getattr(DebugTalk(), func_name)(*params_list)

        # 4. 处理返回值类型(确保数字字符串不会变成整数)
        if isinstance(value, str) and value.isdigit():
            value = f"'{value}'"  # 如 `123` → `'123'`

        # 5. 替换 YAML 字符串中的 `${函数()}`
        old_str = f"${{{func_name}({params})}}"  # 原字符串:${read_yaml(access_token)}
        data_str = data_str.replace(old_str, str(value))  # 替换为函数返回值

    return data_str

流程拆解

  • 正则匹配 :找到 YAML 字符串中所有 ${函数(参数)} 格式的内容。
  • 调用函数 :通过 getattr(DebugTalk(), 函数名) 动态调用 Python 函数。
  • 处理类型 :确保数字字符串(如 '123' )不会变成整数。
  • 替换字符串 :把 ${函数()} 替换为函数返回值,生成新的 YAML 字符串。
3. 如何影响接口请求

change 函数中,不再使用 Template 替换,而是调用 hotload_replace

python 复制代码
def change(self, request_data: dict):
    # 1. 字典转 YAML 字符串
    data_str = yaml.safe_dump(request_data)  
    # 2. 热加载替换(替换 `${函数()}` 为 Python 函数返回值)
    new_data_str = self.hotload_replace(data_str)  
    # 3. YAML 字符串转回字典
    return yaml.safe_load(new_data_str)  

效果

YAML 中的 ${read_yaml(access_token)} 会被替换为 debug_talk.pyread_yaml('access_token') 的返回值(从 extract.yaml 读取的真实值 )。

4. 处理加密、随机数

debug_talk.py 中添加加密函数(如 md5 ):

python 复制代码
import hashlib

class DebugTalk:
    def md5(self, text):
        md5 = hashlib.md5()
        md5.update(text.encode())
        return md5.hexdigest()  # 返回 md5 加密后的字符串

在 YAML 中调用:

TypeScript 复制代码
params:
  sign: ${md5(access_token)}  # 调用 DebugTalk.md5(access_token)

效果
access_token 会先被读取,再通过 md5 函数加密,最终作为 sign 参数发送给接口。

5. 解决 "数字字符串类型丢失"

hotload_replace 中,有一段关键代码:

python 复制代码
if isinstance(new_value, str) and new_value.isdigit():
    new_value = f"'{new_value}'"  # 如 `123` → `'123'`

作用

确保从 Python 函数返回的数字字符串(如 '123' ),在替换后仍然是字符串类型(保留单引号),不会被 YAML 解析成整数。


⭐ 各文件代码详细讲解(逐文件拆解)

1. debug_talk.py
python 复制代码
import time
import yaml

class DebugTalk:
    def read_yaml(self, key):
        """读取 extract.yaml 的值"""
        with open("extract.yaml", encoding="utf-8") as f:
            value = yaml.safe_load(f)
            return value[key]  # 返回字典中的值(如 extract.yaml 的 access_token)

    def add(self, a, b):
        """加法运算"""
        return str(int(a) + int(b))  # 返回字符串类型(如 `'3'` 而不是 `3`)

    def get_random_number(self):
        """生成随机数(时间戳)"""
        return str(int(time.time()))  # 返回字符串类型的时间戳

    def md5(self, text):
        """MD5 加密"""
        import hashlib
        md5 = hashlib.md5()
        md5.update(text.encode())
        return md5.hexdigest()  # 返回加密后的字符串

关键设计

所有函数返回值都是字符串类型 ,确保 YAML 解析后类型正确;通过 getattr 动态调用,支持扩展任意函数。

2. extract_util.py
python 复制代码
import re
import yaml
from debug_talk import DebugTalk

class ExtractUtil:
    def hotload_replace(self, data_str: str):
        # 正则匹配 `${函数名(参数)}`,如 `${read_yaml(access_token)}`
        regexp = r"\$\{(.+?)\((.*?)\)\}"  
        # 找到所有匹配的函数调用,返回列表,如 [('read_yaml', 'access_token')]
        fun_list = re.findall(regexp, data_str)  

        for func_name, params in fun_list:
            # 动态调用 DebugTalk 的函数
            dt = DebugTalk()
            if not params:
                # 无参数,如 `${get_random_number()}`
                new_value = getattr(dt, func_name)()  
            else:
                # 有参数,如 `${add(1,2)}`,分割参数为列表
                params_list = params.split(',')  
                new_value = getattr(dt, func_name)(*params_list)  

            # 处理数字字符串,确保类型是字符串(如 '123' → "'123'")
            if isinstance(new_value, str) and new_value.isdigit():
                new_value = f"'{new_value}'"  

            # 替换原字符串中的 `${函数()}` 为新值
            old_str = f"${{{func_name}({params})}}"  
            data_str = data_str.replace(old_str, str(new_value))  

        return data_str

动态调用逻辑

通过 getattr(DebugTalk(), func_name),可以在运行时动态调用任意函数(如 read_yamladd ),实现 "零代码扩展"。

3. YAML 用例写法
TypeScript 复制代码
request:
  method: post
  url: https://api.weixin.qq.com/cgi-bin/media/uploading
  params:
    # 调用 DebugTalk.read_yaml('access_token')
    access_token: ${read_yaml(access_token)}  
  files:
    media: "E:\\shu.png"

效果
access_token 会被替换为 debug_talk.pyread_yaml('access_token') 的返回值(从 extract.yaml 读取的真实值 )。


⭐ 总结:热加载替换的价值

  1. 解决模板替换缺陷:保留字符串类型,支持二次处理(加密、随机数 )。
  2. 零代码扩展:测试人员只需改 YAML,就能调用 Python 函数实现复杂逻辑。
  3. 流程更灵活:从 "固定变量替换" 升级为 "动态函数调用",适配更多接口场景(如加密接口、随机数接口 )。

核心就是 "在 YAML 里写 Python 函数调用,通过动态反射执行函数,替换字符串",实现了 "用 YAML 控制 Python 逻辑" 的黑魔法 ✨

三、热加载代码实现的详细解析

1. fun_list = re.findall(regexp, data_str) 要是有多个函数调用 返回的列表是什摸样的?

data_str 中存在多个函数调用时,re.findall(regexp, data_str) 返回的 fun_list 是一个嵌套元组的列表,每个元组对应一个函数调用的信息(函数名 + 参数)。

假设 data_str 包含 3 个函数调用:

python 复制代码
data_str = """
params:
  token: ${read_yaml(access_token)}
  sign: ${md5(token, timestamp)}
  random: ${get_random(100, 999)}
"""

正则表达式匹配逻辑

使用的正则 regexp = r"\$\{(.+?)\((.*?)\)\}" 会捕获:

  • 第一个分组 (.+?):匹配函数名(如 read_yamlmd5
  • 第二个分组 (.*?):匹配函数参数(如 access_tokentoken, timestamp

fun_list 的返回结果

python 复制代码
fun_list = [
    ('read_yaml', 'access_token'),       # 第一个函数:read_yaml(access_token)
    ('md5', 'token, timestamp'),         # 第二个函数:md5(token, timestamp)
    ('get_random', '100, 999')           # 第三个函数:get_random(100, 999)
]

2. for func_name, params in fun_list:这个是什么语法?

for func_name, params in fun_list: 是 Python 中序列解包(Sequence Unpacking) 的语法,专门用于遍历包含元组 / 列表的可迭代对象 (如 fun_list 这种 "列表套元组" 的结构)。

通俗理解:"一次拆包两个变量"

假设 fun_list 是这样的列表(包含多个元组):

python 复制代码
fun_list = [
    ('read_yaml', 'access_token'),
    ('md5', 'token, timestamp'),
    ('get_random', '100, 999')
]

循环时:

python 复制代码
for func_name, params in fun_list:
    print(f"函数名:{func_name},参数:{params}")

执行过程:

  1. 第一次循环:取列表第一个元组 ('read_yaml', 'access_token')

    • 自动将元组第一个元素赋值给 func_namefunc_name = 'read_yaml'
    • 自动将元组第二个元素赋值给 paramsparams = 'access_token'
  2. 第二次循环:取列表第二个元组 ('md5', 'token, timestamp')

    • func_name = 'md5'
    • params = 'token, timestamp'
  3. 第三次循环:取列表第三个元组 ('get_random', '100, 999')

    • func_name = 'get_random'
    • params = '100, 999'

为什么这样写?

如果不用解包,代码会更繁琐:

python 复制代码
# 不用解包的写法(更麻烦)
for item in fun_list:
    func_name = item[0]  # 手动取元组第一个元素
    params = item[1]     # 手动取元组第二个元素
    print(f"函数名:{func_name},参数:{params}")

for func_name, params in fun_list: 直接一步完成 "取元素 + 赋值",让代码更简洁、可读性更高。

适用场景

这种语法只适用于列表中的元素是 "长度固定的元组 / 列表" 的情况:

  • 如果元组有 3 个元素,就需要 3 个变量接收:for a, b, c in list_of_tuples:
  • 如果元组长度不固定,会报错(如 ValueError: too many values to unpack)。

3. "反射"(getattr 函数的使用)是核心,它实现了 "根据字符串动态调用对象方法" 的黑魔法。

getattr(object, name) 是 Python 的内置函数,作用是 "根据字符串 name,获取对象 object 中的属性或方法"

举例:

func_name 是字符串 'get_random_number' 时:

python 复制代码
func = getattr(dt, 'get_random_number')  # 等价于直接写 dt.get_random_number

反射让代码 "活" 了起来 :不管 YAML 里写什么函数名,只要 DebugTalk 类里有这个方法,就能通过 getattr(dt, func_name) 动态找到并调用,不用修改 Python 代码。

4. 类型判断函数

isinstance(new_value, str)

  • 作用:判断 new_value 是否是字符串类型str)。
  • 场景:因为 .isdigit() 是字符串的方法,只有 new_value 是字符串时,才能调用这个方法。
  • 举例:
    • new_value = "123"isinstance(new_value, str)True
    • new_value = 123(整数)→ isinstance(new_value, str)False

new_value.isdigit()

  • 作用:判断字符串是否由纯数字组成(0-9)。

  • 场景:区分 "数字字符串"(如 "123")和 "普通字符串"(如 "abc""12a")。

  • 举例:

    • "123".isdigit()True
    • "abc".isdigit()False
    • "12a".isdigit()False

5. 字符串替换的核心逻辑,把 YAML 中 ${函数名(参数)} 格式的占位符,替换成函数执行后的真实值(new_value)

先看 old_str = f"${``{``{func_name}({params})}}":拼接原始占位符

这行代码用f-string 格式化字符串 ,重新拼接出 YAML 中原始的函数调用占位符(如 ${read_yaml(access_token)})。

语法拆解:为什么有三个 { 和三个 }

  • f-string 中,{} 是用于嵌入变量的标记(如 f"{name}" 会替换成变量 name 的值)。
  • 但我们最终需要的占位符是 ${函数名(参数)},其中包含 {} 这两个字符,所以需要转义
    • {``{ 表示一个真实的 { 字符
    • }} 表示一个真实的 } 字符

举例:当 func_name = "read_yaml"params = "access_token"

python 复制代码
old_str = f"${{{func_name}({params})}}"
# 拆解:
# 1. 变量替换:{func_name} → "read_yaml",{params} → "access_token"
# 2. 转义处理:${{ → "${",}} → "}"
# 最终结果:old_str = "${read_yaml(access_token)}"

再看 data_str = data_str.replace(old_str, str(new_value)):替换占位符

这行代码调用字符串的 replace 方法,把 data_str 中所有 old_str(原始占位符)替换成 new_value(函数执行后的结果)。

四、日志封装

⭐ 为什么要做日志封装?

直接 print 调试信息有这些问题:

  • 日志散在控制台,跑大量用例后找不到关键信息;
  • 没有级别区分(DEBUG/INFO/WARNING),分不清哪些是普通信息、哪些是报错;
  • 无法持久化保存,测试结束控制台清空就没了。

封装后

  • 日志按级别分类,能快速筛选关键错误;
  • 自动写入文件,随时复盘历史用例执行情况;
  • 格式统一(带时间、级别、文件名),排查问题更高效。

⭐ 三步实现日志封装(逐行拆解)

1. 第一步:pytest.ini 配置日志基础规则

ini

TypeScript 复制代码
# 日志配置
log_file = "./logs/frame.log"  # 日志文件路径(相对路径,会生成到项目的 logs 目录)
log_file_level = INFO          # 文件里记录的日志级别(INFO及以上才存:INFO/WARNING/ERROR)
log_file_format = %(asctime)s %(levelname)s %(filename)s %(message)s  
# 日志格式:时间+级别+文件名+具体信息

关键参数解析

  • log_file:指定日志保存的位置(目录要提前建好,否则报错)。

  • log_file_level:控制 "哪些级别的日志写入文件"。比如设为 INFO,则 DEBUG 级别的日志不会存到文件(适合生产环境,减少日志量)。

  • log_file_format:定义每条日志的结构:

    • %(asctime)s:日志产生的时间
    • %(levelname)s:日志级别(DEBUG/INFO 等)
    • %(filename)s:产生日志的代码文件名
    • %(message)s:日志的具体内容(比如接口响应、自定义信息)
2. 第二步:生成日志对象(代码里调用)
python 复制代码
import logging

# 生成日志对象(关键!让代码能调用日志功能)
logger = logging.getLogger(__name__)  
  • logging.getLogger(__name__) 会根据 pytest.ini 的配置,创建一个 "符合规则的日志对象"。
  • __name__ 是 Python 的内置变量,代表当前模块名(比如 test_api.py 里,__name__ 就是 test_api),这样不同模块的日志会区分开(方便定位哪个文件产生的日志)。
3. 第三步:写入日志(业务代码里用)
python 复制代码
# 假设这是接口请求后的响应
res = requests.get("https://example.com/api")  

# 用 logger 记录 INFO 级别的日志
logger.info(res.text)  

效果

运行测试用例后,./logs/frame.log 文件会新增一行日志,格式如:

TypeScript 复制代码
2024-01-01 10:00:00,123 INFO  test_api.py {"code":200,"msg":"success"}
  • 时间 2024-01-01 10:00:00,123 → 知道啥时候跑的
  • 级别 INFO → 知道是普通信息
  • 文件名 test_api.py → 知道哪个文件触发的
  • 内容 {"code":200,"msg":"success"} → 知道接口响应啥

⭐ 日志级别怎么用?(扩展场景)

除了 info,还有这些常用级别:

python 复制代码
# 调试级(一般开发阶段用,生产环境关)
logger.debug("这是调试信息,比如请求参数:%s", params)  

# 警告级(提醒可能有问题,但不影响运行)
logger.warning("接口响应时间超过 2s,请关注!")  

# 错误级(记录明确的错误,比如接口返回 500)
logger.error("接口报错:%s", res.text)  

配合 log_file_level 使用

如果 pytest.inilog_file_level = INFO,那么:

  • debug 级别的日志不会写入文件 (但控制台可能显示,取决于 log_level 配置);
  • info/warning/error 会写入文件,方便追溯。

⭐ 和普通 print 相比的优势

对比项 print logger + 封装配置
持久化保存 ❌ 控制台关闭就没 ✅ 写入文件留存
级别区分 ❌ 全是普通输出 ✅ 按 DEBUG/INFO 分类
格式统一 ❌ 杂乱无章 ✅ 带时间 / 级别 / 文件名
生产环境可用 ❌ 太多无用输出 ✅ 可控制只存 ERROR

⭐ 实际应用场景(接口自动化里怎么用)

比如在之前的 test_api.py 里,给接口请求加日志:

python 复制代码
import requests
import logging

# 生成日志对象(第二步)
logger = logging.getLogger(__name__)  

def test_login():
    url = "http://example.com/login"
    data = {"user":"test"}
    
    # 记录 DEBUG 级别的请求参数(调试时开,生产关)
    logger.debug("登录接口请求参数:%s", data)  
    
    res = requests.post(url, data=data)
    
    # 记录 INFO 级别的响应(常规信息)
    logger.info("登录接口响应:%s", res.text)  
    
    # 断言失败时,记录 ERROR 级别日志(关键错误)
    try:
        assert res.status_code == 200
    except AssertionError:
        logger.error("登录接口断言失败!响应:%s", res.text)
        raise  # 抛出异常,让用例标记为失败

运行后,日志文件会清晰记录:

  • 什么时候发的请求、参数是啥;
  • 响应内容是否符合预期;
  • 断言失败时,详细错误信息会被重点标记(ERROR 级别)。

⭐ 总结

这套 "零代码极限封装" 本质是利用 Pytest 内置的日志能力 ,通过简单的 pytest.ini 配置 + 三行代码,就能实现:

  • 日志自动持久化到文件;
  • 级别分类清晰;
  • 格式统一易读。

特别适合接口自动化、UI 自动化等测试场景,让你在大量用例中快速定位问题,不用再靠 print 大海捞针啦~