Pytest笔记

13 阅读18分钟

使用方式

安装

pip install pytest

测试用例识别规则(默认规则)

  • 文件名: 必须以 test_ 开头或 _test.py 结尾(如 test_login.py)。
  • 类名: 必须以 Test 开头,且不能包含 __init__ 方法。
  • 方法/函数名: 必须以 test_ 开头。 测试运行方式
  • 命令行运行:
    • 运行所有用例:pytest
    • 运行指定文件:pytest test_demo.py
    • 运行指定目录:pytest tests/
    • 运行指定类/方法:pytest test_demo.py::TestClass::test_method
  • 常用参数:
    • -v:详细输出模式。
    • -s:显示标准输出(即代码中的 print 内容)。
    • -x:遇到第一个失败即停止运行。
    • --html=report.html:生成 HTML 测试报告(需安装插件)。

运行

  • 在 Python 脚本中通过 pytest.main() 运行。
# test_1.py
import pytest  

def test_1():
    assert True == True

if __name__ == '__main__':  
    args = ["./test_1.py", "-v","-s"]  
    pytest.main(args)  
  • 配置文件运行pytest.ini
# pytest.ini  
[pytest]   
# 添加命令行参数,相当于默认执行 pytest -v -s
addopts = -v -s  
  
# 日志配置  
log_cli = true  
log_cli_level = INFO  
log_cli_date_format = %Y-%m-%d-%H-%M-%S  
log_cli_format = %(asctime)s-%(filename)s-%(funcName)s-%(lineno)d-%(levelname)s  %(message)s

# test_1.py
import pytest  
import logging  
  
def test_1():  
    logging.info("执行第一个测试函数")  
    assert True == True  

def test_2():  
    assert 4 == 5  
    logging.info("函数test_2")  # 前面断言为False,这一行不输出,不执行

运行指令:

pytest ./test_1.py

跳过测试

整个文件

方法一:使用pytestmark (推荐)

import pytest  

# 这里的pytestmark不可以不写,原因必须写,写在文件最前面
pytestmark = pytest.mark.skip("模块构建中。。。")

# 。。。。。。文件上下文。。。。。。

方法二:使用配置文件

# pytest.ini  
[pytest]  
# 忽略特定的文件或目录(可以指定多个)  
addopts = 
        --ignore=./test_1.py
        --ingnore=./test_2.py 
        # 有not 是跳过测试函数,无not指定运行函数
        -k "not _one"
        
# 测试函数会被跳过
def test_1_one():  
    assert True == True

跳过某个函数

  • 无条件跳过测试函数
@pytest.mark.skip(reason="todo ")  
def test_2():  
    assert True == 1  
  • 满足条件,跳过测试
@pytest.mark.skipif(sys.platform == "win32", reason="此平台不运行")  
def test_unix_specific():  
    """在非 Windows 系统上运行,在 Windows 上跳过"""  
    logging.info("sys.platform == win32")  
    assert True  
  
@pytest.mark.skipif(sys.platform == "win64", reason="此平台不运行")  
def test_unix_specific2():  
    """在非 Windows 系统上运行,在 Windows 上跳过"""  
    logging.info("sys.platform == win64")  
    assert True  
  • 动态跳过测试函数
def test_dynamic_skip():  
    if True:  
        logging.info("动态跳过测试前代码")  
        pytest.skip("不满足条件,跳过")  
        logging.info("动态跳过后测试代码")  # 该行代码不执行

fixture

fixture 是一种用于设置测试环境的机制。它们可以用来执行一些前置或后置操作(例如:准备数据、启动服务、清理状态等),并且可以在多个测试之间共享。作用域是决定其执行顺序的首要因素。pytest遵循作用域越小,越先执行;作用域越大,越后执行的原则

作用域

函数级、类级、模块级:它决定了 fixture 函数在何时被创建和销毁。通过 scope 参数设置

作用域说明创建/销毁时机典型使用场景
function (默认)每个测试函数都运行一次测试函数执行前创建,执行后销毁需要为每个测试用例提供独立、干净的测试环境时。如:每个测试用例都需要独立的数据库事务、临时文件等。
class每个测试类运行一次类中第一个测试方法执行前创建,类中最后一个测试方法执行后销毁当一个类中的所有测试方法可以共享同一个资源时,能提高效率。如:一个类中的测试都使用同一个配置对象。
module每个测试文件(模块)运行一次模块中第一个测试函数执行前创建,模块中最后一个测试函数执行后销毁对于整个模块的测试都共享的资源。如:一个模块的测试都连接到同一个测试数据库实例。
session整个测试会话只运行一次整个测试会话开始时创建,所有测试结束后销毁对于非常昂贵或全局唯一的资源。如:启动一个外部服务、建立到全局数据库的连接、读取全局配置文件等。
import logging  
import pytest  
scope_flage = {  
    "func": 0,  
    "class": 0,  
    "moudle": 0  
}  
def scope_init(scope="func"):  
    global scope_flage  
    if scope == "func":  
        scope_flage["func"] += 5  
        logging.info(scope_flage)  
    elif scope == "class":  
        scope_flage["class"] += 5  
        logging.info(scope_flage)  
    elif scope == "moudle":  
        scope_flage["moudle"] += 5  
        logging.info(scope_flage)  
    logging.info("数据初始化成功")  
    return scope_flage  
  
def close_init():  
    global scope_flage  
    scope_flage = {  
        "func": 0,  
        "class": 0,  
        "moudle": 0  
    }  
    logging.info(scope_flage)  
    logging.info("回收数据成功")  
    return True  
  
@pytest.fixture  
def db():  
    """创建并返回一个数据库连接(函数级别)"""  
    logging.info("\n🔧 [Fixture] 初始化数据资源")  
    yield scope_init("func")  # 测试执行点  
    logging.info("🧹 [Fixture] 回收数据资源")  
    close_init()  
  
def test_operation_func1(db):  
    db["func"] += 13  
    logging.info(db)  
    assert True == True  
  
def test_operation_func2(db):  
    logging.info(db)  
    assert True == True  
  
@pytest.fixture(scope="module")  
def module_db():  
    """模块级别 Fixture(整个测试文件只初始化一次)"""  
    logging.info("\n🌍 [Module Fixture] 初始化数据资源(模块级)")  
    yield scope_init("moudle")  
    logging.info("\n🌍 [Module Fixture] 关闭数据资源(模块级)")  
    close_init()  
  
def test_operation_moudle3(module_db):  
    module_db["moudle"] += 13  
    logging.info(module_db)  
    assert True == True  
    
def test_operation_moudle4(module_db):  
    logging.info(module_db)  
    assert True == True  
   
@pytest.fixture(scope="class")  
def class_db():  
    """类级别 Fixture(整个测试类只初始化一次)"""  
    logging.info("\n👥 [Class Fixture] 初始化数据资源(类级)")  
    yield scope_init("class")  
    logging.info("\n👥 [Class Fixture] 关闭数据资源(类级)")  
    close_init()  
  
class TestClassScope:  
    def test_class1_scope(self, class_db):  
        """验证类级 Fixture 作用域"""  
        class_db["class"] += 13  
        logging.info(class_db)  
        assert 1 == 1  
  
    def test_class2_scope(self, class_db):  
        """验证类级 Fixture 作用域(同一个类)"""  
        logging.info(class_db)  
        assert 1 == 1

image.png

pytest.mark.usefixtures

如果你不想在每个测试函数中都列出所有的 fixtures,或者你需要为多个测试函数应用同一个 fixture,或者一个测试函数使用多个fixture,可以使用 pytest.mark.usefixtures 来标记这些测试函数或整个测试类

@pytest.fixture  
def start1_fix():  
    logging.info("加载资源1")  
    yield  
    logging.info("注销资源1")  
  
  
@pytest.fixture  
def start2_fix():  
    logging.info("加载资源2")  
    yield  
    logging.info("注销资源2")  
  
@pytest.fixture  
def start3_fix():  
    logging.info("加载资源3")  
    yield  
    logging.info("注销资源3")  
  
@pytest.fixture(autouse=True)  # 所有的测试函数都会调用该fixture
def all_used_fixture():  
    logging.info("这个夹具会自动应用到所有测试用例中。")  
  
@pytest.mark.usefixtures("start1_fix")  
def test_1():  
    assert True == True  
  
@pytest.mark.usefixtures("start1_fix", "start2_fix", "start3_fix")  
def test_2():  
    assert True == True  
  
def test_3():  
    assert True == True    
    

image.png

conftest.py:共享 fixture 的利器

你不必在每个测试文件中都重复定义相同的 fixture。pytest 允许你在一个名为 conftest.py 的特殊文件中定义 fixture,这个文件中的 fixture 对其所在目录及其子目录下的所有测试文件都是可见且可用的

tests/
├── conftest.py          # 定义了 common_fixture
├── test_module_a.py
└── sub_directory/
    ├── conftest.py      # 可以覆盖或扩展父级的 fixture
    └── test_module_b.py

conftest.py是一个无需导入的神奇文件,pytest 会自动发现并加载它。 它通常用于存放项目级或目录级的共享 fixture,比如数据库连接、配置加载等。 不同目录层级可以有多个 conftest.py,子目录的 conftest.py 中的 fixture 可以覆盖父目录中的同名 fixture。

fixture相互依赖

@pytest.fixture  
def before_fix():  
    logging.info("加载前置资源1")  
    yield [0,0]  
    logging.info("注销前置资源1")  
  
@pytest.fixture  
def end_fix(before_fix):  # 依赖 before_fix    if before_fix[0] == True:  
        logging.info("加载前置资源2-1")  
        yield [1]  
        logging.info("注销前置资源2-1")  
    elif before_fix[1] == False:  
        logging.info("加载前置资源2-2")  
        yield [0]  
        logging.info("注销前置资源2-2")  
    else:  
        logging.info("加载前置资源2-3")  
        yield [1,1,0,0]  
        logging.info("注销前置资源2-3")  
  
def test_user_creation(end_fix):  
    assert end_fix == [0]

image.png

fixture参数传递

@pytest.fixture  
def data_engine(request):  
    logging.info(f"⚙️ [Param Fixture] 使用 {request.param} 引擎")  
    yield {  
        "size": 10,  
        "works": 20  
    }[request.param]  
    logging.info("关闭数据引擎")  

@pytest.mark.parametrize("data_engine", ["works", "size"], indirect=True)  
def test_parametrized_db(data_engine):  
    assert data_engine in [10,20]

image.png

名字的重用: Fixture 的名字和测试函数参数的名字是故意设计成一样的,这是一种“魔法”。这个魔法就是依赖注入。

xfail失败通过标记

import pytest  
  
@pytest.mark.xfail(reason="业务1未完成,必定失败")  
def test_1():  
    assert True == True  
  
@pytest.mark.xfail(reason="业务2未完成,必定失败")  
def test_2():  
    """预期失败:应该失败,但不计入测试失败)"""  
    assert True == False  
  
def divide(a, b):  
    if b == 0:  
        raise ValueError("除数不能为零")  
    return a / b  
  
@pytest.mark.xfail(raises=ValueError, reason="已知缺陷:除以零会报错")  
def test_divide_by_zero():  
    # 这个调用会触发 ValueError    divide(10, 0)  
  
def test_divide_success():  
    # 这是一个正常的测试,不会失败  
    assert divide(10, 2) == 5

image.png

自定义标记

在测试文件中使用 pytest.mark.xxx 即可自动注册标记 ,不需要额外配置(pytest 会自动识别)

# =================== 冒烟测试(带自定义标记) ===================
@pytest.mark.smoke  
def test_login_success_smoke():  
    """冒烟测试:成功登录"""  
    logging.info("✅ 正在运行冒烟测试:登录成功")  
    assert True  
  
@pytest.mark.smoke  
def test_payment_process_smoke():  
    """冒烟测试:支付流程"""  
    logging.info("✅ 正在运行冒烟测试:支付流程")  
    assert True  
  
# =================== 长时间测试(带其他标记) ===================
@pytest.mark.long 
def test_full_user_flow_long():  
    """长时间测试:完整用户流程"""  
    logging.info("⏳ 正在运行长时间测试:完整用户流程")  
    assert True  
  
  
# =================== 重要功能测试 ===================
@pytest.mark.feature("user_management")  
def test_user_creation_feature(request):  
    """功能测试:创建用户"""  
    logging.info("⚙️ 正在运行功能测试:创建用户")  
  
    # 获取生成器  
    markers = request.node.iter_markers(name="feature")  
    # 获取第一个标记对象,如果没有则返回 None    marker = next(markers, None)  
    # 提取参数  
    feature_name = marker.args[0] if marker else "未知"  
    logging.info(f"当前正在测试功能模块: {feature_name}")  
    assert True  
  
if __name__ == "__main__":  
    pytest.main([  
        "test_4.py",  # 测试文件  
        "-m",  # 指定标记  
        "smoke",  # 标记名称  
        "-v",  # 详细输出  
    ])

配置文件pytest.ini【没有配置会有警告】

# pytest.ini  
[pytest]
markers =  
    smoke: 标记为冒烟测试  
    long: 标记为耗时较长的测试  
    feature: 标记为某个功能模块的测试
    

test_user_creation_feature的测试结果:

test_4.py::test_user_creation_feature
-------------------- live log call ----------------------- 
2026-03-29-00-35-06-test_4.py-test_user_creation_feature-70-INFO  ⚙️ 正在运行功能测试:创建用户
2026-03-29-00-35-06-test_4.py-test_user_creation_feature-78-INFO  当前正在测试功能模块: user_management
PASSED

pytest.mark.parametrize传递参数

@pytest.mark.parametrize 核心作用是:让同一个测试函数,使用不同的参数组合运行多次。 基本语法格式:

@pytest.mark.parametrize("参数名1,参数名2", [(1_1, 值1_2), (2_1, 值2_2)])
def test_func(参数名1, 参数名2):
    ...

基础用法(单参数)

import pytest

def add(var:int):  
    return var  
    
# 定义参数:第一个字符串是"参数名",第二个列表是"参数值列表" 
@pytest.mark.parametrize("case",[1,2,3,4])  
def test_add(case):  
    print(f"测试方法add({case})开始执行")  
    assert add(case) == case
    

多参数组合(列表对应)

你可以传入多个参数。注意:列表中的元组个数决定了运行次数,元组内的值对应参数名。

import pytest

@pytest.mark.parametrize("username, password, is_valid", [
    ("admin", "123456", True),   # 用例 1
    ("user", "wrong", False),    # 用例 2
    ("", "123456", False),       # 用例 3: 空用户名
    ("admin", "", False),        # 用例 4: 空密码
])
def test_login_logic(username, password, is_valid):
    """模拟登录验证逻辑"""
    # 模拟业务逻辑
    if username == "admin" and password == "123456":
        actual_valid = True
    else:
        actual_valid = False
    
    assert actual_valid == is_valid, f"用户 {username} 的验证结果错误"

💡 注意:这里不是笛卡尔积(即不会把每个用户名和每个密码都组合一遍),而是一一对应。列表里有几个元组,就运行几次。

自定义测试 ID

如果参数很长或包含特殊字符,生成的测试 ID 会很乱。可以使用 ids 参数自定义名称。

import pytest

@pytest.mark.parametrize("price, discount, expected", [
    (100, 0.9, 90),
    (200, 0.8, 160),
    (50, 1.0, 50),
], ids=["九折优惠", "八折优惠", "无折扣"])
def test_calc_price(price, discount, expected):
    final_price = price * discount
    assert final_price == expected

运行效果:
test_calc_price[九折优惠]
test_calc_price[八折优惠]
test_calc_price[无折扣]
(在 HTML 报告或 Allure 中非常有用)

嵌套 Parametrize (笛卡尔积)

如果你想让两组参数完全组合(例如:2 种浏览器 × 3 种分辨率 = 6 次运行),可以堆叠两个 parametrize 装饰器。

import pytest

@pytest.mark.parametrize("browser", ["Chrome", "Firefox"])
@pytest.mark.parametrize("resolution", ["1920x1080", "1366x768", "375x667"])
def test_ui_display(browser, resolution):
    """
    这将运行 2 * 3 = 6 次:
    Chrome + 1920x1080
    Chrome + 1366x768
    Chrome + 375x667
    Firefox + 1920x1080
    ...
    """
    print(f"正在 {browser} 下测试分辨率 {resolution}")
    assert True

注意:装饰器是从下往上执行的,但效果是所有的组合都会覆盖。

indirect=True

class Pizza:  
    def __init__(self, flavor):  
        self.flavor = flavor  
        self.is_baked = False  
  
    def bake(self):  
        """烤披萨"""  
        self.is_baked = True  
        return f"🍕 热腾腾的 {self.flavor} 披萨出炉了!"  
  
    def eat(self):  
        """吃披萨"""  
        if not self.is_baked:  
            return "❌ 还没烤熟,不能吃!"  
        return f"😋 真好吃,这是 {self.flavor} 味的!"  
  
  
@pytest.fixture  
def chef(request):  
    # 1. 获取 parametrize 传进来的口味名字 (比如 "榴莲")  
    flavor_name = request.param  
  
    print(f"\n👨‍🍳 厨师收到订单:要做一份 [{flavor_name}] 披萨...")  
  
    # 2. 厨师开始制作 (实例化对象)  
    my_pizza = Pizza(flavor_name)  
  
    # 3. 厨师把披萨烤好 (执行一些初始化逻辑)  
    my_pizza.bake()  
  
    # 4. 把做好的【披萨对象】端给测试函数  
    return my_pizza  
  
  
# 注意这里:传进去的是字符串 ["榴莲", "牛肉"]  
# 但因为加了 indirect=True,它们会先传给 chef 处理  
@pytest.mark.parametrize(  
    "chef",  # 这里的名字必须和上面的 fixture 名字一模一样  
    ["榴莲", "牛肉"],  # 这是原始数据(口味名字)  
    indirect=True  # <--- 魔法开关:告诉 pytest "别直接传字符串,先让 chef 处理一下"  
)  
def test_eat_pizza(chef):  
    # 🔴 如果没有 indirect=True:  
    # chef 变量就会是字符串 "榴莲"。  
    # 下面这行代码会报错:AttributeError: 'str' object has no attribute 'eat'  
  
    # ✅ 有了 indirect=True:  
    # chef 变量已经是 <Pizza object> 了!  
  
    print(f"🍽️ 准备开吃:{chef.flavor}")  
  
    # 我们可以直接调用对象的方法  
    result = chef.eat()  
  
    # 断言  
    assert "真好吃" in result  
    assert chef.is_baked is True
    

indirect=True 是 pytest 参数化测试中最关键的参数之一。它的核心作用是:告诉 pytest:“不要把参数列表里的值直接传给测试函数,而是先把这些值传给同名的 fixture,让 fixture 处理完后,再把 fixture 的返回值传给测试函数。” 简单来说:它把“数据”变成了“fixture 的输入参数”。 通常用于以下场景:

  1. 复杂的对象初始化: 测试需要的不是简单的字符串或数字,而是一个配置好的对象(如数据库连接、浏览器实例、API 客户端)。你希望参数只是配置项(如 URL、用户名),而对象的创建逻辑封装在 fixture 里。
  2. 复用 Setup/Teardown 逻辑: 如果每个参数都需要执行相同的“清理工作”(比如测试完删除数据),你可以把清理逻辑写在 fixture 的 yield 之后。无论参数怎么变,清理逻辑只写一次。
  3. 动态跳过测试: 可以在 fixture 内部根据 request.param 的值决定是否 pytest.skip(),从而灵活控制哪些参数组合需要运行。 ⚠️ 常见错误 如果忘了加 indirect=True,但 fixture 里又用了 request.param,或者测试函数里试图调用对象方法,通常会报这两类错:
  4. Fixture "xxx" called directly (如果你试图在参数化里直接调 fixture)
  5. AttributeError: 'str' object has no attribute '...' (因为传进来的是原始数据,不是对象) 这句话的意思是:“请分别用 'mysql' 和 'postgres' 这两个字符串去初始化 db_engine 这个 fixture,然后把初始化好的 Database 对象传给我的测试函数。”

allure-pytest库

安装:

pip install allure-pytest

下载allure

  1. 下载地址[Releases · allure-framework/allure2 · GitHub](Releases · allure-framework/allure2 · GitHub)
  2. 配置环境:.\allure-2.38.0\bin
  3. 验证:allure -version 使用:
pytest [测试文件] --alluredir=allure-results  --clean-alluredir

allure serve ./allure-results

标记装饰器:

  • @allure.epic(),用于描述被测软件系统
  • @allure.feature(),用于描述被测软件的某个功能模块
  • @allure.story(),用于描述功能模块下的功能点或功能场景,也即测试需求
  • @allure.title(),用于定义测试用例标题
  • @allure.description(),用于测试用例的说明描述
  • @allure.severity(),标记测试用例级别,由高到低分为 blocker、critical、normal、minor、trivial 五级
  • @allure.step(),标记通用函数使之成为测试步骤,测试方法/测试函数中调用此通用函数的地方会向报告中输出步骤描述
import allure  
import pytest  
import os  
  
def get_token():  
    return True  
  
@allure.epic("token的epic")  
@allure.feature("token的feature")  
@allure.step("token的step")  
@allure.title("token的title")  
@allure.story("token的story")  
@allure.description("token的description")  
@allure.severity("token的severity")  
def test_login():  
    assert get_token() == True  
  
@allure.epic("token2的epic")  
@allure.feature("token2的feature")  
@allure.step("token2的step")  
@allure.title("token2的title")  
@allure.story("token2的story")  
@allure.description("token2的description")  
@allure.severity("token2的severity")  
def test_login2():  
    assert get_token() == True  
  
@allure.epic("token3的epic")  
@allure.feature("token3的feature")  
@allure.step("token3的step")  
@allure.title("token3的title")  
@allure.story("token3的story")  
@allure.description("token3的description")  
@allure.severity("token3的severity")  
def test_login3():  
    assert get_token() == False  
  
if __name__=="__main__":  
    os.system(r"pytest -vs ./test_5.py --alluredir=./allure-results --clean-alluredir")  
    # os.system(r"allure generate ./allure-results -o ./report-allure")  
    # os.system(r"allure serve ./allure-results")

image.png

image.png

import pytest  
import allure  
import time  
import json  
import random  
from datetime import datetime  

# ==============================================================================  
# 1. 全局配置与 Hook (用于自动捕获失败现场)  
# ==============================================================================  
  
@allure.feature("🛍️ 电商核心交易系统")  
@allure.story("订单处理流程")  
class TestOrderSystem:  
    """  
    演示类:模拟一个复杂的电商订单处理系统。  
    包含:参数化、动态步骤、附件上传、链接跳转、严重程度动态调整。  
    """  
    # 模拟测试数据:不同场景的订单  
    test_data = [  
        {"case_id": "ORD-001", "user": "VIP_User", "amount": 999.00, "status": "success", "desc": "VIP用户大额订单"},  
        {"case_id": "ORD-002", "user": "Normal_User", "amount": 50.00, "status": "success", "desc": "普通用户小额订单"},  
        {"case_id": "ORD-003", "user": "Black_List", "amount": 10000.00, "status": "fail",  
         "desc": "黑名单用户拦截测试"},  
        {"case_id": "ORD-004", "user": "Timeout_User", "amount": 200.00, "status": "timeout",  
         "desc": "支付超时异常测试"},  
    ]  
  
    @pytest.mark.parametrize("data", test_data, ids=lambda x: x["case_id"])  
    @allure.title("测试用例:{data[desc]}")  
    def test_process_order(self, data):  
        """  
        核心测试逻辑:处理订单  
        演示点:  
        1. 动态设置严重程度 (Severity)        
        2. 动态添加参数 (Parameters)        
        3. 多步骤嵌套 (Steps)        
        4. 附加文本/JSON日志 (Attach)        
        5. 模拟异常与截图  
        """  
        # --- 动态元数据设置 ---        # 根据数据动态设置严重程度  
        if data["status"] == "fail":  
            allure.dynamic.severity(allure.severity_level.BLOCKER)  
            allure.dynamic.tag("风险拦截", "安全审计")  
        elif data["status"] == "timeout":  
            allure.dynamic.severity(allure.severity_level.CRITICAL)  
            allure.dynamic.tag("性能问题")  
        else:  
            allure.dynamic.severity(allure.severity_level.NORMAL)  
            allure.dynamic.tag("常规交易")  
  
        # 添加动态链接 (关联 Jira 或需求文档)  
        allure.dynamic.link(f"https://jira.example.com/browse/{data['case_id']}", name="需求追踪")  
        allure.dynamic.issue("https://github.com/myrepo/issues/123", name="已知问题")  
  
        # 添加动态参数 (在报告中清晰展示输入数据)  
        allure.dynamic.parameter("订单ID", data["case_id"])  
        allure.dynamic.parameter("用户类型", data["user"])  
        allure.dynamic.parameter("交易金额", f"${data['amount']}")  
        allure.dynamic.parameter("预期结果", data["status"])  
  
        # --- 执行步骤 1: 验证用户资格 ---        
        with allure.step("🔍 步骤 1: 验证用户资格与风控检查"):  
            self._log_action(f"正在查询用户 {data['user']} 的状态...")  
            time.sleep(0.5)  # 模拟耗时  
  
            user_info = {"id": 1001, "name": data["user"], "level": "VIP" if "VIP" in data["user"] else "Normal"}  
            # 附加 JSON 格式的中间数据  
            allure.attach(  
                json.dumps(user_info, indent=2, ensure_ascii=False),  
                name="用户信息快照",  
                attachment_type=allure.attachment_type.JSON  
            )  
  
            if data["user"] == "Black_List":  
                # 模拟业务逻辑失败  
                self._log_action("❌ 检测到黑名单用户,拦截交易!")  
                # 附加纯文本日志  
                allure.attach("风控规则 ID: RULE-9527\n拦截原因:用户处于黑名单", name="风控拦截日志",  
                              attachment_type=allure.attachment_type.TEXT)  
                
                assert False, "交易被风控系统拦截"  
  
        # --- 执行步骤 2: 扣减库存 ---        
        with allure.step("📦 步骤 2: 锁定并扣减库存"):  
            self._log_action(f"尝试锁定商品,金额:{data['amount']}")  
            time.sleep(0.3)  
            # 模拟随机生成的库存流水号  
            stock_flow_id = f"STK-{random.randint(10000, 99999)}"  
            allure.attach(stock_flow_id, name="库存流水号", attachment_type=allure.attachment_type.TEXT)  
  
        # --- 执行步骤 3: 调用支付网关 ---        
        with allure.step("💳 步骤 3: 调用第三方支付网关"):  
            self._log_action("正在连接支付网关...")  
            time.sleep(0.5)  
  
            if data["status"] == "timeout":  
                # 模拟超时异常  
                error_log = "ConnectionTimeout: Gateway did not respond within 3000ms"  
                allure.attach(error_log, name="网关错误日志", attachment_type=allure.attachment_type.TEXT)  
  
                # 【高级技巧】模拟失败时自动附加"截图"  
                # 在实际 UI 自动化中,这里会是 driver.get_screenshot_as_png()                # 这里我们生成一个假的 PNG 头或者文本模拟截图内容,让报告看起来有截图  
                fake_screenshot_content = f"SIMULATED SCREENSHOT AT {datetime.now()}\nError: Payment Timeout\nUser: {data['user']}"  
                allure.attach(  
                    fake_screenshot_content,  
                    name="失败现场截图 (模拟)",  
                    attachment_type=allure.attachment_type.PNG  # 即使内容是文本,设为PNG也会在报告中显示为图片块(取决于查看器),或者用 TEXT 更稳妥  
                )  
                # 为了演示效果,这里用 TEXT 类型确保能看到内容,实际项目中请用真正的 bytes                raise AssertionError("支付网关响应超时")  
  
        # --- 执行步骤 4: 生成订单 ---        
        with allure.step("🧾 步骤 4: 生成最终订单并发送通知"):  
            order_result = {"order_no": f"NO-{time.time()}", "status": "PAID"}  
            allure.attach(json.dumps(order_result), name="最终订单详情",attachment_type=allure.attachment_type.JSON)             self._log_action("✅ 订单创建成功,通知已发送")  
  
        # 最终断言  
        assert data["status"] == "success", f"期望状态为 success,但实际为 {data['status']}"  
  
    def _log_action(self, message: str):  
        """辅助方法:打印日志并同时写入 Allure 步骤详情(可选)"""  
        print(f"[LOG] {message}")  
        # 如果需要将每个小动作都作为子步骤,可以使用 nested step,但通常 print 足够  
        # 这里仅做演示,实际 log 会显示在 'Output' 标签页  
  
  
# ==============================================================================  
# 2. 另一个 Feature 演示:数据驱动与动态描述  
# ==============================================================================  
  
@allure.feature("📊 数据分析报表模块")  
@allure.story("报表生成引擎")  
class TestReportEngine:  
  
    @pytest.mark.parametrize("report_type, expected_time", [  
        ("日报", 1.0),  
        ("周报", 3.5),  
        ("月报", 10.0),  
    ])  
    @allure.description("""  
    这是一个长描述的测试用例。  
    它用于验证不同类型的报表生成时间是否在允许范围内。  
  
    **前置条件**:  
    1. 数据库连接正常  
    2. 数据仓库已同步  
  
    **预期行为**:  
    生成时间不应超过阈值。  
    """)  
    def test_generate_report_performance(self, report_type, expected_time):  
        """性能测试演示"""  
  
        allure.dynamic.title(f"性能测试:{report_type}生成耗时验证")  
        allure.dynamic.label("owner", "DataTeam")  
        allure.dynamic.label("component", "ETL-Pipeline")  
  
        with allure.step(f"开始生成 {report_type}"):  
            start = time.time()  
            # 模拟处理耗时 (稍微随机一点,让它有时通过有时失败,方便演示)  
            # 为了演示稳定性,这里固定模拟一个接近阈值的值  
            actual_time = expected_time * 0.9  
            time.sleep(actual_time)  
            duration = time.time() - start  
  
        with allure.step("验证耗时"):  
            allure.dynamic.parameter("预期最大耗时", f"{expected_time}s")  
            allure.dynamic.parameter("实际耗时", f"{duration:.2f}s")  
  
            # 附加一个大的文本块模拟 SQL 执行计划  
            sql_plan = f"""  
            EXPLAIN ANALYZE            SELECT * FROM huge_table WHERE type = '{report_type}'  
            -> Seq Scan on huge_table (cost=0.00..100.00 rows=1000)               Filter: (type = '{report_type}')  
               Actual Time: {duration * 1000:.2f} ms            """            allure.attach(sql_plan, name="SQL 执行计划", attachment_type=allure.attachment_type.TEXT)  
  
            assert duration <= expected_time, f"{report_type} 生成超时!耗时 {duration:.2f}s > {expected_time}s"  

image.png