资讯详情

从unittest到pytest:自动化测试框架入门与实战

📅 2026/10/11 7:14:57 | 华诺云谱 👁 阅读
从unittest到pytest:自动化测试框架入门与实战
几年前我刚转做自动化测试的时候最常干的事情就是在源码里到处插print()跑完盯着控制台看半天再删掉。后来同事丢给我一坨unittest我捏着鼻子写了一礼拜setUpClass第一个印象就是“测试跟优雅压根不沾边”。直到接手一个遗留项目的测试改造我狠下心把Pytest框架从工具列表里翻出来认认真真读了一遍官方文档才意识到——不是测试本身枯燥是我一直用了错误的工具。Pytest之所以这几年在自动化测试圈里几乎成了标配核心就在于它把“写测试”的门槛压到了极低同时又把“跑测试”的体验拉到了极好。这篇入门文章写给还在手动逐条验证、或者在unittest里挣扎的测试工程师和开发同学我会用实际项目里的例子把Pytest的核心玩法拆开讲透。1. 为什么我从unittest换到了pytest三个让我崩溃的点1.1 手写断言方法查到怀疑人生在unittest里判断两个值是否相等你得写import unittest class TestMath(unittest.TestCase): def test_add(self): self.assertEqual(add(2, 3), 5)看起来还能接受但一旦遇到更丰富的断言场景——判断字符串是否包含子串、判断字典是否包含某个键、判断函数是否抛出异常——就得去查文档。assertEqual、assertTrue、assertIn、assertRaises、assertAlmostEqual……这些API本身没什么问题问题在于它们把“表达断言”这件事完全束缚在了unittest的语法里。你只是想表达“这个函数的返回值应该等于5”这么一句话却要套上一层方法调用。这就像你只想说“我饿了”却非要翻开一本菜谱找到对应页才敢开口。如果断言逻辑稍微复杂一点比如同时校验多个字段、或者对返回的列表做排序断言你就得自己封装辅助方法。测试代码的维护成本会直线上升而且每多一层封装就多一层出错的可能。我曾在老项目里见过一个开发者自己写的assertDictEqual扩展类因为边界条件没写好测试本身反而先挂了。pytest的做法完全不同直接用Python原生的assert关键字失败时自动帮你分析表达式。这才是真正在“表达”断言而不是在“调用”断言。1.2 setUp/tearDown把测试体淹没了unittest的经典结构要求把用例组织成测试类公共准备逻辑放在setUp里清理放在tearDown里。这套设计在面向对象时代看起来很合理但实际项目里有个非常难受的体验准备代码往往比测试逻辑本身还长。假设要测一个购物车接口每个用例都需要先创建用户、再登录拿token、再初始化购物车这些全部塞进setUp然后真正的测试方法反而只写三五行。如果测试类里同时有数据库连接、临时文件、缓存清理这些事setUp和tearDown会变得越来越臃肿。更麻烦的是它们与用例本身的关系是隐式的——你只看test_case里的代码时根本不知道前置条件有哪些必须往上翻到setUp才能理清依赖。这种割裂对测试的可读性是致命的。pytest用fixture机制把这个痛点解决得相当彻底。fixture可以定义成一个函数把“准备数据”这件事从测试体里彻底拆出去测试函数需要什么就直接声明什么依赖关系在函数签名里一眼可见。这点我在后面第3节详细展开这里先记住结论pytest不强制你用类组织测试纯函数式的fixture注入让代码的可读性上了一个台阶。1.3 失败信息几乎等于没有最让我崩溃的是断言失败时的报错。unittest默认输出经常就是这样一行AssertionError: 5 ! 4是谁的返回值等于5是哪一行左边的5到底是从哪里来的全部没有。在慢速的集成测试里你往往需要重新打印很多中间变量才能定位问题。而pytest的assert失败信息会自动把表达式两边的值展示出来还会附带相关的上下文体验差距就像一个是别人丢给你一个报错截图另一个是报错截图旁边还画了张示意图。这种细节恰恰是工作中最省时间的部分——少一次人肉调试就多一段完整的专注时间。2. 五分钟跑通第一个用例安装、命名规则和第一行断言2.1 安装就一条命令目录约定却是个隐藏坑安装Pytest只需要一条命令pip install pytest装完之后在任意目录新建一个文件比如test_demo.py写def test_one(): assert 1 1 2命令行执行pytest如果当前目录下只有这一个测试文件你会看到类似这样的输出collected 1 item test_demo.py . [100%] 1 passed in 0.02s看起来很简单但真正的坑在命名规则上pytest默认只收集文件名匹配test_*.py或*_test.py的文件只收集函数名匹配test_*的用例。如果你把文件命名为calc_tool.py或者把函数命名为check_one()pytest会直接忽略它们而且控制台不会给出明显警告——只会显示collected 0 items。我第一次碰到这个问题时在CI日志里翻了好久才发现是命名不匹配。所以第一条规则请记牢测试文件以test_开头或以_test.py结尾、用例函数以test_开头是最稳妥的。项目里的目录结构我一般建议这样组织project/ ├── src/ │ └── app.py └── tests/ ├── test_order.py ├── test_payment.py └── conftest.py把测试统一放在tests/目录下与被测代码分离这是一个很干净的习惯。2.2 第一条断言从assert开始而不是从类开始很多从unittest过来的同学下意识会延续写类的习惯万事万物搞一个class TestXxx(unittest.TestCase)。其实pytest最友好的地方就是支持纯函数式的测试组织方式——不需要写类不需要继承任何父类一个def test_xxx()就够了def get_user_name(user_id): # 假设这是业务函数 return alice if user_id 1 else bob def test_get_user_name(): assert get_user_name(1) alice这对心智负担的降低是巨大的当测试就是一个普普通通的函数你就能用函数式思维来组织它参数化、fixture注入、跳过标记都变得顺理成章。pytest也不是不支持类当你有多个测试需要共享状态时类依然有它的价值但“默认用函数”的哲学极大降低了新人的上手门槛。2.3 读懂pytest的输出绿色圆点、F和s的含义跑pytest时默认输出里是一串字符。你至少要认识三个.表示测试通过F表示断言失败这是你需要重点关注的s表示测试被跳过skip通常是暂时不想跑或者环境不满足如果加-v参数输出会展开成每个测试的完整名称和结果test_demo.py::test_get_user_name PASSED用-q参数则进入安静模式只显示汇总信息。失败时的输出类似FAILED test_demo.py::test_get_user_name - AssertionError: assert bob alicepytest会自动把断言表达式拆开显示具体差别。这种体验比unittest的AssertionError: 5 ! 4友好得多也正是第1节里说的“省时间”的细节。3. fixture把“准备数据”变成自动注入而不是复制粘贴3.1 传统setup的痛点每个测试都要重新准备假设你测的是一个购物车功能每个测试都需要已登录的会话、已创建的用户、已加入的商品。用unittest写你会在setUp里反复做def setUp(self): self.user create_user(test_user) self.session login(self.user) self.cart Cart(self.session)写完这几行测试方法里才真正开始断言。如果十个测试类都需要这个逻辑就得复制十遍。想改一下用户名的前缀全局搜索替换吧。更糟糕的是如果某个测试需要不同的用户权限你又得在测试方法里临时改self.user整个状态管理会越来越乱。3.2 fixture的自动发现与注入机制pytest用了一个完全不同的思路。你只需要定义一个普通函数然后让测试函数的参数名跟它保持一致pytest就会自动把返回值“注入”进来import pytest pytest.fixture def user(): return create_user(test_user) pytest.fixture def session(user): return login(user) def test_cart_add(session): cart Cart(session) cart.add(apple, 2) assert cart.total 2这里没有setUp没有self只有fixture函数和函数的参数声明。pytest发现test_cart_add需要session参数后自动去找名字叫session的fixture而session又依赖user于是先注入user。这种依赖关系是显式的、按需的、可组合的——我个人认为这是pytest最漂亮的设计跟依赖注入的思想一脉相承。你不需要知道fixture内部是怎么创建的只需要声明“我要用这个”维护成本骤降。3.3 scopemodule/session昂贵的准备只做一次默认情况下fixture在每个测试函数调用前后都会执行一次。对于创建用户、生成token这类轻量操作还好但如果是启动浏览器、连接数据库这种代价昂贵的操作每个函数都来一遍整个测试套件会慢到让人绝望。这时用scope参数pytest.fixture(scopemodule) def db_conn(): conn create_db_connection(test_db) return connscope可以取function默认、class、module、session四种。module表示整个文件只执行一次这个fixturesession则是整个测试会话期间只执行一次。做浏览器自动化测试的同学几乎都会把driver实例放到session级别否则每跑一个用例开一次浏览器测试时长根本不现实。但这个优化也有副作用如果测试之间会修改共享fixture的状态就可能产生“串味”。比如一个测试往数据库里插了数据但没清理下一个测试就受到了影响。我的建议是读多写少、无副作用的对象适合session/module级别有写操作、状态会变化的对象宁可慢一点也用function级别稳比快重要。3.4 用yield释放资源数据库连接别忘关fixture不只是提供数据还承担清理。用yield把fixture分成两个阶段pytest.fixture def db_conn(): conn create_db_connection(test_db) yield conn conn.close() # 测试结束后执行yield之前的代码在测试前执行yield返回的值注入测试函数yield之后的代码在测试结束后执行——哪怕测试中途断言失败清理逻辑也会照常运行。这个机制比unittest的tearDown更直观准备和清理放在同一个函数里挨在一起一看就懂。我之前维护过一套老测试代码准备和清理逻辑分散在两个文件里后来有人改了清理逻辑没同步结果数据库连接泄漏测试跑着跑着就报too many connections排查了大半天。改用yield fixture之后这种问题基本绝迹。3.5 conftest.py公共fixture的默认位置fixture可以定义在测试文件里也可以定义在conftest.py里。conftest.py是pytest特殊的配置文件它能为当前目录及其所有子目录下的测试共享fixture。我们项目里一般这样组织tests/ ├── conftest.py # 全局fixture比如登录、driver ├── test_order.py └── test_payment/ ├── conftest.py # 支付模块专用fixture └── test_refund.py这个层级设计的妙处在于“公共的放上层、模块私有的放下层”成了天然的组织方式不需要额外文档解释fixture该放哪里。新同事接手项目打开conftest.py就知道全局有哪些公共依赖代码结构非常干净。4. 参数化一份测试代码跑出N个测试场景4.1 为什么手动复制测试函数是个坏味道如果要测一个校验身份证号的函数正常写法可能是def test_id_card_valid(): assert is_valid_id_card(110101199001010012)但边界场景有很多15位老身份证、18位带X、生日不存在、校验位错误……如果每个场景都复制一个函数代码会像流水账一样膨胀。一旦被测函数签名改了你要同步改几十个地方。更糟的是如果某个场景失败报错里看到的函数名几乎一模一样定位很困难。这时候参数化就上场了。4.2 pytest.mark.parametrize基础用法用参数化装饰器可以把一组组数据拍平import pytest pytest.mark.parametrize(id_card, expected, [ (110101199001010012, True), (110101199001010020, False), # 校验位错误 (110101900101001, True), # 15位 (11010119900230001X, False), # 非法日期 ]) def test_id_card(id_card, expected): assert is_valid_id_card(id_card) expectedpytest会把每组参数生成一个独立的测试用例实际展示时类似test_id.py::test_id_card[110101199001010012-True] PASSED test_id.py::test_id_card[110101199001010020-False] PASSED如果其中一组失败那组参数会直接出现在用例名里根本不需要自己手动打印参数去猜测是哪个场景。这个能力用在接口测试上尤其爽一个接口几十组入参写一组测试函数就全跑完了。4.3 参数化与fixture联动params参数有时你想要的是同一个fixture在不同参数下跑多遍。可以在fixture上用paramspytest.fixture(params[chrome, firefox]) def browser(request): driver create_driver(request.param) yield driver driver.quit() def test_login_page(browser): assert browser.title 登录这会自动生成两个测试用例分别用chrome和firefox跑。实际项目里配合selenium或playwright做跨浏览器测试这个特性特别实用。request.param就是当前这次注入的参数值——pytest会在每次参数迭代时创建一个新的测试上下文。这里我踩过一个小坑如果fixture未使用request参数pytest会报错因为request是你访问参数的入口必须先声明它。4.4 ids让测试名字可读失败一眼可见参数化默认生成的用例名是把参数拼在一起参数多了会很难看。可以用ids参数改名pytest.mark.parametrize(id_card, expected, [ (110101199001010012, True), (110101199001010020, False), ], ids[valid_18_digit, wrong_check_digit]) def test_id_card(id_card, expected): assert is_valid_id_card(id_card) expected这样输出里就是test_id_card[valid_18_digit]一眼能看出这组测的是什么。给参数起个好名字与给测试函数起个好名字同样重要。在几百上千条测试的环境里可读的用例名就是可读的测试文档。5. pytestAllure把测试结果做成能交付的报告5.1 控制台绿点只能给自己看报告是给人看的前面的操作跑完看到的是控制台里的点和F。这个输出对开发自己有价值但当你需要向测试组长、项目经理甚至客户汇报测试结果时不可能丢一个命令行截图过去。Allure就是把pytest结果转成结构化HTML报告的社区主流方案。它不但展示通过/失败/跳过还能把每个用例的步骤、参数、截图、日志按层级组织起来看起来像一个真正的产品页面。5.2 安装allure命令行工具和allure-pytest插件这里要分两步。第一步安装Python侧插件pip install allure-pytest第二步安装Allure命令行工具。macOS上可以用brew install allureWindows需要下载allure压缩包并配置环境变量PATH。安装好后验证allure --version跑测试时多带一个参数指定报告数据目录pytest --alluredir./allure-results跑完后用命令把数据渲染成HTML报告allure serve ./allure-resultsallure serve会自动启动本地Web服务并打开浏览器相当于一份可交互的动态报告。如果想生成静态文件给别人传阅用allure generate ./allure-results -o ./allure-report然后allure open ./allure-report。5.3 用feature/story/title给用例讲一个完整故事真正让Allure好看的原因在装饰器它可以让用例带上业务语义import allure allure.feature(订单模块) allure.story(创建订单) allure.title(用户创建订单后金额正确) def test_create_order_total(): ...feature相当于一级模块story是模块下的功能点title是给人看的名字。报告页面里这些信息会形成左侧的功能树点击展开就能看到每个功能下的所有用例通过率、耗时一目了然。真正把测试结果当产品来做的时候这个层级设计非常值钱。我在项目里通常这样约定feature对应业务域订单、支付、用户story对应具体功能点title用一句完整的话描述用户场景。5.4 截图和日志失败现场比什么都重要自动化的核心价值在于失败时能快速定位。Allure提供了attach能力可以做失败自动截图。结合pytest的钩子写一个conftest.pyimport allure import pytest pytest.hookimpl(hookwrapperTrue) def pytest_runtest_makereport(item, call): outcome yield report outcome.get_result() if report.when call and report.failed: driver item.funcargs.get(driver) if driver: allure.attach(driver.get_screenshot_as_png(), namefailure_screenshot, attachment_typeallure.attachment_type.PNG)这段代码的意思是当某个测试用例执行失败时自动把driver当前的页面截图挂到Allure报告里。看报告时不需要再开浏览器复现光看截图和日志就能八九不离十。这是我在真实项目里用得最多的Allure特性。它让我不用反复登录测试环境查看数据状态省下了大量时间。除了截图你还可以用allure.attach挂JSON响应体、接口请求参数、页面源代码等失败现场的完整证据链就这么建立了。6. 踩坑记录与提效心法让pytest融入日常工作6.1 跑了半天0个用例先检查命名规则我刚迁移到pytest时有一次在CI上跑测试等了十分钟一看结果collected 0 items。排查了很久才发现是我把测试函数命名成了check_order_status不是test_开头。这类问题非常高频。建议新项目建立时就定好纪律所有测试文件统一用test_开头所有用例函数统一用test_开头同时养成跑pytest -v的习惯——开头几秒就能看到collected N itemsN如果不符合预期趁早停下来查。6.2 并行执行pytest-xdist的加速与隔离测试量上来后动辄几百上千条串行跑可能要几分钟甚至更久。pytest-xdist插件可以并行pip install pytest-xdist pytest -n auto-n auto会根据CPU核心数自动开进程。但注意并行环境下fixture的隔离性变得非常重要。不要用session级别的文件句柄去写同一个日志文件不要在测试里依赖随机端口或公共目录否则并行后的偶发失败会让排查非常痛苦。我的经验是先串行跑通再上并行。把并行当作最终优化手段而不是一开始就打开。6.3 有选择地跳过skip与skipif有些用例不是一开始就能跑的比如依赖的外部服务还没启动、只针对Windows的用例在macOS上跑。用skip和skipif来管理import pytest import sys pytest.mark.skip(reason依赖的支付服务未启动) def test_payment(): ... pytest.mark.skipif(sys.platform win32, reasonWindows上不执行此用例) def test_unix_style_path(): ...跳过不等于忽略报告里能看到跳过的数量。这样你能够知道“不是没跑而是暂时没跑”比注释掉代码规范太多。要小心的是跳过的用例如果长期存在容易成为测试套件里的“僵尸”最好的做法是给跳过原因加上负责人和修复时间定期清理。6.4 从写用例到调试测试心态转变最重要最后聊点个人体会。用pytest跑测试几年后我最大的变化是写测试的心态从“证明代码能跑”变成了“帮未来的自己排查问题”。断言写得更细fixture划分更清楚参数化的名字宁可长一点也要一眼能懂。刚开始我把pytest当unittest的替代品后来才意识到它是一套完整的测试组织哲学——测试不是代码的附属品而是一等公民。一个小技巧分享给你保持测试代码的可读性比让它跑得快更重要。如果你在看一个测试时不能在三秒内说出它的前置条件、断言了什么、失败时大概哪里出错这段测试就还有优化的空间。pytest给了你足够优雅的机制剩下的就看你怎么用了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑