1. 为什么说pytest是自动化测试进阶的第一选择
1.1 先看看那些"跑得通但活不下去"的测试脚本
做Python自动化测试做到一定阶段,你会发现一个扎心的现象:很多人写的测试代码,能跑,但没人敢改。我接手过一套几百个函数的接口测试脚本,每个函数开头都是login(),中间是time.sleep(2),地址直接硬编码成http://192.168.1.100:8080,断言全靠把响应体打印出来人工看。这套东西最初只有几十条用例,跑起来还能忍,等到用例数量超过两百,维护成本已经不是线性增长,而是指数爆炸。
那个阶段的代码长什么样,我印象太深了:
def test_login(): url = "http://192.168.1.100:8080/api/login" data = {"username": "tester", "password": "123456"} resp = requests.post(url, json=data) print(resp.text) def test_order(): time.sleep(2) resp = requests.post( "http://192.168.1.100:8080/api/order", json={"goods": "iphone", "num": 1} ) print(resp.json()["orderId"])单看这些函数,问题还不算刺眼。但你要理解:当测试脚本的数量从十个涨到三百个,每个函数里都有一段登录、一段sleep、一段硬编码地址时,任何一次接口字段调整,都要全局搜索替换;任何一次环境迁移,所有脚本全部重改。最要命的是,脚本跑完了你根本不知道哪些用例真的通过,哪些只是"打印出来了但没有判断"。
pytest是来收拾这三笔烂账的。第一,它用assert的语义告诉框架"什么算失败";第二,它用fixture把前置后置逻辑从测试函数里抽出去;第三,它通过插件体系把报告、重试、并发、参数化全部纳入工程化轨道。这也是为什么我说,Python自动化测试进阶的第一道门槛,不是requests也不是selenium,而是测试框架——pytest就是这个阶段最值得投入的一项能力。
1.2 理解用例收集机制,你的目录结构才不会走歪
很多新手第一次用pytest,直接一个pytest命令跑起来,看到一堆用例执行了就觉得自己会了。实际上,pytest能跑到哪些用例,是有明确收集规则的,搞不懂这套规则,后面写大型工程一定会迷路。
默认规则很简单:
- 文件名以
test_开头或者以_test.py结尾的文件才会被收集; - 文件内以
test_开头的函数会被收集; - 以
Test开头的类,类里以test_开头的方法会被收集; conftest.py不属于测试用例,但会被自动加载,用来提供fixture和钩子函数。
举个例子,下面这个类就不是class TestLoginCase:,而是class LoginCase:,那pytest默认根本不会执行类里的test_login方法。这种坑几乎每个团队都踩过,看起来很小,排查起来却非常费时间。
还有一个我强烈建议养成的习惯:跑用例之前先用--collect-only看一眼收集结果。
pytest --collect-only这条命令不执行任何用例,只列出pytest将要运行的用例清单。目录结构改完之后、往CI里挂pytest之前,先跑一次收集,能省掉大量"为什么这条用例没跑"的排查时间。我在团队里定的规矩是:任何新增或移动测试文件,必须先用这条命令确认收集范围,再谈执行。
1.3 一个能应付日常的pytest.ini配置
pytest很多行为都可以在pytest.ini里统一声明,这也是工程化的重要一步。不要把所有参数都写在命令行里,那样换个人、换个机器就跑不齐了。
我常用的基础配置是这样的:
[pytest] testpaths = testcases addopts = -v -s --tb=short --strict-markers markers = smoke: 冒烟测试 p0: 核心用例 regression: 回归测试 flaky: 不稳定用例下面对应说明一下:
testpaths指定用例目录,pytest不会满仓库找,收集速度快很多;addopts是默认追加的命令行参数,-v输出详细日志,-s关闭输出捕获,让print能直接看到,--tb=short把失败堆栈压缩成短格式,--strict-markers让未注册的标记直接报错;markers把自定义标记提前注册好,不然每次运行都会蹦出一堆PytestUnknownMarkWarning,团队里根本分不清哪些标记是故意加的、哪些是拼错单词造成的。
配合markers,日常执行就能做到按维度筛选用例:
pytest -m "smoke and p0" pytest -m "not flaky" pytest -k "login or order"-m是按标记筛选,-k是按用例名做表达式匹配。这两条命令掌握好,测试执行效率能翻一倍,不用每次全量跑。
2. fixture机制:pytest和unittest分水岭的核心
2.1 从setup/teardown到fixture,思维上要做一次切换
如果你用惯了unittest,第一次看fixture可能会不太适应。unittest的模式是继承TestCase,然后在类里写setUp和tearDown,所有测试方法共享同一个前置后置。这个设计的最大问题在于:你不能精确控制每个方法需要什么数据,只能用一套模板硬套,数据一复杂就只能在测试方法内部再次初始化。
pytest的fixture是一次彻底的反转。它不是把前置后置"绑"在某个类上,而是把前置后置定义成一个可以被函数参数自动注入的对象。你不需要初始化什么,直接在测试函数声明一个同名参数,pytest就会把fixture的返回值传进来。
import pytest import requests @pytest.fixture def base_url(): return "http://127.0.0.1:8000" @pytest.fixture def session(base_url): s = requests.Session() s.headers["Content-Type"] = "application/json" s.headers["X-Client"] = "pytest" return s def test_login(session, base_url): resp = session.post(f"{base_url}/api/login", json={ "username": "tester", "password": "123456", }) assert resp.status_code == 200这里test_login里有两个参数,pytest会自动找到同名fixture,然后按依赖关系先执行base_url,再执行session——注意,session这个fixture自己又依赖了base_url。这种依赖注入能力,是unittest的setUp完全做不到的,它让前置条件变成可组合、可复用的模块。
2.2 fixture作用域:不是参数越多越好,而是范围越准越好
@pytest.fixture有一个关键参数scope,它决定了fixture的创建和销毁时机。默认是function,也就是每个用例执行前后各自创建一遍。除此之外还有四个级别:
| scope | 含义 | 典型场景 |
|---|---|---|
| function | 每个测试函数执行前后各执行一次 | 临时数据、mock替换 |
| class | 每个测试类执行前后各执行一次 | 类级别共享的初始化 |
| module | 每个模块执行前后各执行一次 | 模块级DB连接 |
| session | 整个测试会话只执行一次 | 登录token、全局配置 |
很多人一上来不管三七二十一,把能共享的全设成session,表面上看执行速度快了很多,实际上埋下了随机失败的雷。我自己踩过最痛的一次:把一个返回数据库会话的fixture设成session级别,结果用例之间因为共享同一个事务,数据互相污染,跑单条全过,跑全套随机挂。
我的经验是:作用域的选择要遵循"能小则小"原则。登录token这类天然全局的,用session没问题;数据库连接,如果业务有事务隔离,最好用module级别,用完即弃;临时造的数据,必须function级别,一条用例一清理。
2.3 conftest.py怎么划分fixture的作用边界
conftest.py是pytest最容易被误解的文件之一。很多人以为conftest.py是"全局配置文件",所以在根目录放一个,然后所有fixture都往里塞。实际上,conftest.py的作用域是按目录层级划分的:放在哪个目录,就只对该目录及其子目录生效。
理解这一点对团队协作非常重要。比如这样一个工程:
project/ ├── conftest.py # 全工程共用的fixture,如env、session ├── testcases/ │ ├── api/ │ │ ├── conftest.py # 接口测试专用fixture,如http_client │ │ ├── test_login.py │ │ └── test_order.py │ └── ui/ │ ├── conftest.py # UI测试专用fixture,如driver │ └── test_home.py根目录conftest只放所有测试都需要的公共设施;api目录的conftest放接口测试相关的设施;ui目录的conftest放UI测试相关的设施。这样每个目录的fixture边界清晰,谁改都不会影响不相关的模块。
另一个常见坑是:fixture名字冲突。如果两个conftest都定义了同名fixture,pytest会优先使用离用例更近的那一个,而不是报错。这个特性很方便,但也很隐蔽——你可能在某层conftest里修改了fixture实现,却发现运行结果毫无变化,因为更内层还有一个同名fixture在生效。排查的时候,建议用这个命令看每个用例实际用了哪些fixture以及fixture来自哪个文件:
pytest --fixtures这条命令会把所有可见fixture列出来,并标注定义位置,排查歧义特别好用。
2.4 teardown的三种实现方式与陷阱
fixture不仅管前置,也要管后置。pytest主推的是yield方式,在yield之前是setup逻辑,yield之后就是teardown逻辑:
@pytest.fixture def temp_user(db): user = db.create_user(username="tester") yield user db.delete_user(user.id)测试函数执行完之后,fixture会从yield处恢复,继续执行db.delete_user。这种方式的好处是:清理逻辑和创建逻辑放在同一处,读代码的时候不需要跳来跳去。
除了yield,还有request.addfinalizer,它可以在fixture中途动态注册清理函数,适合清理函数可能变化的场景。但大多数情况下,yield更直观,我更推荐yield。
使用teardown时有一个特别容易踩的坑:如果fixture的scope是session,那么teardown只会在整个测试会话结束时执行一次。也就是说,如果测试中途进程被Ctrl+C强杀,清理逻辑可能根本没机会跑。所以session级别的fixture,尽量不要依赖它的teardown去做关键数据清理,清理动作应该放在function级别或者交给测试用例本身。
3. 数据驱动与参数化:从写死脚本到批量用例
3.1 @pytest.mark.parametrize的基本动作
接口测试里最典型的需求就是同一个接口,换不同的入参,验证不同的期望结果。没有参数化之前,你会写出一堆几乎一样的测试函数:
def test_login_success(): ... def test_login_wrong_password(): ... def test_login_user_not_exist(): ...这种写法的问题不用多说。用@pytest.mark.parametrize可以把同一套逻辑收敛成一个函数,批量生成用例:
import pytest @pytest.mark.parametrize("username,password,expected_code", [ ("tester", "123456", 200), ("tester", "badpass", 401), ("nobody", "123456", 404), ]) def test_login(username, password, expected_code, session, base_url): resp = session.post(f"{base_url}/api/login", json={ "username": username, "password": password, }) assert resp.status_code == expected_code这条用例会展开成三条用例,每一条都有独立的测试结果。如果第一组数据挂了,第二组数据照样会跑,不会互相拖累。这也正是数据驱动最大的价值:测试数据和测试逻辑分离,新增场景只需要加一行数据,不需要复制一个函数。
3.2 从YAML/JSON文件里读取测试数据
当参数数据量变大,直接写在装饰器里会让代码文件很难看。更工程化的做法是把数据挪出来,放到yaml或json文件里。
# data/login_cases.yaml - username: tester password: "123456" expected_code: 200 - username: tester password: "badpass" expected_code: 401 - username: nobody password: "123456" expected_code: 404读取的时候,我习惯写一个辅助函数:
import yaml from pathlib import Path def load_cases(file_name): path = Path(__file__).parent.parent / "data" / file_name with open(path, encoding="utf-8") as f: return yaml.safe_load(f)这里必须提醒一件事:yaml加载一定要用safe_load,不要用load。旧版yaml.load可以解析任意Python对象,一旦数据文件被外部改动,存在代码注入风险。safe_load只解析基础类型,安全且够用。
然后这样参数化:
@pytest.mark.parametrize("case", load_cases("login_cases.yaml")) def test_login(case, session, base_url): resp = session.post(f"{base_url}/api/login", json={ "username": case["username"], "password": case["password"], }) assert resp.status_code == case["expected_code"]需要注意一个细节:参数化数据是pytest在收集阶段就固定的,所以load_cases会被提前执行。如果yaml文件里有运行时才生成的内容,比如时间戳,就必须再包一层fixture或在用例内部去读取,不要在parametrize里去做动态计算。
3.3 参数化遇上fixture:indirect的用法
有一种场景:你要构造多个不同的用户,每个用户都需要经过fixture做初始化。直接parametrize一个user参数,pytest会把它当普通参数处理,而如果你想让这个参数成为fixture的输入,就需要打开indirect=True。
@pytest.fixture def user(request): user_info = request.param # 这里可以做一些初始化逻辑,比如注册、清缓存、生成token return user_info @pytest.mark.parametrize("user", [ {"name": "tester", "role": "admin"}, {"name": "viewer", "role": "guest"}, ], indirect=True) def test_permission(user): assert user["role"] in ("admin", "guest")打开indirect=True之后,pytest会把参数值作为request.param传给同名fixture,再由fixture做进一步加工返回给测试函数。这在接口自动化里的典型用法是:每个用例组传入不同的账号角色,fixture负责完成该角色的登录和token初始化。
3.4 给参数化用例一个能看懂的名字
默认情况下,参数化用例的ID是参数值的repr,比如test_login[case0]。如果数据很多,报告中显示为case0、case1会让排查的人非常痛苦。最佳实践是用ids参数把用例ID改成可读的业务含义。
@pytest.mark.parametrize("case", load_cases("login_cases.yaml"), ids=["成功", "密码错误", "用户不存在"]) def test_login(case, session, base_url): ...如果你的数据是从yaml读的,更常见的做法是把id作为一个字段放进数据文件:
# data/login_cases.yaml - id: 登录成功 username: tester ...然后在ids这个参数里写一个lambda读取id字段:
@pytest.mark.parametrize("case", load_cases("login_cases.yaml"), ids=lambda case: case["id"]) def test_login(case, session, base_url): ...这样Allure报告、命令行输出、失败日志里出现的都是"登录成功"这类一眼能看懂的用例名,而不是case3这种天书。
4. 断言与失败处理:遇见问题不白跑一趟
4.1 用pytest.raises把异常断言写严谨
很多测试只断言"接口返回200"就完事了,但异常路径同样需要覆盖。pytest里断言异常的标准写法是pytest.raises:
import pytest def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): 1 / 0更实用的做法是捕获异常对象,再对异常的细节做断言:
def test_login_user_not_exist(): with pytest.raises(ValueError) as exc_info: login("nobody", "123456") assert exc_info.value.args[0] == "user not found"pytest.raises还支持match参数,用正则表达式去匹配异常消息,适合验证"是不是我们预期的那一种错误":
def test_login_empty_password(): with pytest.raises(ValueError, match="password.*empty"): login("tester", "")这种方式比单纯判断异常类型要严谨得多。同一类型异常可能有很多原因,通过match能精确锁定错误信息。
4.2 软断言:一条用例里多个校验点不互相打断
默认情况下,assert一旦失败,测试函数立即终止,后续的断言不会执行。这在某些场景里很浪费——你已经有5个校验点,第一个挂了,后面4个都不知道状态是什么,只能修完再跑一遍。
pytest-assume插件提供了软断言能力:
import pytest def test_order_detail(): resp = get_order_detail("order_001") pytest.assume(resp["status"] == "PAID") pytest.assume(resp["amount"] == 99.00) pytest.assume(resp["items"][0]["name"] == "iPhone")所有pytest.assume都会执行,最后统一报告哪些断言失败。这个插件非常适合响应体字段特别多的接口校验场景,一次跑出所有差异点。
但这里有个重要的兼容性坑:pytest-rerunfailures和pytest-assume是不兼容的,pytest-rerunfailures会在收集阶段直接报错或表现异常。所以在一个工程里,重试策略和软断言往往只能选一个。我的处理方式是:常规用例用软断言,少数容易超时不稳定的用例单独加标记,不全局开重试。
4.3 接口自动化里的重试策略:不是无脑重试
接口自动化跑久了,一定会遇到网络抖动、服务端偶发超时这类问题。直接判失败太冤,无脑重试又可能掩盖真正的缺陷。我推荐的组合是pytest-rerunfailures加上pytest-timeout。
先装依赖:
pip install pytest-rerunfailures pytest-timeout然后分两层设计:
第一层,全局兜底:在pytest.ini的addopts里加上--reruns 2 --reruns-delay 1 --timeout=10。含义是所有用例最多重试2次,每次间隔1秒,单个用例执行超过10秒直接判定失败。
[pytest] addopts = -v -s --tb=short --reruns 2 --reruns-delay 1 --timeout=10第二层,精确治理:对确知容易偶发失败的用例,用标记单独配置重试次数:
@pytest.mark.flaky(reruns=5, reruns_delay=2) def test_sometimes_timeout(): ...这里的关键不是让所有用例都重试5次,而是把"确定性"和"偶发性"区分开。确定失败的用例重试再多也是白费时间,偶发失败的用例才能从重试里获益。判断标准很简单:一个用例重试后通过了,要去看日志,确认是不是依赖的服务抖动;如果重试后仍然失败,那就是真缺陷,要进入缺陷流程。
4.4 skip与xfail的正确打开方式
没有skip的测试工程,迟早会被环境问题淹没。pytest里跳过用例有三种常见姿势:
@pytest.mark.skip(reason="接口尚未开发"):无条件跳过,用于已知但暂未实现的场景;@pytest.mark.skipif(condition, reason="仅在Windows下运行"):条件跳过,用于平台差异、环境差异;@pytest.mark.xfail(condition, reason="已知缺陷"):预期失败,用于已知问题但暂不修复的用例。
xfail有个很有用的操作:如果你写的断言逻辑是"当前版本这个接口不接受该参数,但预期未来会支持",就可以用strict=True。当接口突然支持了、用例真的通过了,pytest会把这个用例标记为XPASS,并且在设置了--strict-markers后直接让测试套件失败,倒逼你主动更新用例。这样一来,已知问题修复的瞬间,测试就会提示你"该解锁了"。
5. 插件生态与Allure报告:从控制台到可视化
5.1 我常用的pytest插件矩阵
pytest最强大的地方在于插件生态。不用自己造轮子,需求基本都有现成插件。我按实际使用频率整理了一张表:
| 插件 | 解决的问题 | 命令/装饰器 |
|---|---|---|
| pytest-xdist | 并行执行,缩短总耗时 | pytest -n 4 |
| pytest-ordering | 控制用例执行顺序 | @pytest.mark.run(order=1) |
| pytest-timeout | 单个用例超时控制 | @pytest.mark.timeout(10) |
| pytest-rerunfailures | 失败重试 | --reruns 2 |
| pytest-assume | 软断言 | pytest.assume() |
| pytest-cov | 覆盖率统计 | pytest --cov=api |
| allure-pytest | Allure报告生成 | --alluredir=./results |
这里要提醒一下pytest-ordering。在理想的测试工程里,用例之间是不应该存在执行顺序依赖的,但现实中总有历史包袱,比如某些用例必须等登录用例先跑。pytest-ordering能救急,但不能当信仰。长期依赖它,用例之间的隐式依赖会越来越多,最后没人敢并行、没人敢单独跑某个用例。
5.2 Allure报告接入与特性标注
大部分测试团队从"能跑完"到"能讲清楚",靠的就是Allure报告。接入步骤不复杂:
pip install allure-pytest pytest --alluredir=./allure-results --clean-alluredir allure generate ./allure-results -o ./allure-report --clean allure open ./allure-report--clean-alluredir非常重要。如果不加,下一次运行的结果会和上一次残留文件混在一起,报告里的用例数量和通过率全乱套。我自己第一次接Allure时就吃过这个亏,一度以为插件有bug,最后发现是没清理旧目录。
Allure的价值不只是把结果变成网页,更在于用注解给用例补充业务上下文。我常用的注解就这几个:
import allure @allure.feature("登录模块") @allure.story("密码校验") @allure.title("登录时密码错误返回401") @allure.severity(allure.severity_level.BLOCKER) def test_login_wrong_password(): ...feature对应大模块,story对应功能点,title覆盖默认用例名,severity标记重要程度。跑完之后在Allure报告里,产品、测试、开发都能按模块和严重级别筛选用例,不用再对着txt日志猜。
5.3 用pytest_runtest_makereport钩子把失败现场记录下来
Allure的另一个杀手锏是attachment。你可以把失败时的请求地址、响应体、日志片段都贴到报告里,让开发同学打开报告就是一手现场信息。
我一般不在每个用例里手动写attach逻辑,而是在conftest.py里通过hook统一实现。最常用的钩子是pytest_runtest_makereport:
import allure import pytest @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: # 尝试从fixture里取出session或者响应体 if "session" in item.funcargs: session = item.funcargs["session"] allure.attach( body=str(session.headers), name="请求头", attachment_type=allure.attachment_type.TEXT, )这里item.funcargs能拿到当前用例已经注入的所有fixture返回值。也就是说,你既能拿到session,也能拿到基础配置,把这些信息在失败时attach到Allure报告里,排查问题时非常管用。
需要注意,hook函数里要判断call.when == "call",因为一个用例会经历setup、call、teardown三个阶段,三个阶段都会触发这个钩子。如果在setup阶段就失败了,设备或环境问题优先于用例本身,这时候记录请求头意义不大。另外,hookwrapper=True的写法一定要记得yield,否则会破坏pytest内部的结果处理,出现很诡异的行为。
6. 接口自动化工程实战:从demo到可维护体系
6.1 一个亲测好用的工程目录结构
理论知识再多,落不了地都是空谈。下面是我在多个项目里验证过的目录结构,不复杂,但足够让一个中小型接口自动化工程稳定转起来:
project/ ├── pytest.ini ├── requirements.txt ├── conftest.py ├── config/ │ ├── __init__.py │ └── settings.py ├── api/ │ ├── __init__.py │ ├── base.py │ └── user_api.py ├── data/ │ └── login_cases.yaml ├── testcases/ │ ├── __init__.py │ ├── conftest.py │ └── test_login.py └── utils/ ├── __init__.py └── http_client.py各层职责要明确:
config/settings.py放环境配置和全局常量;api/封装接口调用层,测试用例不直接写requests请求,而是调用user_api.login()这类方法;data/放参数化数据文件;testcases/只放测试用例,不写业务逻辑;utils/放公共工具函数。
这个结构最核心的原则是"测试用例不直接碰requests"。为什么要这样?因为当接口字段调整时,你只需要改api/层,testcases/里的断言逻辑几乎不动。如果不做这层封装,每个用例里都是一大段requests代码,改一个字段名要动几十处。
6.2 环境切换方案:同一个用例,跑遍dev、test、prod
自动化测试最基础的诉求之一,就是同一套用例能在不同环境之间切换。我在config/settings.py里通常这样设计:
import os ENV = os.getenv("TEST_ENV", "dev") CONFIG = { "dev": { "base_url": "http://127.0.0.1:8000", "db_url": "sqlite:///dev.db", }, "test": { "base_url": "http://test.internal.example.com", "db_url": "mysql://user:pass@test-db:3306/app", }, } current_config = CONFIG[ENV]然后在conftest.py提供fixture:
import pytest from config.settings import current_config @pytest.fixture(scope="session") def base_url(): return current_config["base_url"]使用环境变量而不是在代码里写死,是为了方便在CI里动态切换。GitLab或Jenkins的流水线里,跑dev环境就exportTEST_ENV=dev,跑test环境就exportTEST_ENV=test,测试代码一行不用改。
如果你需要支持命令行参数方式,可以自己注册一个--env选项,原理一样。但我的经验是:环境变量在CI里天然好用,命令行参数在本地调试时方便。两者取决于团队习惯,不需要都上。
6.3 登录态与token复用:session级别fixture的正确用法
接口自动化里最痛苦的事情就是每个用例都登录一遍。一次两次还能忍,几百个用例下来,光登录耗时就能拖垮整个执行时间。正确的做法是:把登录后的会话或token做成session级别的fixture,整个测试过程只登录一次。
import pytest import requests @pytest.fixture(scope="session") def http_session(base_url): s = requests.Session() resp = s.post(f"{base_url}/api/login", json={ "username": "autotest", "password": "123456", }) assert resp.status_code == 200 token = resp.json()["token"] s.headers["Authorization"] = f"Bearer {token}" return s这里有两个细节需要特别注意。
第一,requests.Session()不是普通的一个请求函数,它会自动保存cookie,也能统一加headers。用它做单例会话是最合适的。
第二,session作用域下,登录只执行一次,但如果用例之间数据相互影响,后面用例拿到的token可能就不是最新的了。如果被测系统的token有权限变更机制,比如管理员权限被降级,就要把scope降到module甚至function。不要迷信session,选择作用域的核心依据是"被复用资源是否会被用例改动"。
6.4 数据清理与用例隔离:并行执行的前提
很多团队不敢用pytest-xdist并行,不是xdist不好用,而是用例之间不隔离,一并行就乱。要做到能并行,数据隔离是必须跨过的坎。
我给团队定的规则很简单:每个用例涉及的业务数据,要么在用例内部自行创建并清理,要么用唯一标识避免冲突。比如测试创建订单,订单号一定不能写死:
import uuid def test_create_order(http_session, base_url): order_no = f"auto_{uuid.uuid4().hex[:12]}" resp = http_session.post(f"{base_url}/api/order", json={ "order_no": order_no, "goods": "iphone", }) assert resp.status_code == 200 # 用例结束前自行清理 http_session.delete(f"{base_url}/api/order/{order_no}")uuid.uuid4().hex[:12]生成一个几乎不会重复的短随机串,确保并行环境下每个进程创建的数据互不干扰。清理动作放在用例内部而不是teardown里,是因为teardown一旦因为异常跳过,数据就残留了。在用例内部清理,至少能保证正常路径下不留垃圾数据。
6.5 持续集成里的执行策略和团队协作经验
最后把这套工程挂到CI上时,我总结了一套比较省心的执行策略:
pytest -m "smoke" --env=dev --alluredir=./allure-results --clean-alluredir冒烟阶段只跑smoke标记的少量核心用例,保证主流程没问题;冒烟过了再跑全量回归:
pytest -m "regression" -n 4 --dist=loadscope --env=test --alluredir=./allure-results --clean-alluredir这里加了-n 4让4个进程并行,--dist=loadscope保证同一个模块的用例尽量分到同一个进程,避免因为模块内共享状态导致并行失败。
在团队协作上还有一个血的教训:pytest的输出格式必须统一。很多团队每个人本地的pytest.ini都不一样,有的加-v,有的不加,最后在CI里看到的日志五花八门。所以pytest.ini一定要进版本库,并且作为强制标准;本地跑测试,直接敲pytest,所有参数从pytest.ini读取,不让人为自定义。
把上面这六块内容吃透,pytest就不再是一个"能跑测试的库"了,它会真正变成你自动化测试工程的底座。我每次看到有人还在脚本里写login()、sleep(2)的时候,都会想起自己当年被那几百个无人敢改的测试函数支配的日子。框架不是万能的,但它能帮你把那些重复劳动从代码里抽离出来,腾出精力去关心真正值得关注的业务逻辑和测试设计。