从单元测试到集成测试:pytest实战构建可靠的Python代码 📅 发布时间:2026/9/8 12:47:58 👁 浏览次数: 写代码不写测试等于只穿了一只鞋就出门跑步。我见过太多人把“单元测试”“集成测试”当成项目的负担觉得功能能跑就行结果项目一过三个月改一个支付金额的计算逻辑下游七个报表全错了找不到是哪一行写的、哪个调用点改成什么了。国内很多Python入门教程讲到JSON、爬虫、虚拟环境就结束了一到测试就轻飘飘带过。可真正让你从“会写代码”跨到“能维护项目”的恰恰是这章内容。这篇文章我会从单元测试和集成测试的分工说起结合一个订单价格计算的完整例子把pytest怎么用、mock怎么隔离外部依赖、测试数据库怎么搭、集成测试怎么测真实协作流程一步步走一遍。适合刚学完Python基础语法、正在尝试做稍微大一点项目的读者也适合已经写了一阵子代码但始终没有认真补上测试这块短板的开发者。1. 先想明白为什么单元测试能替你扛住回归1.1 重构恐惧症的唯一解药我有个特别深的体会刚工作的前两年我重构任何一段代码都提心吊胆。不是怕新代码写不出来是怕改完以后不知道哪里炸了。炸了也没关系问题在于炸的位置通常不在你改的地方而是隔了三层调用关系的某个角落。这种“怕改”的恐惧会让人不知不觉不敢动老代码项目就慢慢腐化了。单元测试解决的就是这件事。它的目标很简单把每个函数、每个模块的行为固化成断言只要行为没变测试就一直过哪天你改了内部实现结果把外部的行为搞错了测试立刻红给你看。有了这层保护“重构”就从一个高风险动作变成了一个普通操作。你可以放心地抽出公共函数、换更快的算法、调整异常处理顺序改完跑一遍测试就知道有没有问题。我经常跟朋友打比方单元测试就是给你的代码写了一份可执行的合同。这份合同不规定内部怎么实现只规定输入什么、应该输出什么、碰到异常会怎么表现。你看合同没变那就大胆去改实现。1.2 单元测试与集成测试的分工定位很多初学者的第一个误区是把单元测试和集成测试混为一谈。这两个东西虽然都叫测试职责完全不同。单元测试的关注点是“一个函数/一个类输入这个值输出对不对”。它的特点是快、隔离、覆盖全面。一个单元测试在毫秒级完成一天跑几万次都不心疼。单元测试的代码通常不访问真实数据库不发起真实请求不依赖外部系统只测自己的逻辑。集成测试的关注点是“多个模块拼在一起之后数据流对不对”。比如订单模块调了用户模块用户模块再去查数据库查出来的数据经过计算后发到消息队列这个链路在真实环境下能不能跑通、字段对不对、顺序对不对都得靠集成测试来验证。它比单元测试慢也比单元测试贵但它是唯一能在早期暴露模块间契约问题的手段。所以一套健康的测试体系从来不是二选一底层把单元测试写细减少低级bug上层用集成测试兜住模块协作的边界。两条腿一起走项目才能持续迭代。2. 工具选型解析为什么我推荐pytest2.1 unittest、pytest、doctest怎么选聊到Python测试第一反应通常有三个选项标准库的unittest、第三方库pytest、以及作为函数注释示例的doctest。unittest是Python自带的测试框架不需要安装经典风格是创建一个继承TestCase的类方法名以test_开头里面用self.assertEqual、self.assertTrue这类断言方法。它没有问题能跑很多老项目也都在用。但我个人不喜欢它的两点一是样板代码多一个简单的测试也要写类、写方法、写self二是fixture机制用setUp/tearDown来组织一旦测试之间需要共享数据代码会变得很绕。doctest适合用来验证文档里的示例是否真实可用不适合作为正式测试体系。它把测试写在函数docstring里阅读方便但写复杂断言、参数化测试、异常场景时会很吃力。我一般只在写库的时候用它做“示例即测试”真正的主力还是pytest。pytest是当前Python社区事实上的标准。它支持普通函数直接作为测试用例断言就用Python原生的assert失败信息自动打印出表达式的详细差异。它内置fixture机制、参数化装饰器、临时目录、monkeypatch还有大量插件可以扩展覆盖率、超时、重试、分布式执行等功能。除非项目强制要求unittest否则我从新项目第一天起就默认pytest。2.2 pytest的fixture和参数化好在哪pytest相比unittest最质的飞跃就是fixture。fixture允许你用装饰器定义一个“生产测试依赖”的函数在测试函数参数列表里声明需要什么pytest自动注入。举个最直观的例子很多测试需要连接一个测试数据库。unittest里你需要在setUp里建立连接、在tearDown里清理数据每一层的setUp/tearDown还会互相继承、执行顺序复杂。pytest里你只要写一个fixture函数处理好连接和销毁然后测试函数上写一个db参数框架就会自动帮你组合。不同测试可以声明不同的fixture组合互不干扰。参数化更是消灭重复测试代码的利器。没有参数化之前你想测一个函数在不同输入下是否符合预期要么写一堆几乎一样的测试函数要么写一个测试函数里用for循环去做断言。用for循环其实是个坏习惯因为第一个断言失败后面的循环直接中断你看不到后面的用例是过还是挂而且报错信息里根本没有具体是哪组数据出的问题。用pytest.mark.parametrize每个参数组合都是独立用例哪个组合挂了日志里清清楚楚不会互相牵连。2.3 一个能直接用的测试工程模板我不建议在项目根目录随手放几个test文件就开始写。一套基础的目录规范能让后面的维护省很多事。我比较常用的模板是这样的project_root/ ├── src/ │ └── myapp/ │ ├── __init__.py │ ├── order.py │ └── repository.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_order.py │ └── test_api_integration.py ├── pytest.ini └── requirements-dev.txt被测代码放在src/myapp里测试代码全部放在tests目录conftest.py放跨文件共享的fixture。项目根目录下放一个pytest.ini配置文件[pytest] testpaths tests addopts -q --tbshort --disable-warnings filterwarnings errorfilterwarnings设为error是为了把代码里的废弃API警告直接变成测试失败逼迫你升级依赖、清理坏习惯。addopts里的-q表示安静模式--tbshort表示错误堆栈只要短格式日志太多的时候看起来不累。有了这套基础结构你后面写任何测试都是往里填内容不用每次重新想组织方式。3. 单元测试实战从第一个函数到全面覆盖3.1 先把被测函数设计得不那么难测很多新手发现写测试很难问题往往出在被测代码本身——函数里塞了数据库查询、网络请求、文件读写、打印日志所有东西搅在一起你根本没法构造一个最小输入来验证逻辑。单元测试能顺畅写下去的前提是业务逻辑里“纯计算”的部分被拆成可以独立调用的函数。这句话值得反复读。以订单价格计算为例假设业务规则是订单包含若干商品每项有商品名、单价、数量整单折扣按折扣率计算折扣率0到1之间如果商品列表为空总价应该是0非法折扣率必须抛异常不能静默算错价格。我先把核心计算设计成这样# src/myapp/order.py def calculate_order_total(items, discount1.0): if not 0.0 discount 1.0: raise ValueError(discount must be between 0 and 1) subtotal 0.0 for _, price, quantity in items: if price 0 or quantity 0: raise ValueError(price and quantity must be non-negative) subtotal price * quantity return round(subtotal * discount, 2)这个函数不碰数据库不读文件不连网络输入是普通数据结构输出是浮点数。它的“可测性”非常好因为你能精确控制每一条输入能事先心算出期望结果。如果你的代码里有很多函数都长着一张“什么都干”的脸那第一步不是补测试而是先重构把计算逻辑从副作用里抠出来。测试不是找麻烦是逼你把代码写干净。3.2 边界值、异常路径和正常路径怎么写写单元测试最忌讳只测“正常情况”。正常路径是锦上添花边界和异常才是bug的高发区。我给自己定了这么一套习惯每个核心函数至少覆盖下面几类场景常规输入一组商品、一个折扣率验证返回值空输入空列表能不能正常返回0还是直接抛错边界值折扣率为0、为1单个商品数量为0非法输入折扣率小于0、大于1商品单价为负数必须抛出明确异常浮点精度涉及小数计算的金额必须确认精确到分不出现0.10.20.30000000000000004这种脏数据。对应到代码上第一版测试文件长这样# tests/test_order.py import pytest from myapp.order import calculate_order_total def test_normal_order_with_discount(): items [(A, 100.0, 2), (B, 20.0, 3)] assert calculate_order_total(items, 0.9) 234.0 def test_zero_discount_returns_subtotal(): items [(A, 88.0, 1)] assert calculate_order_total(items, 1.0) 88.0 def test_free_order_returns_zero(): assert calculate_order_total([], 1.0) 0.0 def test_discount_greater_than_one_raises(): items [(A, 10.0, 1)] with pytest.raises(ValueError, matchdiscount): calculate_order_total(items, 1.1) def test_negative_price_raises(): items [(A, -5.0, 1)] with pytest.raises(ValueError, matchnon-negative): calculate_order_total(items)每个测试函数只验证一个行为名字用一句话描述预期的行为。失败的时候你看到test_discount_greater_than_one_raises立刻知道是折扣率校验出问题了不需要从头读全函数。3.3 用pytest参数化消灭重复测试代码上面几个测试还只是毛毛雨。真实业务里一个订单计算可能在十几种价格组合里反复验证按上面的写法测试文件很快就会变成几百行的复制粘贴。用参数化重构一下pytest.mark.parametrize( items, discount, expected, [ ([(A, 100.0, 2), (B, 20.0, 3)], 0.9, 234.0), ([(A, 88.0, 1)], 1.0, 88.0), ([], 1.0, 0.0), ([(A, 33.0, 0)], 1.0, 0.0), ([(A, 0.05, 100)], 0.8, 4.0), ], ) def test_calculate_order_total_variants(items, discount, expected): assert calculate_order_total(items, discount) expected参数化之后每一组数据都是独立的测试用例。运行pytest你能看到五个独立的用例名哪个挂了一眼就能定位。更重要的是后续你要增加新的价格组合只需要往列表里填一行不需要新增函数。测试逻辑和测试数据彻底分开了维护成本直线下降。再补一个参数化的异常用例pytest.mark.parametrize( items, discount, [ ([(A, 10.0, 1)], 1.5), ([(A, 10.0, 1)], -0.1), ([(A, -2.0, 1)], 1.0), ([(A, 1.0, -3)], 1.0), ], ) def test_invalid_parameters_raise(items, discount): with pytest.raises(ValueError): calculate_order_total(items, discount)到这里calculate_order_total这个函数的单元测试基本覆盖了所有关键分支。跑一下pytest配合pytest-cov插件看一眼覆盖率pip install pytest-cov pytest --covmyapp --cov-reportterm-missing如果覆盖率不理想说明有些分支还没测到比如某个异常分支始终没进去。我会盯着Missing列把没有覆盖到的行补上测试。当然覆盖率只是指标不是目标但它是排查“我是不是还有大量逻辑裸奔”的雷达。4. 隔离外部依赖mock、fixture与可信测试4.1 为什么要隔离外部依赖会把测试变慢变脏单元测试有个原则测试本地逻辑不要依赖外部环境。如果你在单元测试里访问了测试数据库、请求了第三方接口、调用了支付网关那这套测试就失去了“能随时重复跑、能稳定出结果”的优势。举个例子测试发送订单通知的函数。如果测试代码真的去调微信推送接口那么一旦网络断开、密钥过期、接口限流测试就挂了而且挂的消息很可能和你的业务逻辑毫无关系。更麻烦的是真实接口通常会修改线上状态哪怕用户数据是测试账号也会污染环境。我的建议是单元测试阶段所有外部依赖都用替身mock或stub代替。替身只验证“我的代码有没有在正确的时机用正确的参数调用外部接口”而不是真的去把外部系统跑一遍。真实联通性验证留给集成测试那里才允许动数据库和真实接口。4.2 三种隔离手段的适用场景pytest自带monkeypatch fixturepytest-mock插件则提供了更顺手的mock对象。我一般按场景选择函数里直接import了某个模块的第三方调用比如requests.post我会用monkeypatch.setattr替换目标函数需要在断言里验证“调用了几次、传了什么参数”我会用mocker.patch拿到Mock对象再用mock_obj.assert_called_once_with进行检查如果外部系统太复杂我会用faker生成模拟数据用Fake类模拟整个外部服务。用monkeypatch写一个例子场景是下了订单之后调用通知模块发一条站内信但测试环境我们不能真的发消息# src/myapp/notifier.py def send_after_order(order_id, user_email, message): # 这里假设调用了某些第三方推送API pass# tests/test_notify.py from myapp.order import process_order from myapp.notifier import send_after_order def test_order_triggers_notification(monkeypatch): notified [] def fake_send(order_id, user_email, message): notified.append((order_id, user_email, message)) monkeypatch.setattr(myapp.notifier.send_after_order, fake_send) order process_order(basket[(A, 10.0, 2)], user_emailxexample.com) assert order.total 20.0 assert len(notified) 1 assert notified[0][0] order.order_id这里monkeypatch把真正的网络调用换成了本地函数fake_send。测试只关心业务逻辑里确实发起了通知而不关心推送API到底通不通。fake_send里收集参数的方式让断言非常直观。如果换成pytest-mock的mocker写起来更简洁def test_order_triggers_notification(mocker): fake_send mocker.patch(myapp.notifier.send_after_order, return_valueNone) order process_order(basket[(A, 20.5, 1)], user_emailxexample.com) assert order.total 20.5 fake_send.assert_called_once_with( order.order_id, xexample.com, mocker.ANY )mocker.ANY表示“这个参数只要存在就行我不关心具体内容”适合消息里包含时间戳这类动态值的场景。注意使用mock的目的不是为了让测试通过而是聚焦“调用了没有、参数对没对”整体逻辑的值仍然是业务自己算的。4.3 临时数据库fixture写法有些函数就是绕不开数据库那在单元测试阶段我倾向于使用pytest内置的tmp_path参数再配合轻量级SQLite搭一个临时数据库。这样测试跑完数据库文件直接没了干净利落。不用装任何插件tmp_path是pytest自带的。# tests/conftest.py import sqlite3 import pytest from myapp.repository import OrderRepository pytest.fixture() def db_conn(tmp_path): db_file tmp_path / test_orders.db conn sqlite3.connect(db_file) conn.execute( CREATE TABLE orders ( id INTEGER PRIMARY KEY, user_email TEXT NOT NULL, total REAL NOT NULL ) ) conn.commit() yield conn conn.close() pytest.fixture() def order_repo(db_conn): return OrderRepository(db_conn)测试函数里只要声明需要order_repopytest会自动依次创建db_conn、再创建order_repo。这种“fixture链条”能力让依赖组装变得极其清晰每个fixture只负责一件小事组合起来就是完整的依赖环境。需要注意的是tmp_path是session级还是function级它在pytest里默认每个测试函数都是新的临时目录因此两个测试之间不会共享数据天然隔离无需手动清理。你不需要做任何额外的“清库”操作。5. 集成测试实战让模块真正协作起来5.1 集成测试验证的是“模块之间的契约”写完单元测试别急着收工。单元测试只能证明“函数自己转得对”但它证明不了“A函数调B函数时B返回的格式是不是A想要的”更证明不了“数据库里存进去的值再读出来是不是原来那个类型”。集成测试就是专治这种“模块之间各说各话”的问题。它跑的是真实链路不是mock出来的接口不是假的推送器而是尽量接近生产环境的组件组合。在这个阶段数据库可以用本地测试库HTTP请求可以打到沙箱但代码路径必须是真实的。用订单流程举例一条集成测试会覆盖从创建订单、计算总额、写入订单表再到发送通知、记录日志整条链路走一遍。如果哪个环节字段名对不上、类型不匹配、表结构缺失测试就会在早期暴露出问题比上了生产环境再让用户发现要好一百倍。5.2 用测试数据库做真实数据流验证集成测试的数据库选择我推荐在本地用一个独立测试实例不跟开发库共用。如果项目足够小SQLite也能凑合但要注意SQLite的行为和MySQL/PostgreSQL有差异比如类型转化更宽松、索引行为不同。既然目标是验证真实协作最好选择一个和生产环境同类型的数据库哪怕起个Docker容器都行。我的习惯是项目里写一个docker-compose.test.yml用测试专用的数据库服务测试结束直接销毁。集成测试里先创建表结构再插入测试数据然后跑业务代码最后断言数据落库情况和返回结果。为了每次测试互不影响fixture里要做两件事重建表结构、开启事务回滚。事务回滚的方式是测试开始时开启事务测试结束时回滚这样数据库不会残留任何中间数据。SQLite下写法如下pytest.fixture() def transactional_db(): conn sqlite3.connect(:memory:) conn.execute(CREATE TABLE orders (id INTEGER PRIMARY KEY, user_email TEXT, total REAL)) conn.commit() try: yield conn finally: conn.rollback() conn.close()要注意用内存SQLite的好处是快但如果并发连接多每个连接看到的内存库是独立的。真正要模拟并发场景还是得用真实数据库实例。集成测试不是越快越好而是越接近真实越好两者需要平衡。5.3 接口互联测试别把本地代码跑通当上线安全除了数据库集成测试还经常要验证接口互联。比如你的项目里有一个调用用户服务获取用户等级的模块单元测试里你mock了整个用户服务集成测试里就要换成真实的测试服务或者本地启动的依赖服务。我的经验是接口集成测试不要单独只测“调用成功”一定要把成功、失败、超时、返回异常数据四种情况都覆盖到。很多系统出问题不是主流程错了而是依赖方返回了一个你没预料到的格式把你的解析逻辑击穿了。集成测试里可以故意让测试服务返回缺失字段的数据验证系统能否优雅降级。写接口测试时我常用的模式是用fastapi的TestClient或者requests直接打本地启动的测试服务。只要数据隔离做好这个测试其实非常快能在几秒内完成一个小型链路的验证。关键不是选什么工具而是强迫自己从“我函数没错”上升到“我们整条链路没错”。6. 常见问题排查与避坑实录6.1 单测跑得慢先自查这五个地方遇到测试越跑越慢不要先怀疑pytest本身。我自己踩过的坑整理成一张检查表测试代码里有没有真的去访问网络、有没有真的连数据库、是否在测试里调用了sleep、是不是每个测试都重复创建了重量级对象、依赖服务是不是在本地没启动导致每次重试超时。通常80%的慢都来自网络超时和数据库连接。有些测试确实需要等待异步任务完成尽量避免用time.sleep固定等待改成轮询等待“目标条件满足”这样机器快就快跑机器慢也不会误判失败。6.2 mock太多导致测试失去价值怎么办mock是双刃剑。用多了测试里全是替身最后测的不是真实代码而是“自己和自己约定的结果”这种测试全是绿的但一点保护能力都没有。我给自己定了一条纪律只mock边界外部系统不mock内部逻辑。比如发通知、支付网关、认证服务这些外部调用可以mock但你自己的订单计算、折扣规则、库存扣减逻辑绝对不能用mock糊弄过去。如果测试里发现需要mock自己项目内部的多个函数那往往是代码耦合太紧的报警信号优先去重构代码而不是加更多mock。6.3 fixture泛滥和测试间的相互污染fixture好用但写多了也会乱。比较常见的是fixture里改了全局状态比如配置文件、环境变量测试跑完没恢复导致后面的测试全部受影响。pytest的monkeypatch在处理环境变量时会自动恢复这点我很喜欢。但你如果自己写fixture修改了全局对象一定要在yield之后复原。另外一个坑是fixture作用域混乱。默认fixture是函数级每个测试都重建安全但慢调成module级或者session级能提速但状态会因为测试顺序改变而互相污染。经验法则是只读数据可以放module级任何“测试中会被修改”的fixture保持函数级。6.4 每次跑挂先看测试环境再怀疑业务代码测试红了新手的第一反应是打开业务代码从头到尾查。我的习惯是倒过来先确认测试环境是不是干净的。数据有没有被上一个测试污染、数据库表结构是不是没更新、依赖服务的测试数据是不是过期了。环境没问题再去看测试代码本身最后才去检查业务代码。这个顺序能帮你省掉无数排查时间。还有个小技巧是给pytest配置一个快速失败参数后面可以专注修一个错误pytest --maxfail1 -x适合你刚改完哪块业务逻辑、只想快速知道第一处炸在哪的时候。真正要跑完整的回归再去掉限制。7. 把测试沉淀成自己的习惯单元测试和集成测试这块知识点本身并不难难的是形成习惯。我可以很诚实地说我刚开始也嫌麻烦总觉得自己写的东西很对不用测。直到有一次因为改了一个“看起来只是优化性能”的函数把一个报表模块搞挂了用户第二天早上一打开后台就看到错误堆栈。那次之后我才真正把测试当成和写逻辑一样重要的事。现在我的开发流程已经固定成写核心逻辑之前先想清楚函数边界写代码的同时把参数化用例想好跑通了有钱就补边界和异常用例涉及数据库和外部系统的场景单元测试里全部隔离集成测试里再用本地测试库走一遍真实链路。每次合代码之前pytest必须全绿必须跑过覆盖率哪怕只覆盖到中位数也比“从未测过”好太多。你不需要第一天就写出教科书级的高覆盖测试。从今天找一个最核心、最容易出错的小函数开始写第一组用例把“写测试”这件事变成习惯后面受益的是三个月后还在维护这个项目的你。