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.pytest_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) # teardown:清理,避免脏数据

经验:资源建立成本决定 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
# conftest.py
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、覆盖率都是插件级的事。