1. 从 unittest 转向 pytest:一次测试框架选型的真实复盘先说个背景。前阵子我接手了团队里一套老项目的回归测试,代码量不大,但里面大几十个用例全是基于 unittest 写的。写起来难受,跑起来也难受——每个测试类都要手动继承 TestCase,断言失败的时候控制台输出只有一行AssertionError,想看具体哪个值不对还得自己打日志。后来我们逐步把用例迁到 pytest,同样的功能,代码量直接缩了一半,定位问题的速度更是完全不在一个量级。很多人可能会问: pytest 到底好在哪里?我自己的体会可以归纳成三个点,这也是当初我们决定迁框架的原因。第一,pytest 的用例就是普通的函数。不需要类继承,不需要特意命名成test_xxx之外的方法,一个文件里直接写函数就行,pytest会按照约定收集它们。这对入门者极度友好,也方便后期维护。第二,断言是原生的assert。写完表达式,pytest 会通过“断言内省”机制告诉你左边是什么、右边是什么、差在哪里。这在调试接口返回、数据库查询结果这类场景时,能省掉大量重复的print排错。第三,fixture 体系非常灵活。同一个临时数据,在 unittest 体系里要写 setUp、tearDown,还要考虑继承、调用顺序;在 pytest 里就是一个带@pytest.fixture装饰器的函数,用的时候声明参数即可注入。数据准备、环境清理、数据库回滚、登录态管理,都能很干净地拆开。顺着这个思路,下面我把 pytest 的语法和核心概念拆开讲。内容上会刻意控制“由浅入深”:先讲断言和收集规则,再重点讲 fixture 和参数化,最后补充工程化配置与踩坑经验。这样你既能快速上手,也能直接往真实的自动化框架里套。2. 断言与测试发现规则:把 pytest 的底层机制先吃透2.1 断言的“内省”机制:为什么失败信息能这么完整pytest 没有重新发明断言语法,它就是直接复用了 Python 原生的assert语句,但它背后做了一件很聪明的事情:在测试运行时,pytest 会重写测试模块的字节码(AST 层面的改写),把assert语句替换成带详细上下文信息的调用。举个例子,你写了一句:def test_user_age(): user_age = 18 expected_age = 20 assert user_age == expected_age在 unittest 里,跑挂了你只会看到一行AssertionError。但在 pytest 里,失败输出会直接提示:E AssertionError: assert 18 == 20更复杂的表达式,比如assert {"name": "张三"} == {"name": "李四"},它还会帮你展示两个 dict 的差异点。这就是所谓“断言内省”。对我们搞自动化的人来说,这个能力直接决定了排查效率。CI 上跑挂了、日志一堆,你能最快看到的是 pytest 帮你算出来的“实际值 vs 期望值”,而不是自己在代码里到处埋点。有一点需要注意,pytest 只能改写它在收集阶段加载的测试文件。也就是说,如果你在非测试模块里写了一个公共函数,里面也带assert,那个断言失败时不一定能得到这么详细的内省信息。所以我的建议是:把断言尽量写进测试函数内部,公共函数里可以不写断言。2.2 测试文件与用例收集规则:命名是你最好的文档pytest 默认按以下规则收集测试:文件名匹配test_*.py或*_test.py;文件里的函数名以test_开头,类名以Test开头(注意是驼峰大写 T),类内方法名也是test_开头;目录下如果没有__init__.py,pytest 会用“根目录下的唯一包名”模式进行导入,避免包名冲突。这个规则看起来简单,但在大型项目里很容易被误解。我见过不少人在test_helpers.py里放一个函数helper_do_something(),以为 pytest 会把它当用例跑。其实不会,因为函数名不以test_开头。反过来,有人把工具函数命名为test_get_time(),结果被 pytest 收集成一个用例,里面什么断言都没有,白白多了一个永远通过的测试,还会污染测试报告。所以,工具函数统一放到helpers.py、utils.py这种不会被收集的文件里,测试文件只放测试用例,命名上严格执行test_前缀。这看起来是个小事,但团队接手的第二天就会感谢你。2.3 常用断言写法与浮动精度处理pytest 里最基础也最实用的几类断言,我整理成一张表做对照:场景推荐写法说明数值相等assert result == 100内省机制会展示实际值浮点近似assert result == pytest.approx(0.3333, abs=1e-3)别直接用==比较浮点,坑死你是否为空assert not items比assert len(items) == 0更简洁容器包含assert "error_code" in response_body常用于判断接口返回字段异常断言with pytest.raises(ValueError):代替 unittest 的assertRaises比较字典子集assert expected.items() <= actual.items()适合只校验部分字段浮点比较这个点,我不止一次在队友的代码里看到assert a == b然后时灵时不灵的情况。不要拿浮点直接相等,要么用pytest.approx,要么你自己手动算一个误差范围。接口返回里的金额、比例这类字段,用pytest.approx是最稳的选择。异常断言还有一个很实用的细节,pytest.raises返回的是异常信息对象,你可以接着校验异常消息、异常类型,比如:with pytest.raises(ValueError) as exc_info: parse_config("invalid_config") assert "unknown key" in str(exc_info.value)这样就把“抛没抛异常”和“异常信息对不对”拆成两次检查,排查问题时信息更明确。实际项目里,接口参数校验、配置解析这类场景非常有用。3. fixture 体系:pytest 区别于 unittest 的灵魂设计3.1 fixture 的定义与依赖注入fixture 可以说是 pytest 最核心、也最需要花时间理解的概念。简单说,fixture 就是一个被@pytest.fixture装饰的函数,它负责准备一个“可被测试用例依赖的东西”。这个东西可以是一段配置、一个客户端连接、一条测试数据,甚至是一个 mock 对象。用法上,测试函数想用哪个 fixture,就在参数列表里写哪个名字,pytest 会自动去找并注入进来。比如:import pytest @pytest.fixture def api_client(): # 这里可以初始化一个带 token 的 HTTP 客户端 client = build_http_client() return client def test_get_user(api_client): resp = api_client.get("/user/1") assert resp.status_code == 200这段代码里,api_client会被自动创建、注入到测试函数里。你不用在测试函数里手动调用,也不需要 import,更不需要在 setup 里声明。这就是“依赖注入”的直观体验。我自己的体会是,刚开始用 fixture 时会忍不住在测试函数内部手动创建对象,然后发现代码越来越绕。切换到 fixture 后,“准备数据”和“执行业务断言”这两个职责就彻底分开了,测试函数只关注它关心的业务逻辑,这是一个很大的心智改善。3.2 scope 参数:控制 fixture 的创建与复用粒度fixture 默认每个测试函数执行前都会重新创建,这相当于 unittest 的setUp。但很多场景下,我们希望一个昂贵的操作只做一次,比如浏览器实例、数据库连接、登录 token。这时候就要用到scope:scope 值生命周期适合场景function每个测试函数执行前后各执行一次默认值,适合数据隔离需求高的用例class每个测试类执行一次类级别共享资源module每个测试模块执行一次模块级别的公共数据package每个包目录执行一次较少用session整个 pytest 会话周期执行一次全局唯一对象,如浏览器、数据库连接我推荐的原则是:能用低粒度(function)就不用高粒度(session),除非创建开销大到无法接受。因为粒度越高,用例之间的耦合就越强。比如你把一个“修改型操作”写进了 session 级 fixture,后面的测试用到同样的数据就可能拿不到预期值,排查起来会很头疼。有一次我在一个接口测试项目里,把一个数据库连接设为 session 级,本来没问题,但后面对接了一个新需求:不同模块要操作不同的测试库。结果 session 级连接没法切库,所有用例全部串了。最后我把连接拆成 module 级,按目录区分测试库,问题才解决。scope 不是越大越好,而是要根据数据隔离需求来定。3.3 conftest.py:跨文件共享 fixture 的秘密武器当多个测试文件都需要同一个 fixture 时,把 fixture 写在某个测试文件里显然不合适。pytest 的标准做法是放到conftest.py里。conftest.py是一个特殊的配置文件,pytest 会自动发现并执行它,而且它定义的 fixture 可以被同目录及以下所有目录的测试用例使用。这个目录层级非常关键,因为它天然形成了一种“作用域链”:项目根目录/ ├── conftest.py # 全项目可用 ├── tests/ │ ├── test_a.py │ ├── api/ │ │ ├── conftest.py # 仅 api 目录下可用 │ │ ├── test_login.py │ │ └── test_order.py我一般会把“全局共通”的 fixture(比如日志配置、公共的 API 根地址)放在根目录conftest.py,把“某个模块专属”的 fixture(比如特定业务域的测试数据结构)放在对应目录的conftest.py里,这样查找依赖时逻辑非常清晰。不过这里有一个常见的坑:conftest.py里的 fixture 是“自动发现”的,如果你给某个 fixture 取名client,而另一个 conftest 文件里又有一个client,后者的定义会覆盖前者,很容易造成莫名行为。团队协作时,fixture 的命名最好带模块前缀,比如order_api_client、auth_session,避免重名。3.4 yield fixture:把“用完清理”变成一等公民unittest 里的tearDown最大的问题是,你不得不在setUp里准备好所有东西,然后等测试结束再清理。而 pytest 的 fixture 支持用yield把“准备阶段”和“清理阶段”放进同一个函数:import pytest @pytest.fixture def database(): conn = create_connection() yield conn conn.close() def test_query(database): result = database.query("SELECT 1") assert result == 1yield前面的部分是测试执行前的准备,yield后面的部分是测试结束后的清理。不管测试成功还是失败,清理代码都会执行。这就是资源释放的兜底。yield fixture 后面还可以接finally,用来兜底异常情况:@pytest.fixture def database(): conn = create_connection() try: yield conn finally: conn.close()这个写法等价于tearDown的兜底逻辑,但看起来直白得多。我们在做数据库测试时,经常把“清空相关表”“回滚事务”这些操作直接写在 yield 后面,测试函数里只用专注于业务断言,非常舒服。4. 参数化测试:一份用例,多组数据,覆盖度翻倍4.1parametrize的三种基本用法接口测试里最常见的需求是:同一个接口,用多组入参去验证不同的分支。如果不参数化,你只能复制粘贴多个测试函数,或者用循环包在一个测试函数里。前者代码冗余,后者一个用例挂了就整体失败,都拿不到详细的分组报告。pytest 提供了@pytest.mark.parametrize,直接解决这个问题:import pytest @pytest.mark.parametrize("input_data, expected_code", [ ("valid_user", 200), ("invalid_user", 401), ("missing_field", 400), ]) def test_login(input_data, expected_code): resp = login(input_data) assert resp.status_code == expected_code执行时 pytest 会把三组数据拆成三个独立用例,报告会分别展示test_login[valid_user-200]、test_login[invalid_user-401]这类测试 ID。挂了哪组,一眼就能定位是哪组输入的问题,完全不影响其他组执行。这在大量数据回归时非常好用。也可以在类上使用parametrize,这样类里的每个测试方法都会拿到参数:@pytest.mark.parametrize("n", [0, 1, 10]) class TestMathOperations: def test_square(self, n): assert n * n >= 04.2 堆叠使用:多参数组合的笛卡尔积如果多个变量之间需要做组合,可以直接叠加多个parametrize装饰器。pytest 会帮你生成所有参数的组合,也就是笛卡尔积:@pytest.mark.parametrize("username", ["alice", "bob"]) @pytest.mark.parametrize("status", ["active", "blocked"]) def test_user_status(username, status): assert get_user_status(username) == status这个场景做“不同用户类型 × 不同账号状态”的登录/权限测试非常方便。不过要注意,组合数量是乘数关系,参数一多用例数量会爆涨。一般我会控制组合维度不超过三个,超过三个就用数据文件或从数据库查,避免用例爆炸。4.3 参数 id:给用例起个能看懂的名字默认的测试 ID 是参数值直接拼上去,如果参数是长字符串或者 dict,报告会特别难看。可以用ids参数给每一条数据单独命名:@pytest.mark.parametrize( "input_data, expected_code", [ ("valid_user", 200), ("invalid_user", 401), ("missing_field", 400), ], ids=["正常用户登录", "密码错误", "缺少必填字段"], ) def test_login(input_data, expected_code): ...实际跑的时候,报告显示的是test_login[正常用户登录]这种可读性很强的名字。团队评审、失败报告沟通时,这个细节特别加好感。4.4 从外部文件读取参数:数据与逻辑分离的进阶思路测试数据保持在代码里固然直观,但一旦量级到了几百组,我建议把数据挪到文件里管理。我用得最多的是 YAML 或者 JSON,配合一个 fixture 读取:import json import pytest @pytest.fixture(scope="session") def login_cases(): with open("data/login_cases.json", encoding="utf-8") as f: return json.load(f) @pytest.mark.parametrize("case", login_cases.__dict__) def test_login_auto(case): resp = login(case["username"], case["password"]) assert resp.status_code == case["expected_code"]当然这里的login_cases.__dict__只是示意,fixture 一旦通过参数传入,你可以直接拿到整个列表,再用pytest.mark.parametrize传入数据。需要注意,parametrize装饰器无法直接感知 fixture 里的数据,所以通常的做法是:提供数据源的 fixture 在测试函数内部使用,而parametrize的数据来自静态内容。如果你一定要“动态读取参数化”,可以用 pytest 的钩子函数,那个属于进阶玩法,本文先不展开。多数情况下,静态文件 + fixture 读取已经足够。5. 工程化相关配置与常用插件:让 pytest 真正能支撑自动化体系5.1 pytest.ini:一处配置,全家受益pytest 的很多运行参数可以从命令行带,但更推荐集中写在pytest.ini里。文件放在项目根目录,pytest 会自动读取。我的一份最小可用配置长这样:[pytest] minversion = 7.0 testpaths = tests addopts = -v -s --tb=short markers = smoke: 冒烟用例 slow: 慢用例 api: 接口用例这里的addopts参数会在你执行pytest时自动带上命令行选项,比如-v详细输出、-s不捕获 stdout、--tb=short缩短堆栈信息。团队成员执行时不用记一长串命令,统一体验,也避免漏参数。markers部分不是摆设。pytest 执行时如果你用了未注册的 marker 名称,会看到一个 warning。注册过后,marker 的用途就一目了然,还可以配合-m参数筛选执行。5.2 常用插件组合:我推荐的五件套pytest 之所以生态强大,很大程度靠插件。以下是我在没有特殊业务约束时的固定搭配:插件作用备注pytest-cov覆盖率统计CI 门禁常用pytest-xdist多进程并行执行用例-n auto自动分配 CPUpytest-ordering控制用例执行顺序通过@pytest.mark.run(order=1)标记pytest-rerunfailures失败用例自动重试对偶发不稳定用例友好pytest-timeout用例超时控制防止用例挂死拖垮总时长并行执行pytest-xdist有一个坑:多个进程共享同一个外部资源时可能会互相干扰,比如都写同一个临时文件、都占用同一个端口。所以用分布式执行前,必须先确认用例之间没有共享可变状态,或者共享资源被正确隔离。通常我接到一个老项目时,第一次跑并行不用-n auto,先-n 2小规模验证,稳定后再逐步加,这样避免一上来就乱成一锅粥。5.3 marker 标记:怎么筛出冒烟用例、跳过条件marker 就是给测试“贴标签”的机制,最典型的场景是冒烟测试。你不想每次全量跑上千个用例,只想跑关键链路,可以这么写:import pytest @pytest.mark.smoke def test_login_success(): ...执行时:pytest -m smoke就会只收集带smoke标记的用例。同理,-m "not slow"排除慢用例。跳过用例也是一个高频需求。条件不成熟、环境缺失、某个 bug 未修,都可以显式跳过:import pytest @pytest.mark.skip(reason="接口尚未上线") def test_new_feature(): ... @pytest.mark.skipif(not check_es_available(), reason="ES 未部署") def test_elasticsearch_query(): ...skipif的用法非常灵活,甚至可以读取环境变量来控制。很多项目里“被测服务没起来就不跑集成测试”这种需求,用它处理起来非常顺手。5.4 钩子函数:轻量级进入 pytest 内部的机会pytest 的钩子函数是它作为框架最有弹性的部分。如果你希望“收集完用例之后做点排序”、“跑完之后生成自定义报告”,都可以通过钩子实现。最常见的两个:# conftest.py def pytest_collection_modifyitems(config, items): # 按节点 id 倒序执行 items.reverse() def pytest_terminal_summary(terminalreporter, exitstatus, config): # 终端输出结束后追加统计信息 passed = len(terminalreporter.stats.get("passed", [])) failed = len(terminalreporter.stats.get("failed", [])) print(f"自定义统计:passed={passed}, failed={failed}")钩子函数的数量非常多、功能覆盖也广,但我的建议是:前期不要过度依赖钩子,先掌握 fixture、参数化、marker,够应付绝大多数工程问题。钩子更多是当标准能力不够时再动手定制,否则很容易把自己带进维护成本的坑里。6. 落地三个项目之后的经验总结:容易踩的坑与我的排查思路6.1 用例隔离问题:共享状态为什么会让测试随机失败我在自己的一个自动化测试项目里吃过一次大亏。当时写了一个操作订单状态的用例,简单粗暴地把订单创建放在了测试函数内部,断言时又直接查询数据库。表面上看逻辑没问题,但跑久了就发现一个奇怪的现象:如果前面的用例把某个订单状态改成了“已支付”,后面的用例查询到的状态就不符合预期,两条用例偶尔会先后失败。这个问题的本质是用例之间的数据隔离没做好。解决方案有两个方向:一是每个用例都创建自己的数据,不依赖其他用例修改后的状态;二是用 fixture 的 function 级作用域 + 清理逻辑(比如 yield 后面清表/回滚)保证用例执行前后的数据一致。我更喜欢方案二,因为它把“数据准备+数据清理”收敛在 fixture 里,测试函数里根本不需要关心上下文。6.2 conftest 层级覆盖:为什么改了 fixture 却没有生效有段时间我遇到一个诡异的问题:我在某个子目录的 conftest.py 里定义了customer这个 fixture,但子目录下的用例跑的却是根目录那个customer。查了半天才发现,同事在根目录 conftest 里也定义了一个同名的 fixture,pytest 的查找规则是“就近优先”,但由于我第一次没有注意作用域链,导致预期和实际完全相反。这种问题最好的排查方式永远是直接打印 fixture 源码。在用例里临时加一条:import inspect, pytest @pytest.fixture def debug_fixture(request): pass或者更简单,在 conftest 里加一行print(inspect.getsource(customer))。看到当前生效的定义来自哪个文件,问题就清楚了。6.3 失败用例重跑逻辑:不是所有失败都值得重试pytest-rerunfailures很香,但不能滥用。我的原则是:只对已知偶发失败用例加@pytest.mark.flaky(reruns=2)标记,不要全量开启重试。因为如果所有用例都重试,你会失去一个重要的信号——哪些用例真的不稳定。全量重试跑完,绿是绿了,但你把“不稳定”系统性掩盖掉了,后面排查线上问题时只会更痛苦。另外,如果用例本身有副作用(比如写入生产库、发送真实短信),重试可能导致重复执行副作用逻辑,这种用例一定不能开重试。6.4 调试利器:从失败堆栈里快速定位是哪一条数据出的问题日常调试我用最多的三个参数:pytest tests/test_login.py -v --tb=long pytest tests/test_login.py --lf # 只跑上次失败的用例 pytest tests/test_login.py --pdb # 失败后进入交互式调试--lf(last failed)是最实用的一个:回归时如果有几条用例挂了,修完代码直接--lf复跑,不用全量跑一遍。--pdb适合在本地环境深入研究问题,CI 环境别开,因为没人去交互。结合参数化时,-k参数还可以做字符串筛选,比如pytest -k "正常用户登录"只跑名字匹配的用例。这在参数化用例很多的时候,效率提升非常明显。6.5 动态参数化与 fixture 混用时的数据加载次序我自己经常遇到的一个需求是:测试数据从 Excel 读取,但表头格式会变。这时候如果硬把动态数据塞进parametrize,测试还没开始就报错了。我的做法是分两步:第一步,先写一个独立的脚本验证数据文件解析没问题;第二步,再把解析好的数据以模块级常量的形式传给parametrize。这样等于把一个“动态读取”问题拆成了“静态数据 + 测试逻辑”两个环节,出问题的时候定位路径清晰得多。如果你确实需要非常动态的参数化,pytest 支持pytest_generate_tests钩子,可以在收集阶段动态调用参数。这个机制我一直建议团队内部“能用简单方案就别上钩子”,因为它的可读性比较差,新手维护起来容易懵。只有当你有几十种数据源、需要统一处理时才值得动手。最后说一点我自己的体会。pytest 真正厉害的地方不只是语法简洁,而是它把“测试代码的组织方式”和“工程化能力”做成了同一套体系。你用 fixture 管理依赖、用 parametrize 管理数据、用 marker 管理筛选、用钩子管理扩展,这些概念彼此独立,但又能在同一个项目里自然组合。上手阶段建议从小项目练起,比如把你手头一个最普通的接口用手写脚本改成 pytest 用例,跑通之后再逐步加 fixture、参数化、配置文件。不要一上来就堆插件,被复杂配置淹没时会怀疑人生。每当我看到新同事把 pytest 用成更花哨的 unittest 时,我都会提醒一句:“你不需要继承任何类,不要手动调用 fixture,把数据准备交给框架。” 放下 unittest 的习惯,才能真正体会到 pytest 的自由度。这也是我这几年从踩坑到稳定落地,最想分享的一个转变。
1. 从 unittest 转向 pytest:一次测试框架选型的真实复盘先说个背景。前阵子我接手了团队里一套老项目的回归测试,代码量不大,但里面大几十个用例全是基于 unittest 写的。写起来难受,跑起来也难受——每个测试类都要手动继承 TestCase,断言失败的时候控制台输出只有一行AssertionError,想看具体哪个值不对还得自己打日志。后来我们逐步把用例迁到 pytest,同样的功能,代码量直接缩了一半,定位问题的速度更是完全不在一个量级。很多人可能会问: pytest 到底好在哪里?我自己的体会可以归纳成三个点,这也是当初我们决定迁框架的原因。第一,pytest 的用例就是普通的函数。不需要类继承,不需要特意命名成test_xxx之外的方法,一个文件里直接写函数就行,pytest会按照约定收集它们。这对入门者极度友好,也方便后期维护。第二,断言是原生的assert。写完表达式,pytest 会通过“断言内省”机制告诉你左边是什么、右边是什么、差在哪里。这在调试接口返回、数据库查询结果这类场景时,能省掉大量重复的print排错。第三,fixture 体系非常灵活。同一个临时数据,在 unittest 体系里要写 setUp、tearDown,还要考虑继承、调用顺序;在 pytest 里就是一个带@pytest.fixture装饰器的函数,用的时候声明参数即可注入。数据准备、环境清理、数据库回滚、登录态管理,都能很干净地拆开。顺着这个思路,下面我把 pytest 的语法和核心概念拆开讲。内容上会刻意控制“由浅入深”:先讲断言和收集规则,再重点讲 fixture 和参数化,最后补充工程化配置与踩坑经验。这样你既能快速上手,也能直接往真实的自动化框架里套。2. 断言与测试发现规则:把 pytest 的底层机制先吃透2.1 断言的“内省”机制:为什么失败信息能这么完整pytest 没有重新发明断言语法,它就是直接复用了 Python 原生的assert语句,但它背后做了一件很聪明的事情:在测试运行时,pytest 会重写测试模块的字节码(AST 层面的改写),把assert语句替换成带详细上下文信息的调用。举个例子,你写了一句:def test_user_age(): user_age = 18 expected_age = 20 assert user_age == expected_age在 unittest 里,跑挂了你只会看到一行AssertionError。但在 pytest 里,失败输出会直接提示:E AssertionError: assert 18 == 20更复杂的表达式,比如assert {"name": "张三"} == {"name": "李四"},它还会帮你展示两个 dict 的差异点。这就是所谓“断言内省”。对我们搞自动化的人来说,这个能力直接决定了排查效率。CI 上跑挂了、日志一堆,你能最快看到的是 pytest 帮你算出来的“实际值 vs 期望值”,而不是自己在代码里到处埋点。有一点需要注意,pytest 只能改写它在收集阶段加载的测试文件。也就是说,如果你在非测试模块里写了一个公共函数,里面也带assert,那个断言失败时不一定能得到这么详细的内省信息。所以我的建议是:把断言尽量写进测试函数内部,公共函数里可以不写断言。2.2 测试文件与用例收集规则:命名是你最好的文档pytest 默认按以下规则收集测试:文件名匹配test_*.py或*_test.py;文件里的函数名以test_开头,类名以Test开头(注意是驼峰大写 T),类内方法名也是test_开头;目录下如果没有__init__.py,pytest 会用“根目录下的唯一包名”模式进行导入,避免包名冲突。这个规则看起来简单,但在大型项目里很容易被误解。我见过不少人在test_helpers.py里放一个函数helper_do_something(),以为 pytest 会把它当用例跑。其实不会,因为函数名不以test_开头。反过来,有人把工具函数命名为test_get_time(),结果被 pytest 收集成一个用例,里面什么断言都没有,白白多了一个永远通过的测试,还会污染测试报告。所以,工具函数统一放到helpers.py、utils.py这种不会被收集的文件里,测试文件只放测试用例,命名上严格执行test_前缀。这看起来是个小事,但团队接手的第二天就会感谢你。2.3 常用断言写法与浮动精度处理pytest 里最基础也最实用的几类断言,我整理成一张表做对照:场景推荐写法说明数值相等assert result == 100内省机制会展示实际值浮点近似assert result == pytest.approx(0.3333, abs=1e-3)别直接用==比较浮点,坑死你是否为空assert not items比assert len(items) == 0更简洁容器包含assert "error_code" in response_body常用于判断接口返回字段异常断言with pytest.raises(ValueError):代替 unittest 的assertRaises比较字典子集assert expected.items() <= actual.items()适合只校验部分字段浮点比较这个点,我不止一次在队友的代码里看到assert a == b然后时灵时不灵的情况。不要拿浮点直接相等,要么用pytest.approx,要么你自己手动算一个误差范围。接口返回里的金额、比例这类字段,用pytest.approx是最稳的选择。异常断言还有一个很实用的细节,pytest.raises返回的是异常信息对象,你可以接着校验异常消息、异常类型,比如:with pytest.raises(ValueError) as exc_info: parse_config("invalid_config") assert "unknown key" in str(exc_info.value)这样就把“抛没抛异常”和“异常信息对不对”拆成两次检查,排查问题时信息更明确。实际项目里,接口参数校验、配置解析这类场景非常有用。3. fixture 体系:pytest 区别于 unittest 的灵魂设计3.1 fixture 的定义与依赖注入fixture 可以说是 pytest 最核心、也最需要花时间理解的概念。简单说,fixture 就是一个被@pytest.fixture装饰的函数,它负责准备一个“可被测试用例依赖的东西”。这个东西可以是一段配置、一个客户端连接、一条测试数据,甚至是一个 mock 对象。用法上,测试函数想用哪个 fixture,就在参数列表里写哪个名字,pytest 会自动去找并注入进来。比如:import pytest @pytest.fixture def api_client(): # 这里可以初始化一个带 token 的 HTTP 客户端 client = build_http_client() return client def test_get_user(api_client): resp = api_client.get("/user/1") assert resp.status_code == 200这段代码里,api_client会被自动创建、注入到测试函数里。你不用在测试函数里手动调用,也不需要 import,更不需要在 setup 里声明。这就是“依赖注入”的直观体验。我自己的体会是,刚开始用 fixture 时会忍不住在测试函数内部手动创建对象,然后发现代码越来越绕。切换到 fixture 后,“准备数据”和“执行业务断言”这两个职责就彻底分开了,测试函数只关注它关心的业务逻辑,这是一个很大的心智改善。3.2 scope 参数:控制 fixture 的创建与复用粒度fixture 默认每个测试函数执行前都会重新创建,这相当于 unittest 的setUp。但很多场景下,我们希望一个昂贵的操作只做一次,比如浏览器实例、数据库连接、登录 token。这时候就要用到scope:scope 值生命周期适合场景function每个测试函数执行前后各执行一次默认值,适合数据隔离需求高的用例class每个测试类执行一次类级别共享资源module每个测试模块执行一次模块级别的公共数据package每个包目录执行一次较少用session整个 pytest 会话周期执行一次全局唯一对象,如浏览器、数据库连接我推荐的原则是:能用低粒度(function)就不用高粒度(session),除非创建开销大到无法接受。因为粒度越高,用例之间的耦合就越强。比如你把一个“修改型操作”写进了 session 级 fixture,后面的测试用到同样的数据就可能拿不到预期值,排查起来会很头疼。有一次我在一个接口测试项目里,把一个数据库连接设为 session 级,本来没问题,但后面对接了一个新需求:不同模块要操作不同的测试库。结果 session 级连接没法切库,所有用例全部串了。最后我把连接拆成 module 级,按目录区分测试库,问题才解决。scope 不是越大越好,而是要根据数据隔离需求来定。3.3 conftest.py:跨文件共享 fixture 的秘密武器当多个测试文件都需要同一个 fixture 时,把 fixture 写在某个测试文件里显然不合适。pytest 的标准做法是放到conftest.py里。conftest.py是一个特殊的配置文件,pytest 会自动发现并执行它,而且它定义的 fixture 可以被同目录及以下所有目录的测试用例使用。这个目录层级非常关键,因为它天然形成了一种“作用域链”:项目根目录/ ├── conftest.py # 全项目可用 ├── tests/ │ ├── test_a.py │ ├── api/ │ │ ├── conftest.py # 仅 api 目录下可用 │ │ ├── test_login.py │ │ └── test_order.py我一般会把“全局共通”的 fixture(比如日志配置、公共的 API 根地址)放在根目录conftest.py,把“某个模块专属”的 fixture(比如特定业务域的测试数据结构)放在对应目录的conftest.py里,这样查找依赖时逻辑非常清晰。不过这里有一个常见的坑:conftest.py里的 fixture 是“自动发现”的,如果你给某个 fixture 取名client,而另一个 conftest 文件里又有一个client,后者的定义会覆盖前者,很容易造成莫名行为。团队协作时,fixture 的命名最好带模块前缀,比如order_api_client、auth_session,避免重名。3.4 yield fixture:把“用完清理”变成一等公民unittest 里的tearDown最大的问题是,你不得不在setUp里准备好所有东西,然后等测试结束再清理。而 pytest 的 fixture 支持用yield把“准备阶段”和“清理阶段”放进同一个函数:import pytest @pytest.fixture def database(): conn = create_connection() yield conn conn.close() def test_query(database): result = database.query("SELECT 1") assert result == 1yield前面的部分是测试执行前的准备,yield后面的部分是测试结束后的清理。不管测试成功还是失败,清理代码都会执行。这就是资源释放的兜底。yield fixture 后面还可以接finally,用来兜底异常情况:@pytest.fixture def database(): conn = create_connection() try: yield conn finally: conn.close()这个写法等价于tearDown的兜底逻辑,但看起来直白得多。我们在做数据库测试时,经常把“清空相关表”“回滚事务”这些操作直接写在 yield 后面,测试函数里只用专注于业务断言,非常舒服。4. 参数化测试:一份用例,多组数据,覆盖度翻倍4.1parametrize的三种基本用法接口测试里最常见的需求是:同一个接口,用多组入参去验证不同的分支。如果不参数化,你只能复制粘贴多个测试函数,或者用循环包在一个测试函数里。前者代码冗余,后者一个用例挂了就整体失败,都拿不到详细的分组报告。pytest 提供了@pytest.mark.parametrize,直接解决这个问题:import pytest @pytest.mark.parametrize("input_data, expected_code", [ ("valid_user", 200), ("invalid_user", 401), ("missing_field", 400), ]) def test_login(input_data, expected_code): resp = login(input_data) assert resp.status_code == expected_code执行时 pytest 会把三组数据拆成三个独立用例,报告会分别展示test_login[valid_user-200]、test_login[invalid_user-401]这类测试 ID。挂了哪组,一眼就能定位是哪组输入的问题,完全不影响其他组执行。这在大量数据回归时非常好用。也可以在类上使用parametrize,这样类里的每个测试方法都会拿到参数:@pytest.mark.parametrize("n", [0, 1, 10]) class TestMathOperations: def test_square(self, n): assert n * n >= 04.2 堆叠使用:多参数组合的笛卡尔积如果多个变量之间需要做组合,可以直接叠加多个parametrize装饰器。pytest 会帮你生成所有参数的组合,也就是笛卡尔积:@pytest.mark.parametrize("username", ["alice", "bob"]) @pytest.mark.parametrize("status", ["active", "blocked"]) def test_user_status(username, status): assert get_user_status(username) == status这个场景做“不同用户类型 × 不同账号状态”的登录/权限测试非常方便。不过要注意,组合数量是乘数关系,参数一多用例数量会爆涨。一般我会控制组合维度不超过三个,超过三个就用数据文件或从数据库查,避免用例爆炸。4.3 参数 id:给用例起个能看懂的名字默认的测试 ID 是参数值直接拼上去,如果参数是长字符串或者 dict,报告会特别难看。可以用ids参数给每一条数据单独命名:@pytest.mark.parametrize( "input_data, expected_code", [ ("valid_user", 200), ("invalid_user", 401), ("missing_field", 400), ], ids=["正常用户登录", "密码错误", "缺少必填字段"], ) def test_login(input_data, expected_code): ...实际跑的时候,报告显示的是test_login[正常用户登录]这种可读性很强的名字。团队评审、失败报告沟通时,这个细节特别加好感。4.4 从外部文件读取参数:数据与逻辑分离的进阶思路测试数据保持在代码里固然直观,但一旦量级到了几百组,我建议把数据挪到文件里管理。我用得最多的是 YAML 或者 JSON,配合一个 fixture 读取:import json import pytest @pytest.fixture(scope="session") def login_cases(): with open("data/login_cases.json", encoding="utf-8") as f: return json.load(f) @pytest.mark.parametrize("case", login_cases.__dict__) def test_login_auto(case): resp = login(case["username"], case["password"]) assert resp.status_code == case["expected_code"]当然这里的login_cases.__dict__只是示意,fixture 一旦通过参数传入,你可以直接拿到整个列表,再用pytest.mark.parametrize传入数据。需要注意,parametrize装饰器无法直接感知 fixture 里的数据,所以通常的做法是:提供数据源的 fixture 在测试函数内部使用,而parametrize的数据来自静态内容。如果你一定要“动态读取参数化”,可以用 pytest 的钩子函数,那个属于进阶玩法,本文先不展开。多数情况下,静态文件 + fixture 读取已经足够。5. 工程化相关配置与常用插件:让 pytest 真正能支撑自动化体系5.1 pytest.ini:一处配置,全家受益pytest 的很多运行参数可以从命令行带,但更推荐集中写在pytest.ini里。文件放在项目根目录,pytest 会自动读取。我的一份最小可用配置长这样:[pytest] minversion = 7.0 testpaths = tests addopts = -v -s --tb=short markers = smoke: 冒烟用例 slow: 慢用例 api: 接口用例这里的addopts参数会在你执行pytest时自动带上命令行选项,比如-v详细输出、-s不捕获 stdout、--tb=short缩短堆栈信息。团队成员执行时不用记一长串命令,统一体验,也避免漏参数。markers部分不是摆设。pytest 执行时如果你用了未注册的 marker 名称,会看到一个 warning。注册过后,marker 的用途就一目了然,还可以配合-m参数筛选执行。5.2 常用插件组合:我推荐的五件套pytest 之所以生态强大,很大程度靠插件。以下是我在没有特殊业务约束时的固定搭配:插件作用备注pytest-cov覆盖率统计CI 门禁常用pytest-xdist多进程并行执行用例-n auto自动分配 CPUpytest-ordering控制用例执行顺序通过@pytest.mark.run(order=1)标记pytest-rerunfailures失败用例自动重试对偶发不稳定用例友好pytest-timeout用例超时控制防止用例挂死拖垮总时长并行执行pytest-xdist有一个坑:多个进程共享同一个外部资源时可能会互相干扰,比如都写同一个临时文件、都占用同一个端口。所以用分布式执行前,必须先确认用例之间没有共享可变状态,或者共享资源被正确隔离。通常我接到一个老项目时,第一次跑并行不用-n auto,先-n 2小规模验证,稳定后再逐步加,这样避免一上来就乱成一锅粥。5.3 marker 标记:怎么筛出冒烟用例、跳过条件marker 就是给测试“贴标签”的机制,最典型的场景是冒烟测试。你不想每次全量跑上千个用例,只想跑关键链路,可以这么写:import pytest @pytest.mark.smoke def test_login_success(): ...执行时:pytest -m smoke就会只收集带smoke标记的用例。同理,-m "not slow"排除慢用例。跳过用例也是一个高频需求。条件不成熟、环境缺失、某个 bug 未修,都可以显式跳过:import pytest @pytest.mark.skip(reason="接口尚未上线") def test_new_feature(): ... @pytest.mark.skipif(not check_es_available(), reason="ES 未部署") def test_elasticsearch_query(): ...skipif的用法非常灵活,甚至可以读取环境变量来控制。很多项目里“被测服务没起来就不跑集成测试”这种需求,用它处理起来非常顺手。5.4 钩子函数:轻量级进入 pytest 内部的机会pytest 的钩子函数是它作为框架最有弹性的部分。如果你希望“收集完用例之后做点排序”、“跑完之后生成自定义报告”,都可以通过钩子实现。最常见的两个:# conftest.py def pytest_collection_modifyitems(config, items): # 按节点 id 倒序执行 items.reverse() def pytest_terminal_summary(terminalreporter, exitstatus, config): # 终端输出结束后追加统计信息 passed = len(terminalreporter.stats.get("passed", [])) failed = len(terminalreporter.stats.get("failed", [])) print(f"自定义统计:passed={passed}, failed={failed}")钩子函数的数量非常多、功能覆盖也广,但我的建议是:前期不要过度依赖钩子,先掌握 fixture、参数化、marker,够应付绝大多数工程问题。钩子更多是当标准能力不够时再动手定制,否则很容易把自己带进维护成本的坑里。6. 落地三个项目之后的经验总结:容易踩的坑与我的排查思路6.1 用例隔离问题:共享状态为什么会让测试随机失败我在自己的一个自动化测试项目里吃过一次大亏。当时写了一个操作订单状态的用例,简单粗暴地把订单创建放在了测试函数内部,断言时又直接查询数据库。表面上看逻辑没问题,但跑久了就发现一个奇怪的现象:如果前面的用例把某个订单状态改成了“已支付”,后面的用例查询到的状态就不符合预期,两条用例偶尔会先后失败。这个问题的本质是用例之间的数据隔离没做好。解决方案有两个方向:一是每个用例都创建自己的数据,不依赖其他用例修改后的状态;二是用 fixture 的 function 级作用域 + 清理逻辑(比如 yield 后面清表/回滚)保证用例执行前后的数据一致。我更喜欢方案二,因为它把“数据准备+数据清理”收敛在 fixture 里,测试函数里根本不需要关心上下文。6.2 conftest 层级覆盖:为什么改了 fixture 却没有生效有段时间我遇到一个诡异的问题:我在某个子目录的 conftest.py 里定义了customer这个 fixture,但子目录下的用例跑的却是根目录那个customer。查了半天才发现,同事在根目录 conftest 里也定义了一个同名的 fixture,pytest 的查找规则是“就近优先”,但由于我第一次没有注意作用域链,导致预期和实际完全相反。这种问题最好的排查方式永远是直接打印 fixture 源码。在用例里临时加一条:import inspect, pytest @pytest.fixture def debug_fixture(request): pass或者更简单,在 conftest 里加一行print(inspect.getsource(customer))。看到当前生效的定义来自哪个文件,问题就清楚了。6.3 失败用例重跑逻辑:不是所有失败都值得重试pytest-rerunfailures很香,但不能滥用。我的原则是:只对已知偶发失败用例加@pytest.mark.flaky(reruns=2)标记,不要全量开启重试。因为如果所有用例都重试,你会失去一个重要的信号——哪些用例真的不稳定。全量重试跑完,绿是绿了,但你把“不稳定”系统性掩盖掉了,后面排查线上问题时只会更痛苦。另外,如果用例本身有副作用(比如写入生产库、发送真实短信),重试可能导致重复执行副作用逻辑,这种用例一定不能开重试。6.4 调试利器:从失败堆栈里快速定位是哪一条数据出的问题日常调试我用最多的三个参数:pytest tests/test_login.py -v --tb=long pytest tests/test_login.py --lf # 只跑上次失败的用例 pytest tests/test_login.py --pdb # 失败后进入交互式调试--lf(last failed)是最实用的一个:回归时如果有几条用例挂了,修完代码直接--lf复跑,不用全量跑一遍。--pdb适合在本地环境深入研究问题,CI 环境别开,因为没人去交互。结合参数化时,-k参数还可以做字符串筛选,比如pytest -k "正常用户登录"只跑名字匹配的用例。这在参数化用例很多的时候,效率提升非常明显。6.5 动态参数化与 fixture 混用时的数据加载次序我自己经常遇到的一个需求是:测试数据从 Excel 读取,但表头格式会变。这时候如果硬把动态数据塞进parametrize,测试还没开始就报错了。我的做法是分两步:第一步,先写一个独立的脚本验证数据文件解析没问题;第二步,再把解析好的数据以模块级常量的形式传给parametrize。这样等于把一个“动态读取”问题拆成了“静态数据 + 测试逻辑”两个环节,出问题的时候定位路径清晰得多。如果你确实需要非常动态的参数化,pytest 支持pytest_generate_tests钩子,可以在收集阶段动态调用参数。这个机制我一直建议团队内部“能用简单方案就别上钩子”,因为它的可读性比较差,新手维护起来容易懵。只有当你有几十种数据源、需要统一处理时才值得动手。最后说一点我自己的体会。pytest 真正厉害的地方不只是语法简洁,而是它把“测试代码的组织方式”和“工程化能力”做成了同一套体系。你用 fixture 管理依赖、用 parametrize 管理数据、用 marker 管理筛选、用钩子管理扩展,这些概念彼此独立,但又能在同一个项目里自然组合。上手阶段建议从小项目练起,比如把你手头一个最普通的接口用手写脚本改成 pytest 用例,跑通之后再逐步加 fixture、参数化、配置文件。不要一上来就堆插件,被复杂配置淹没时会怀疑人生。每当我看到新同事把 pytest 用成更花哨的 unittest 时,我都会提醒一句:“你不需要继承任何类,不要手动调用 fixture,把数据准备交给框架。” 放下 unittest 的习惯,才能真正体会到 pytest 的自由度。这也是我这几年从踩坑到稳定落地,最想分享的一个转变。