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 # 全量
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}"
并行下必崩的三种写法:
- session 级 fixture 里创建"全局唯一"数据,多个 worker 各建一份,断言总数翻倍。
- 用例依赖执行顺序(
test_01_create→test_02_update)。 - 共享同一账号/库存/文件路径。
判断是否真的独立: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、无顺序依赖、无全局可变状态