pytest 实战:从单文件脚本到可维护的测试工程
很多团队的 pytest 用着像 unittest:一个文件塞几十个 test_xxx,互相依赖、顺序敏感、失败难排查。这篇文章讲怎么把 pytest 用成"工程"而不是"脚本堆"。
一、目录结构:约定大于配置
1 2 3 4 5 6 7 8 9 10
| tests/ ├── conftest.py # 全局 fixture(驱动/数据库/环境) ├── test_login.py ├── test_order/ │ ├── conftest.py # 本目录专属 fixture(订单前置数据) │ ├── test_create.py │ └── test_pay.py └── support/ ├── client.py # API 客户端封装 └── assertions.py # 业务断言工具
|
两条铁律:
- fixture 就近声明:只在某目录用到的 fixture 放该目录的
conftest.py,全局共享的才放根目录;
- 测试文件不 import 彼此:
test_a.py 和 test_b.py 之间只允许通过 fixture 共享状态,直接 import 会制造隐式顺序依赖。
二、fixture 的正确打开方式
1. 作用域分层
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @pytest.fixture(scope="session") def api_client(): """整个测试会话只建一次连接,最重的资源放 session 级""" client = APIClient(base_url=ENV["base_url"]) client.login("admin") yield client client.close()
@pytest.fixture(scope="function") def fresh_order(api_client): """每个用例独立的订单数据,函数级保证隔离""" order = api_client.create_order(sample_item()) yield order api_client.delete_order(order.id)
|
经验:资源建立成本决定 scope。连接、驱动、登录态 → session;业务数据 → function。把 function 级的东西升成 session 级是数据污染的头号来源。
2. 参数化:一份用例跑 N 组数据
1 2 3 4 5 6 7 8 9
| @pytest.mark.parametrize("amount, expected", [ (100, "PAID"), (0, "REJECTED"), (-1, "REJECTED"), (99999999, "RISK_BLOCKED"), ]) def test_pay_amount(api_client, fresh_order, amount, expected): result = api_client.pay(fresh_order.id, amount) assert result.status == expected
|
参数化用例在报告里是独立行,哪组数据挂了看一眼就知道,比 if/else 分支清晰一个量级。
三、标记(mark)与选择器:让"跑哪些"可表达
1 2 3 4 5 6 7
| pytest.ini: [pytest] markers = smoke: 冒烟用例(发版必跑) api: 接口层 app: App 端 UI slow: 单条超过 60s 的重型用例
|
1 2 3
| pytest -m "smoke and not slow" pytest tests/test_order -k "pay" pytest --lf
|
团队约定:CI 流水线分三档 profile——smoke(每次提交,5 分钟内)、api(每日构建,30 分钟)、all(发版前,2 小时)。用例没有标记 = 不许进主干。
四、失败现场:报告与产物
1 2 3 4 5 6 7 8
| import pytest
def pytest_runtest_makereport(item, call): if call.when == "call" and call.excinfo is not None: report = call.report report.extra.append(pytest_html.extrascreenshot(item.stash["screenshot"]))
|
- 用
pytest-html 出静态报告,失败用例自动带截图/请求响应 dump;
- 产物统一落
var/results/<run_id>/,报告头部写清:git sha、环境、分支——三个月后回看还能复现;
- 断言失败信息要带上下文:
assert result.status == "PAID", f"order={order.id} resp={result.raw}",不要裸 assert。
五、常见反模式
| 反模式 |
症状 |
解法 |
| 用例间共享全局变量 |
单独跑过、一起跑挂 |
状态全部走 fixture |
| conftest 里写业务逻辑 |
改个接口要动 5 个 conftest |
conftest 只建依赖,业务逻辑进 support/ |
用 pytest.skip 掩盖失败 |
跳过列表越来越长 |
skip 必须带 reason + issue 链接,限期清零 |
| fixture 里吞异常 |
数据建失败却报"断言失败" |
建依赖失败直接 raise,让失败发生在正确的地方 |
六、小结
pytest 的威力在 fixture 依赖图 + 标记选择器:前者解决"用例要什么",后者解决"这次跑什么"。把这两件事想清楚,测试工程就有了骨架,剩下的报告、CI、覆盖率都是插件级的事。