资讯动态

pytest入门:从unittest到fixture与参数化实战

发布时间:2026/10/29 12:42:34 来源:尧图企业网站定制
1. 为什么建议小白一开始用 pytest 而不是先从 unittest 啃起我见过太多人学 Python 测试的第一个岔路口就选错了方向。教材和几年前的老教程几乎清一色从 unittest 讲起理由是标准库自带、不用装东西。这个理由听上去很省钱但代价是要先背下一整套样板规则定义类、继承基类、方法名必须test开头、断言必须换成assertEqual、assertTrue、assertIn这一挂最后还要在文件末尾写一行启动入口。对一个刚学完列表和字典的人来说这些仪式感全是噪音它挡住的是测试到底在验证什么这件真正要紧的事。pytest 走的是完全相反的路子。它把写测试这件事压缩到极致——一个普通函数、一个普通assert、文件名按规矩起就能跑。没有继承没有强制类没有需要额外记忆的断言方法名。你写assert a b它照样能告诉你 a 和 b 具体差在哪。这个差别看起来只是省了几行代码实际上决定了你愿不愿意给代码补测试。这篇内容面向的是能写几句 Python但完全没碰过测试框架的人也适合那些写完脚本只能靠print和人工点界面来确认对不对的人。我会从环境准备一路讲到 fixture、参数化、踩坑排查重点放在为什么这样设计而不只是给你一堆命令抄。因为抄来的命令换个目录就跑不通知道原理才救得了你。1.1 unittest 的门槛卡在哪些具体位置先把这个对比摊开看才明白为什么要跳过。假设要验证一个加法函数返回正确unittest 的写法是这样的import unittest class TestCalc(unittest.TestCase): def test_add(self): self.assertEqual(add(1, 2), 3) self.assertTrue(add(-1, 1) 0) if __name__ __main__: unittest.main()同一个场景pytest 只需要def test_add(): assert add(1, 2) 3 assert add(-1, 1) 0代码量差不多减半但真正的问题不在这里而在于心智负担的分配。用 unittest 的时候你的注意力有一部分必须持续用来想这个断言该用哪个 assertXxx 方法assertDictEqual还是assertEqual就够了这部分思考对验证业务逻辑毫无帮助。尤其是刚入门的人写第一个测试时就卡在为什么我的测试没被认出来——因为他忘了继承TestCase或者类名没写Test开头。还有一个很现实的问题unittest 的失败信息不够友好。assertEqual对两个嵌套字典做比较时输出经常是一大坨没有结构的表示你得肉眼去对。pytest 的失败输出会把差异定位到具体的键或下标还会用颜色和箭头标出期望值和实际值。你调试的时间是省在自己身上的这一点用一周就能有明显体感。1.2 pytest 的省事背后靠的是断言重写很多人不知道assert在 pytest 里为什么能输出那么详细的信息。普通 Python 里assert一旦失败只会抛AssertionError连表达式原文都不一定保留加了-O优化参数会被直接优化掉。如果 pytest 也只是简单地跑一下这个函数它拿到的信息和裸 Python 一样少。pytest 的做法是在导入测试模块之前先对模块的源码做一次改写把每个assert语句换成一段能记录中间变量的代码。所以当你写assert resp[code] 0失败时它能告诉你resp[code]的实际值是多少而不是只丢一句断言失败。这套机制叫断言重写assertion rewriting默认在test_开头的文件里自动生效。理解这一点的实际价值在于如果你把测试函数写在别的模块里再 import 进来改写可能不生效那时候你看到的就是干巴巴的AssertionError没有变量展开。这也是为什么测试文件要按名字放、按名字命名——名字本身就是触发改写和自动收集的开关。1.3 已经有 unittest 用例的项目该怎么办这里要澄清一个常见误解用了 pytest 并不代表要推翻旧代码重写。pytest 能直接收集并运行 unittest 风格的TestCase类两者可以在同一个项目里共存。所以更稳妥的迁移策略是存量用例不动新写的用例一律用 pytest 风格随着业务迭代慢慢替换。这个判断我在实际项目里验证过很多次强行一次性重写测试的收益远小于风险测试代码也是代码它同样需要稳定。2. 装好环境并交出第一个能跑通的测试2.1 虚拟环境这一步别省小白最容易踩的第一个非技术坑是把 pytest 装进了系统 Python然后在一个用别的解释器的项目里死活找不到。判断当前用的是哪个解释器一条命令就够了python -c import sys; print(sys.executable)正确做法是先建隔离环境再装# 在项目根目录执行 python -m venv .venv # 激活Windows .venv\Scripts\activate # 激活macOS / Linux source .venv/bin/activate pip install pytest pytest --version看到版本号输出就说明装好了。为什么强调虚拟环境因为 pytest 会读取项目里的配置文件、插件和 conftest一旦全局装了多个版本排查起来非常痛苦。我遇到过的最难查的一次问题最后定位到原因是系统里装着一个旧版本的 pytest 插件跟项目配置打架。2.2 命名规则决定了测试能不能被发现pytest 不靠你在文件里写注册代码它靠一套约定去扫目录。这套规则必须记牢对象规则举例测试文件test_开头或_test结尾test_calc.py/calc_test.py测试函数test_开头def test_add():测试类Test开头且不能有__init__class TestCalc:测试方法类内test_开头def test_add(self):最后一条里的不能有__init__是很多人栽的地方。pytest 创建测试类实例的方式跟普通类不同你写了__init__它就会警告并跳过这个类表现为我明明写了测试怎么收集到 0 个。一个最小可运行的目录长这样project/ ├── .venv/ ├── calc.py └── tests/ └── test_calc.pycalc.py里放被测试的函数test_calc.py里写from calc import add def test_add_positive(): assert add(1, 2) 3 def test_add_negative(): assert add(-1, -2) -32.3 用pytest还是python -m pytest这个问题看似无聊实际是小白第二大坑。两者在多数情况下表现一样但在模块导入路径上有本质区别pytest以可执行脚本的方式启动Python 的sys.path[0]是 pytest 所在目录。python -m pytest以模块方式启动sys.path[0]是你当前所在的工作目录。差别体现在哪当你的测试文件里写from calc import add而calc.py在项目根目录、测试在tests/子目录时用python -m pytest从根目录启动就能找到calc用裸pytest有时就报ModuleNotFoundError。提示在项目根目录统一使用python -m pytest是个几乎无副作用的习惯能规避八成路径问题。运行方式还有几种常见的精确打击法python -m pytest # 跑全部 python -m pytest tests/test_calc.py # 只跑一个文件 python -m pytest tests/test_calc.py::test_add_positive # 只跑一个函数 python -m pytest -k add and not negative # 按名字表达式筛选-k这个筛选语法是我平时用得最多的一个尤其在一个文件里有几十个用例、只想复跑刚改坏的那两条时。表达式支持and、or、not配合用例名里的关键词比一遍遍改代码注释掉其他用例优雅得多。3. 断言怎么写、失败信息怎么读3.1 裸 assert 就够用但要写对表达式pytest 里断言只有一句话你写什么样的布尔表达式它就检查什么。所以关键是表达式本身要写得信息密度高。对比下面两种写法# 信息量低 assert len(items) 3 # 信息量高 assert items [a, b, c]第一种失败时只告诉你长度不是 3你得自己去翻 items 到底有什么。第二种失败时 pytest 会把两个列表逐项对齐展示一眼看出是多了还是少了、差在哪个位置。断言的目的是让失败时少花时间定位所以宁可多写一点具体值。需要自己补充上下文时直接在 assert 后面加逗号assert resp.status_code 200, f接口返回异常body{resp.text}这个自定义信息只在失败时打印不影响性能属于低成本高回报的写法。3.2 浮点数比较必须用 approx这是新手最容易写出偶发失败的地方assert 0.1 0.2 0.3 # 这条会失败原因不是 pytest 的问题是二进制浮点表示的固有误差。正确写法import pytest assert 0.1 0.2 pytest.approx(0.3) assert 0.1 0.2 pytest.approx(0.3, abs1e-9) # 指定绝对误差approx默认使用相对误差对于数量级差别大的数值序列也适用。凡是涉及金额、比例、坐标、科学计算的断言一律套approx这是我个人的硬性习惯能省掉大量昨天还能过今天怎么挂了的抓狂。3.3 断言异常用pytest.raises别用 try验证这里应该抛错是常见需求。不要用try/except里加标志位那种写法直接用上下文管理器import pytest def test_divide_by_zero(): with pytest.raises(ZeroDivisionError): 1 / 0 def test_error_message(): with pytest.raises(ValueError, match格式不正确): parse(abc)match参数支持正则用来校验异常信息里包含的片段比只校验类型更严格。这里有个坑要提醒pytest.raises块里只应该放真正会抛异常的那一句。如果你把一堆前置准备代码也塞进去那些代码抛了同类异常测试照样通过实际上是假阳性。3.4 断言里别放有副作用的调用这条是我用血换来的经验。类似这种写法assert get_user_count() 5 assert get_user_count() 5如果get_user_count()内部有计数器、有缓存写入、有网络请求那第二次调用的返回值可能已经变了测试就会不稳定。更隐蔽的是断言被跳过的情况——以前版本的解释器在-O模式下会整个跳过 assert 语句里面的函数根本不会执行。所以凡是有副作用的操作先把结果赋值给变量再对变量断言count get_user_count() assert count 5这条规则在维护别人的测试代码时特别有用看到assert里套着函数调用先打个问号。4. fixturepytest 里最值得先吃透的一个机制4.1 它解决的是准备工作重复这件事先说不用 fixture 会怎样。假设十个测试都要读取同一个配置文件def test_a(): cfg load_config(config.yaml) assert cfg[timeout] 0 def test_b(): cfg load_config(config.yaml) assert host in cfg重复的load_config出现了十次。你想改成一个临时目录里的配置就要改十个地方。fixture 的作用就是把这段准备逻辑抽出来由框架负责在测试运行前注入import pytest pytest.fixture def cfg(): return load_config(config.yaml) def test_a(cfg): assert cfg[timeout] 0 def test_b(cfg): assert host in cfg理解的关键点在参数注入函数参数名cfg跟 fixture 名字对上pytest 就会在调用测试函数之前先执行那个 fixture把返回值塞进去。这不是装饰器的魔法就是一个按名字匹配的依赖查找过程。4.2 用 yield 做清理顺序别搞反需要收尾的场景开文件、起临时服务、建数据库连接用yield替代returnpytest.fixture def temp_file(tmp_path): p tmp_path / data.txt p.write_text(hello, encodingutf-8) yield p # yield 之后的代码在测试结束后执行 print(清理完成)执行顺序是yield之前的代码 → 测试函数 →yield之后的代码。这里最容易犯的错是把清理逻辑写在yield前面那等于测试还没跑就把资源关了。还有一种更隐蔽的情况测试函数内部抛了异常清理代码还执行吗会执行这是yieldfixture 相对手工写try/finally的最大好处。注意tmp_path是 pytest 内置的 fixture给你一个每次运行都独立、跑完自动清理的临时目录。凡是要写文件的测试都用它别往项目目录里写否则跑几次之后你会收获一堆垃圾文件。4.3 作用域选错测试会互相污染fixture 默认作用域是函数级每个测试跑一次。可以改成更粗的粒度scope执行次数典型用途风险function默认每个测试一次临时文件、可变对象无最安全class每个测试类一次类内共享的只读数据类内测试可能互相影响module每个模块一次加载一次较重的配置同上session整个运行一次启动服务、建连接池状态残留影响全局选作用域的判断标准很简单这个对象被修改后会不会影响下一个测试的判断。只读的配置、只查不改的数据源可以用粗粒度任何可变状态老老实实留在function。我在项目里见过把数据库连接池设成session又忘了回滚事务结果后面所有用例都在脏数据上跑排查了半天才发现是作用域的问题。4.4 conftest.py 与 fixture 的复用边界conftest.py是一个约定文件名放在目录里该目录及子目录下的测试都能自动用上里面定义的 fixture不需要 import。这一点很多人不知道还在各个测试文件里重复写 fixture 或者手动 import。目录结构上可以分层project/ ├── conftest.py # 全局 fixture配置、日志 ├── tests/ │ ├── conftest.py # 子目录 fixture只给 tests 用 │ └── test_api.py内层同名 fixture 会覆盖外层这个覆盖机制很好用默认配置在根conftest.py某个子目录需要特殊配置就在那层的conftest.py里重定义用例代码完全不用改。fixture 之间也能互相依赖直接把另一个 fixture 名写成参数即可pytest.fixture def db(cfg): conn connect(cfg[dsn]) yield conn conn.close()这种链式依赖让配置 → 连接 → 事务 → 测试数据这条链路变得非常清晰而且每层都能单独替换。这是 fixture 相对于简单的setUp/tearDown最大的结构优势。5. parametrize一份用例覆盖一组输入5.1 从复制粘贴到参数化没学过参数化的人写法是这样的def test_parse_1(): assert parse(1) 1 def test_parse_2(): assert parse(0) 0 def test_parse_3(): assert parse(-1) -1三个函数体一模一样只有输入输出不同。加一个新用例就要复制一遍改一次逻辑要改三处。参数化把这件事压成一份import pytest pytest.mark.parametrize(raw, expected, [ (1, 1), (0, 0), (-1, -1), ]) def test_parse(raw, expected): assert parse(raw) expected第一个参数是用逗号分隔的变量名第二个参数是元组列表每行喂给测试一次。跑出来的报告里会显示三个独立的结果失败时只有对应的那一行红。这就是参数化的价值逻辑写一次数据列举多份。5.2 ids 让报告可读默认的用例 id 是把参数值拼起来的字符串遇到复杂对象会变成test_parse[raw0-expected0]这种看不懂的编号。加ids可以直接改pytest.mark.parametrize(raw, expected, [ (1, 1), (0, 0), (-1, -1), ], ids[正整数, 零, 负整数]) def test_parse(raw, expected): assert parse(raw) expected筛选的时候就能用-k 正整数精确定位。数据量大或者值本身不好读比如时间、对象时ids值得花这个时间。5.3 局部标记pytest.param与 xfail有些数据组合是已知会失败的比如还没实现的边界但你不想让它污染整批结果。用pytest.param单独打包import pytest pytest.mark.parametrize(raw, expected, [ (1, 1), pytest.param(, 0, markspytest.mark.xfail(reason空串行为待定)), ]) def test_parse(raw, expected): assert parse(raw) expectedxfail的意思是预期失败跑挂了标记成xfailed而不是failed跑过了标记成xpass。这个标记在先把用例写出来、功能还没实现的阶段特别实用能让测试套件保持全绿的同时留下待办。另一个常配的是skippytest.param(x, 0, markspytest.mark.skip(reason依赖外部环境))5.4 参数化不是越多越好有个度要把握。我见过把一个函数的参数化拆成三层装饰器笛卡尔积出来几百个组合单个用例名长到终端换行跑一次两分钟。这种情况下测试的可读性和反馈速度都被拖垮了。判断标准参数化的目的是用同一段逻辑验证不同的数据而不是用堆数据量去换覆盖率数字。如果不同数据其实触发了完全不同的代码分支那它们更该拆成独立的测试函数各自起个说得清的名字。参数化适合的是同样的处理流程、不同的输入值这种形态。6. 小白高频踩的坑与完整排查链路6.1 报 ModuleNotFoundError 的排查顺序这是 Top 1 问题。不要一上来就改sys.path按下面这个顺序走确认工作目录在哪个目录敲的命令pwd看一眼必须在项目根或包含被测模块的目录。确认解释器python -c import sys; print(sys.executable)看是不是虚拟环境里那个。换成python -m pytest再跑一次如果这一步就好了说明是启动方式的路径差异问题解决。看有没有__init__.pytests/目录下缺这个文件时某些导入写法会失效。加上试试。看项目根目录有没有conftest.pypytest 会把conftest.py所在目录视为 rootdir 并加入sys.path在根目录放一个空的conftest.py是解决导入问题最轻量的手段。最后才考虑pyproject.toml里配pythonpath[tool.pytest.ini_options] pythonpath [.]这个链路之所以重要是因为直接跳到第 6 步的人往往不知道自己为什么就好了换个项目又挂。按顺序排一遍你会知道根因到底是启动方式、还是目录结构。6.2 收集到 0 个用例的几种原因输出no tests ran或者collected 0 items对照检查文件名不是test_开头也不是_test.py结尾函数名不是test_开头比如写成了check_测试类名不是Test开头或者写了__init__用了类但方法忘了写self参数文件在子目录里但那个目录没有__init__.py且名字不像包pytest 默认会递归但某些情况下会跳过命令行给错了路径比如pytest tests/但目录名实际是test/。有个快速定位手段是--collect-onlypython -m pytest --collect-only -q它只收集不执行把准备跑的用例全列出来。对不上的时候一眼就能看出是哪个文件没被扫到。6.3 用例单独跑能过、一起跑就挂这是测试污染几乎每个写测试的人都会遇到。常见来源污染源表现修法模块级全局变量被修改先跑的用例改了状态每个测试内重新构造或改用 fixture 提供环境变量被设置后未恢复后测读到前测的配置用monkeypatch自动还原数据库未回滚数据越来越多或冲突fixture 里用事务包裹并在yield后回滚文件写到固定路径第二次跑读到旧内容一律用tmp_path缓存字典没清命中旧结果在 fixture 里显式清空定位方法先单独跑挂掉的那个能过然后二分法找出凶手。pytest 的-p no:randomly可以关掉随机顺序如果装了相关插件固定顺序之后更容易复现。还有一个很好用的开关--lf只跑上次失败的用例配合二分排查效率很高。monkeypatch这个内置 fixture 值得单独说它专门用来临时改环境变量、改属性、改字典项测试结束自动还原def test_env(monkeypatch): monkeypatch.setenv(API_HOST, example.internal) assert read_host() example.internal6.4 跑起来没反应、卡住不动先加-x让它遇到第一个失败就停加-s关掉输出捕获看实时打印python -m pytest -x -s -v-s特别关键因为 pytest 默认会捕获print正常跑的时候你看不到输出一旦有东西卡住就完全没线索。看到实时输出之后通常能立刻判断是死循环、还是在等输入、还是在等网络超时。有外部依赖的测试建议装超时插件给单个用例设上限避免 CI 卡死。6.5 输出噪音太多怎么办跑几十个用例之后控制台会被各种 warning 和短摘要淹没。几个降噪开关python -m pytest -q # 简洁模式只显示点和失败详情 python -m pytest --tbshort # 失败堆栈简短模式 python -m pytest -W ignore::DeprecationWarning # 忽略弃用警告 python -m pytest -p no:cacheprovider # 不生成缓存目录如果要长期关掉某类警告写进配置文件比每次敲参数好。这也是下面要说的事。7. 把配置沉淀下来让测试真正融进工作流7.1 配置文件放哪、写什么pytest 支持好几种配置位置优先级从高到低大致是命令行参数 pytest.inipyproject.tomltox.ini/setup.cfg。新项目我建议直接写进pyproject.toml一个文件管所有工具别多开几个装配置的文件。[tool.pytest.ini_options] minversion 7.0 testpaths [tests] addopts -q --strict-markers pythonpath [.] markers [ slow: 标记耗时较长的用例, integration: 需要外部依赖的集成测试, ]addopts是默认附加参数把常用的写进去以后敲python -m pytest就等于带上了一整串。--strict-markers是个好习惯拼错标记名比如把slow写成slwo时会直接报错而不是默默忽略——后者会让你以为标记生效了其实根本没筛到。markers里声明的标记可以用-m筛选python -m pytest -m not slow # 跳过慢用例本地快速反馈 python -m pytest -m integration # 只跑集成测试在测试函数上加pytest.mark.slow即可。这个机制在实际项目里非常有用日常开发只跑快的那批提交前再跑全量。7.2 覆盖率看什么、别被它骗了装个插件就能看覆盖率pip install pytest-cov python -m pytest --covsrc --cov-reportterm-missingterm-missing会把没被执行的行号列出来比只看一个百分比有用得多。但覆盖率这个数字有很强的欺骗性我心里有个明确的判断80% 以上的分支覆盖率是个不错的信号但绝不等于质量好一行代码被执行过不代表它的边界被测过有些代码天然不值得测简单的 getter、纯配置加载为了数字好看去凑覆盖率是自欺欺人。真正有价值的用法是反过来看term-missing里那些红色的关键业务分支为什么没被测到。这比盯着百分比涨跌有意义得多。7.3 在自动化流程里怎么跑本地能跑通只是第一步。放进自动化流程时几个实践值得照搬做法理由固定依赖版本pytest7.x避免插件升级导致的意外行为变化用--maxfail1早失败早停省流水线时间输出 JUnit XML 报告便于结果汇总和趋势观察拆分快慢两批快速反馈和全量验证分开跑固定随机种子若用了随机插件失败可复现命令大致是python -m pytest --covsrc --junitxmlreport.xml --maxfail1 -q这里想强调的一点是可复现性。加了随机顺序的插件能帮你发现用例之间的隐式依赖这是好事但一旦失败你必须能用固定种子复现出来否则排查就是从零开始。用随机化的前提是留好重新以相同顺序跑的开关。8. 我个人在小项目里用 pytest 的几条习惯写测试写久了会形成一些不太写在文档里、但确实省事的习惯。头一条是先写一个能跑通的最小测试再往里加东西。新建项目时我会先写一个assert True的空测试跑一遍确认环境、命名、路径全对再开始写真正的逻辑。这一个动作能把我前面列的半数坑提前挡掉成本是三十秒。第二条是关于 fixture 的粒度。我的经验是fixture 宁可多、宁可小不要写一个什么都干的大 fixture。一个 fixture 只负责一件事建连接、造一条数据、起一个临时服务然后用依赖链组合起来。这样当某个测试不需要其中一环时你可以只注入需要的那两三个不用被强行拖上全部准备工作。大 fixture 的另一个问题是它一旦挂了所有依赖它的用例都跟着报同一堆错真正的失败信号被淹掉。第三条是断言信息的取舍。日常写测试我会写得细一点但涉及大对象比较时会加一句自定义消息说明这里在验什么业务含义assert order[status] paid, f订单状态异常期望已支付实际 {order[status]}半年后回来看这段测试业务含义比代码本身更有价值。最后一条也是我觉得对新手最有用的一条不要在测试里追求技巧。测试代码的唯一读者是未来的自己和同事可读性比简洁重要得多。看到一个为了省两行而写得层层嵌套的测试我宁愿它多写十行、一眼能看懂。测试挂了的时候你要在一分钟内判断出是代码错了还是测试本身写错了——这个判断速度取决于测试有多直白。顺着这个思路print调试在测试里也不丢人。pytest 默认捕获输出加-s就能看到。刚上手阶段用assert加几行打印比去研究高级插件来得实在。等你对收集规则、fixture 生命周期、参数化的展开方式都有手感了再去看那些进阶玩法会发现它们大多只是这几条基础的组合变形而已。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑