跳转至

pytest 用例组织与数据驱动

pytest 的坑集中在三处:fixture 的作用域与执行顺序、参数化的组织方式、并行时的共享状态。这篇按这三块展开,代码都可直接落地。

一、工程约定与 pytest.ini

project/
├── pytest.ini
├── tests/
│   ├── conftest.py          # 全局 fixture + 钩子
│   ├── test_smoke.py
│   ├── api/
│   │   ├── conftest.py      # 只对 api 目录生效
│   │   ├── test_order.py
│   │   └── test_user.py
│   ├── ui/
│   │   └── test_login.py
│   └── data/
│       ├── login.yaml
│       └── orders.csv
└── src/                     # 被测代码或框架代码
# pytest.ini
[pytest]
minversion = 7.0
testpaths = tests
pythonpath = .
addopts = -ra --strict-markers --tb=short --durations=10
markers =
    smoke: 冒烟用例
    regression: 回归用例
    api: 接口用例
    ui: UI 用例
    slow: 执行时间超过 30 秒
filterwarnings =
    error::DeprecationWarning

要点:

  • --strict-markers:拼错 @pytest.mark.smoek 直接报错,而不是静默跳过筛选。
  • testpaths:避免 pytest 递归扫描整个仓库(含虚拟环境)。
  • pythonpath = .:省掉 sys.path 手工拼接(pytest 7+ 内置)。
  • --durations=10:每次跑完都能看到最慢的 10 个用例,性能回归尽早发现。

二、fixture:作用域与执行顺序

2.1 五种作用域

scope 创建时机 销毁时机 典型用途
function 每个用例 用例结束 页面对象、临时数据
class 每个类 类结束 一类用例共享的登录态
module 每个文件 文件结束 模块级 token
package 每个包 包结束 包级数据库 schema
session 整个会话 全部结束 浏览器、连接池、容器
import pytest

@pytest.fixture(scope="session")
def driver():
    d = create_driver()
    yield d
    d.quit()                      # 即使用例失败也会执行

@pytest.fixture
def logged_in(driver):
    LoginPage(driver).open().login("qa_user", "Passw0rd!")
    return driver

yield 之后是 teardown,等价于 request.addfinalizer,但更直观。teardown 里不要写 assert,否则会掩盖用例本身的失败;失败只记录日志。

2.2 执行顺序

同一作用域内,fixture 按依赖关系执行;没有依赖时按参数声明顺序:

@pytest.fixture
def a(): print("a")

@pytest.fixture
def b(): print("b")

def test_x(b, a): ...          # 输出 b → a(按参数顺序)
def test_y(a, b): ...          # 输出 a → b

跨作用域时,高作用域先创建、后销毁:session → module → function。这意味着 session fixture 不能依赖 function fixture,否则报:

ScopeMismatch: You tried to access the function scoped fixture tmp_path
with a session scoped request object

2.3 autouse 的边界

@pytest.fixture(autouse=True)
def trace(request):
    print("start", request.node.name)
    yield
    print("end", request.node.name)

autouse 只对同目录及子目录生效。放在根 conftest.py 会影响所有用例,包括你不想影响的慢测试。建议只用于日志、计时、环境检查,不要用于造业务数据。

2.4 fixture 参数化

@pytest.fixture(params=["admin", "user"], ids=["管理员", "普通用户"])
def role(request):
    return request.param

def test_permission(role):
    assert role in ("admin", "user")

每个参数生成一个独立用例。与 parametrize 的区别:

  • fixture params:作用于所有使用该 fixture 的用例,适合"环境/角色"维度。
  • parametrize:只作用于当前函数,适合"输入数据"维度。

indirect=True 可以把参数传给 fixture:

@pytest.fixture
def user(request):
    return UserFactory.create(role=request.param)

@pytest.mark.parametrize("user", ["admin", "user"], indirect=True)
def test_login(user):
    ...

三、conftest 的层级与覆盖

tests/conftest.py            → 全目录生效
tests/api/conftest.py        → tests/api 及子目录生效
tests/api/v2/conftest.py     → 只对 v2 生效

同名 fixture 就近覆盖:

# tests/api/conftest.py
@pytest.fixture
def client():
    return ApiClient(base_url="https://api.example.com")   # 覆盖全局的 client

注意:conftest.py 里的 fixture 不需要 import,pytest 自动发现;但如果你把 fixture 写在普通模块里,就必须在 conftest 里 from .fixtures import * 或注册插件。

四、数据驱动:四种组织方式

4.1 表驱动(最常用)

import pytest

CASES = [
    pytest.param("qa_user", "Passw0rd!", "欢迎回来", id="正常登录"),
    pytest.param("qa_user", "wrong", "密码错误", id="密码错误"),
    pytest.param("", "Passw0rd!", "请输入账号", id="账号为空"),
    pytest.param("qa_user", "", "请输入密码", marks=pytest.mark.smoke, id="密码为空"),
]

@pytest.mark.parametrize("username,password,expected", CASES)
def test_login(username, password, expected, driver):
    page = LoginPage(driver).open().login_expect_failure(username, password)
    assert expected in page.error_text() or page.welcome_text() == expected

pytest.param(..., marks=...) 可以只给某一条数据打标记,比如只挑一条做冒烟。

4.2 YAML 驱动(数据与代码分离)

# tests/data/login.yaml
cases:
  - name: 正常登录
    username: qa_user
    password: Passw0rd!
    expected: 欢迎回来
    tags: [smoke]
  - name: 密码错误
    username: qa_user
    password: wrong
    expected: 密码错误
import functools, yaml, pytest

@functools.lru_cache(maxsize=None)
def load_cases(path="tests/data/login.yaml"):
    with open(path, encoding="utf-8") as f:
        return yaml.safe_load(f)["cases"]

@pytest.mark.parametrize(
    "case",
    load_cases(),
    ids=lambda c: c["name"],
)
def test_login_yaml(case, driver):
    ...

lru_cache 避免每个用例都读一次文件;ids 让报告里显示中文用例名,而不是 case0。

4.3 动态参数化(数据来自数据库/接口)

def pytest_generate_tests(metafunc):
    if "order" in metafunc.fixturenames:
        rows = fetch_orders_from_db(limit=50)          # [(id, amount), ...]
        metafunc.parametrize(
            "order",
            rows,
            ids=[f"order-{r[0]}" for r in rows],
        )

适合"用例数量由数据决定"的场景。注意:收集阶段就会执行,不要在 pytest_generate_tests 里调用被测系统的写接口,否则 --collect-only 都会产生副作用。

4.4 笛卡尔积与顺序

@pytest.mark.parametrize("role", ["admin", "user"])
@pytest.mark.parametrize("action", ["read", "write", "delete"])
def test_permission(role, action):
    ...

装饰器从下往上组合,生成 2 × 3 = 6 个用例。数据量大时用 pytest.param 控制单条跳过:

pytest.param("user", "delete", marks=pytest.mark.xfail(reason="已知缺陷 #123")),

五、标记、筛选与执行

pytest                              # 全量
pytest tests/api                   # 指定目录
pytest -m "smoke and not slow"     # 标记表达式
pytest -k "login and not negative" # 名称匹配
pytest --collect-only -q           # 只看收集结果
pytest -x --maxfail=3              # 失败即停 / 累计 3 个失败停
pytest --lf                        # 只跑上次失败
pytest --ff                        # 先跑上次失败
pytest -q --tb=line                # 精简回溯

-k 匹配的是用例 ID(文件名 + 类名 + 函数名 + 参数 id),中文参数 id 也能匹配。

skip 与 xfail:

@pytest.mark.skip(reason="环境未部署")
def test_a(): ...

@pytest.mark.skipif(os.getenv("CI") == "true", reason="CI 无短信通道")
def test_b(): ...

@pytest.mark.xfail(strict=True, reason="缺陷 #456 未修复")
def test_c(): ...

xfail(strict=True) 表示"预期失败但竟然通过了"要报错,防止缺陷修复后忘记摘标记。

六、并行:xdist 的正确用法

pip install pytest-xdist
pytest -n auto                       # 按 CPU 核数
pytest -n 4 --dist loadscope         # 同一模块/类分到同一 worker
pytest -n 4 --dist loadfile          # 同一文件分到同一 worker

三种分发策略:

策略 行为 适用
load(默认) 逐个用例分发,最均衡 用例彼此独立
loadscope 按模块/类分组 类内有共享 fixture
loadfile 按文件分组 文件级 session fixture

worker_id 的使用:

@pytest.fixture(scope="session")
def account(worker_id):
    suffix = "master" if worker_id == "master" else worker_id   # gw0/gw1
    return f"qa_{suffix}"

并行下必崩的三种写法:

  1. session 级 fixture 里创建"全局唯一"数据,多个 worker 各建一份,断言总数翻倍。
  2. 用例依赖执行顺序(test_01_create → test_02_update)。
  3. 共享同一账号/库存/文件路径。

判断是否真的独立:pytest -n 4 --dist loadfile 与 pytest 单进程结果一致,才算独立。

七、失败取证:钩子统一处理

# tests/conftest.py
import os, datetime, pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    if report.when != "call" or not report.failed:
        return

    driver = item.funcargs.get("driver")
    if driver is None:
        return

    os.makedirs("artifacts", exist_ok=True)
    stamp = datetime.datetime.now().strftime("%H%M%S")
    name = f"artifacts/{item.name}_{stamp}"
    driver.save_screenshot(f"{name}.png")
    with open(f"{name}.html", "w", encoding="utf-8") as f:
        f.write(driver.page_source)
    report.sections.append(("artifacts", f"{name}.png / {name}.html"))

要点:

  • report.when == "call" 才是用例本体失败;setup 失败时 driver 可能不存在。
  • 附件路径写进 report.sections,终端与 HTML 报告都能看到。
  • Allure 场景下改用 allure.attach,但要判断 driver 是否可用。

断言信息也要能定位:

assert order["status"] == "PAID", (
    f"订单状态异常: orderNo={order['orderNo']} "
    f"actual={order['status']} expected=PAID"
)

pytest 的断言重写会自动展开 == 两侧的值,但只在 assert 语句里生效;把断言写进 helper 函数再调用,展开信息会丢失。

八、常见反模式

反模式 后果 改法
fixture 里 assert 报 setup error,看不出业务失败 改为抛业务异常或用例内断言
用例之间靠文件名顺序依赖 单跑失败、全量通过 每个用例自造数据
time.sleep(5) 慢且不稳定 显式等待 / 轮询断言
全局可变状态(模块级 list/dict) 并行互相污染 用 fixture 返回新对象
scope="session" 里放可变请求体 后一个用例改掉前一个的字段 每次 copy.deepcopy
测试里读生产配置 本地过、CI 挂 配置走环境变量
大量 xfail 无 reason 缺陷被隐藏 必须写 reason + 缺陷号
在 conftest 顶层发请求 --collect-only 也执行 放进 fixture

九、检查清单

  • pytest.ini 注册了所有标记并开启 --strict-markers
  • testpaths 指向 tests,避免扫描无关目录
  • fixture 作用域最小化,没有 ScopeMismatch
  • teardown 不 assert,失败只记录
  • 参数化用例有可读 id,报告能看懂
  • pytest_generate_tests 无副作用
  • -n 4 与单进程结果一致
  • 失败自动留存截图/日志,断言带上下文
  • 无 time.sleep、无顺序依赖、无全局可变状态

相关阅读