ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python单元测试与集成测试实战:从pytest到mock依赖隔离

Python单元测试与集成测试实战:从pytest到mock依赖隔离 开篇测试是代码的“安全网”不是KPI我写Python快十年真正让我对“测试”这两个字产生敬畏的不是你写了多少个测试用例、覆盖率数字多漂亮而是某次深夜重构核心模块跑完测试全绿的那一瞬间——那种“可以放心睡觉”的感觉比什么指标都实在。这一章我们聊单元测试与集成测试。简单说单元测试是单独验证每个函数、每个类“自己是不是对的”集成测试是验证几个模块配合起来“是不是还对的”。这两件事解决的痛点非常具体改代码改出回归Bug、上线前手忙脚乱、代码一多就不敢动。很多朋友学Python时觉得“能跑就行”但一个项目只要超过几千行没有测试兜底你改任何一个公共函数都要冒着全站崩掉的焦虑。这篇文章的目标读者是已经掌握Python基础语法、想进入工程化开发阶段的人。我会从单元测试的底层原理讲起到 pytest 实战、mock 依赖隔离、集成测试的真实案例最后是踩坑记录。跟着走一遍你就能给自己的Python项目搭起一张靠谱的安全网。1. 概念边界单元测试、集成测试到底在测什么1.1 单元测试的本质是“隔离验证”单元测试Unit Test的目标是对代码中最小的可测试单元通常是一个函数或一个类的方法做验证。基本思路是把被测单元从整个系统里孤立出来给它一个确定的输入然后断言输出是否符合预期。这里的关键词是“孤立”。如果被测函数会读数据库、调外部API、读环境变量那么在单元测试阶段这些外部依赖都必须被“架空”否则你测的就不再是单元本身而是单元加上一堆外部服务的组合体。为什么必须这么做因为单元测试的使用场景是“频繁、快速、定位精准”。一个项目里单元测试可能有几百上千个每次提交代码都要跑一遍如果每个用例都要连数据库、发HTTP请求那整个测试跑完可能需要几十分钟甚至几个小时开发效率会变得极低。更麻烦的是外部依赖不稳定会导致测试结果不稳定——数据库连接超时、第三方接口限流这些都会让测试“无缘无故”失败你很难判断到底是自己的代码出了问题还是外部服务出了问题。单元测试的设计哲学就是把不可控因素全部排除掉只留下纯逻辑让失败结果永远指向真实的代码缺陷。1.2 集成测试验证的是“协作契约”集成测试Integration Test则是把多个模块放到一起验证它们之间的交互是否符合预期。核心关注点不是“某个函数对不对”而是“模块A调用模块B时数据格式对不对、顺序对不对、异常处理能不能被正确触发”。集成测试常见的对象是数据库读写、外部API调用、消息队列的收发、微服务之间的RPC调用等。打个比方。单元测试就像检查一根链条上的每一环是否结实而集成测试是把整条链条挂上重物看环与环之间的衔接是否牢固。单个环节没问题不代表组装起来整体就没问题——接口参数名改了但调用方没跟着改、返回数据里某个字段突然变成None、状态码含义变了这些Bug都只有到集成层面才能暴露。还有个容易被误解的点集成测试不是“端到端测试”。端到端测试E2E是整个系统从用户入口到后端存储的完整链路通常由专门的测试团队或测试框架负责集成测试一般限定在几个模块之间的协作范围要小得多。在实际项目中这两者经常被混为一谈导致测试用例设计起来又大又重既不符合集成测试的定位也不够端到端的完整最后两边都不讨好。1.3 为什么两者的“硬度”不一样单元测试和集成测试在稳定性要求上也有明显差异。单元测试由于不碰外部依赖只要代码逻辑不变理论上可以做到100%稳定——同一个输入永远得到同一个输出。而集成测试天然依赖环境数据库里的数据可能会变、外部API版本可能会升级所以集成测试本身带有一部分“环境测试”的性质。这导致了两者在运行频率上的取舍单测每次提交代码都要跑集成测试通常在合并分支或准备发版时才完整跑一遍。这种“硬度”差异还影响着测试失败的处理方式。单测失败几乎可以断定是你的业务逻辑有问题需要立刻修。集成测试失败需要先判断是环境问题还是代码问题比如数据库里脏数据导致的失败清理数据后可能就恢复了。如果把集成测试当成单元测试来处理见到红了就焦虑很容易把时间消耗在环境维护上而不是真正修Bug上。2. 工具选型解析unittest、pytest到底选哪个2.1 标准库unittest不需要装但写起来确实啰嗦Python自带的unittest是从Java的JUnit移植而来采用类继承的方式组织测试用例。它的优点是不用安装任何第三方库Python环境装好就能跑在小型脚本项目里简单直接。但它的缺点也很明显样板代码太多。import unittest class TestCalculator(unittest.TestCase): def setUp(self): self.calc Calculator() def test_add(self): result self.calc.add(2, 3) self.assertEqual(result, 5) def tearDown(self): pass if __name__ __main__: unittest.main()每个测试类要继承 unittest.TestCase初始化写在 setUp 里清理写在 tearDown 里断言方法要写成 self.assertEqual、self.assertTrue 这样一长串。对于一个简单的功能测试光这些框架代码就占了一半行数。如果你只是临时验证几个函数这么做确实有点重。即便有 pytest 这个更现代的选择unittest 的基本功还是要懂因为很多老项目、开源库的测试代码仍然是 unittest 写的你能读懂它们才能给这些项目贡献代码。2.2 pytest学习曲线平缓能力上限极高pytest 是目前Python社区事实上的测试标准。它用普通的 assert 语句代替 unittest 的一堆断言方法通过fixture机制管理前后置逻辑放弃了强制类组织允许用纯函数写测试用例这让测试代码的清爽程度直接上升了一个档次。同一个加法测试用 pytest 写成这样def test_add(): calc Calculator() assert calc.add(2, 3) 5没有类、没有 self、没有断言方法名就是一个普通的函数加一个 assert。这种极简风格带来的好处是明显的写测试的门槛降低你只需要关心被测逻辑本身。同时pytest 的插件生态非常成熟比如 pytest-cov 统计覆盖率、pytest-xdist 并行执行、pytest-mock 集成mock这些在实际项目里会大幅提高测试效率。性能方面pytest 的测试收集机制也比 unittest 更智能。pytest 会自动递归查找当前目录下所有 test_*.py 或 *test.py 文件然后匹配 test开头的函数不需要手动把测试类一个个加载进来。它还支持参数化测试、标签分组、插值断言等高级特性这些后面都会讲到。2.3 我的选择单项目通常测试都用pytest旧代码兼容用unittest如果你问我实际工作中怎么选我的习惯是新项目一律用 pytest老项目如果已经是用 unittest 写的我不会为了“换框架”而去重写一遍而是直接用 pytest 直接跑已有的 unittest 测试。pytest 内置了对 unittest.TestCase 的支持能自动识别和收集 unittest 风格的测试类你先跑起来再逐步迁移这种渐进式改造的方式最平稳。安装 pytest 非常简单直接 pip 安装即可pip install pytest pytest-cov pytest-mock建议统一在虚拟环境里装不要污染全局的 Python 环境。在我之前的一篇文章里详细聊过虚拟环境简单说就是用 venv 或 conda 创建一个独立的Python环境所有项目依赖都装在里面。这样不同项目之间的依赖版本才不会互相打架。你创建一个新项目时可以执行python -m venv .venv然后激活再安装 pytest。2.4 除这两者之外的替代方案除了 unittest 和 pytest还有几个值得知道的测试库。Hypothesis 是一个基于属性的测试库能自动生成大量测试数据来探索边界情况对算法比较复杂的模块很有用。nose2 是 nose 的继任者能力尚可但在 pytest 面前优势不明显。如果做异步代码测试pytest-asyncio 插件可以把异步函数当作普通测试函数直接运行。实际选型时我的判断标准是一个测试框架好不好不看它功能多么齐全而看它是否让你“愿意一直写测试”。pytest 在这方面做得最成功。3. 环境准备从安装到跑通第一个测试用例3.1 搭建一个干净的测试环境我特别想强调一下环境问题因为后台经常收到朋友的私信比如“为什么我明明安装了 pytest运行却提示找不到命令”大部分情况是环境混了。先看一下你当前用的 Python 是哪个which python python --version如果输出指向系统自带的 Python比如 /usr/bin/python说明你很可能没用虚拟环境。操作系统自带或通过系统包管理器安装的 Python 通常不受你控制不适合用来构建项目依赖。最稳妥的做法是mkdir python-testing-example cd python-testing-example python3 -m venv .venv source .venv/bin/activate pip install pytest pytest-cov pytest-mock在 Windows 上激活命令是.venv\Scripts\activate其余一致。注意如果系统提示 python3 命令不存在可能是你的 Windows 没把 Python 加到 PATH里这时候需要去 Python 官网重新安装并勾选“Add Python to PATH”。这一步看似基础但很多环境相关的诡异报错根子上都是这里出了问题。3.2 第一个项目骨架与测试文件命名规范我们用一个真实的场景来展开假设你在做一个购物车模块里面有一个计算订单总价的功能函数。项目结构可以这样组织shopping_cart/ ├── cart.py ├── test_cart.py └── conftest.pypytest 默认递归收集当前目录下文件名以 test_ 开头或test 结尾的Python文件。所以你的测试文件必须命名成 test_cart.py 这样的格式否则pytest根本发现不了。测试函数也要以 test开头pytest 才会把它当作一个测试用例去执行。这个命名约定虽然看起来像是“框架要求”实际上是一种非常好的沟通手段——任何人看代码一目了然哪些是业务代码哪些是测试代码。3.3 先写一个真实的测试用例在 cart.py 里写一个适用于各种测试场景的小函数def calculate_total(items): 计算购物车中所有商品的总价。 items 是列表每个元素是包含 price 和 quantity 的字典。 total 0.0 for item in items: total item[price] * item[quantity] return round(total, 2)然后在 test_cart.py 里写测试from cart import calculate_total def test_empty_cart_returns_zero(): assert calculate_total([]) 0.0 def test_single_item(): items [{price: 10, quantity: 2}] assert calculate_total(items) 20.0 def test_multiple_items(): items [ {price: 3.5, quantity: 2}, {price: 1.0, quantity: 1}, ] assert calculate_total(items) 8.0在虚拟环境激活状态下命令行直接运行pytest你会看到类似输出 test session starts collected 3 items test_cart.py ... [100%] 3 passed in 0.02s 三个测试全部通过。注意这里我没有使用任何类、setUp、tearDown只是三个普通函数加 assert这正是 pytest 风格的核心优势。更重要的是你可以立刻做一次“破坏实验”把 calculate_total 里的累加逻辑改成total item[price] - item[quantity]再跑 pytest三个用例会全部变红。这就是测试给你带来的第一个价值——一个可以随时复现问题的雷达。3.4 为什么断言信息这么重要pytest 的断言报告做得非常好它会在测试失败时显示期望值与实际值的完整对比。你可以故意加一个错误断言比如把预期值写成 21.0运行后 pytest 会输出___________ test_single_item ______________ def test_single_item(): items [{price: 10, quantity: 2}] assert calculate_total(items) 21.0 E assert 20.0 21.0 E where 20.0 calculate_total([{price: 10, quantity: 2}])这比普通的 print 调试直观太多。你不需要在业务代码里到处塞 print只需要在断言失败信息里就能看到完整的函数调用链和具体参数。你在设计测试时尽量让每条断言只验证一个行为点这样失败时才能一眼定位问题不用在一大堆断言结果里凌乱地找原因。4. 核心细节解析断言、fixture、参数化与跳过机制4.1 pytest 对断言做的“增强魔法”原生 Python 的 assert 语句其实很简单——表达式为 False 时抛出 AssertionError。pytest 之所以能输出那么详细的失败信息是因为它在收集测试时对 assert 语句做了 AST 重写把原始的断言表达式拆解成可以展示中间变量值的形式。这个机制让 pytest 能直接告诉你“expected 20.0”和“actual 21.0”而不是给你一句笼统的 AssertionError。你不需要自己写特制的断言函数这大大降低了写测试的心智负担。除了基础的 判断pytest 还提供了另一套辅助方法pytest.approx 在比较浮点数时特别有用。上面的购物车例子我用了 round 函数来保留两位小数但很多情况下你算出来的结果是 0.1 0.2直接用 判断可能永远为 False因为浮点数精度问题会导致 0.30000000000000004。这时候正确做法是def test_float_comparison(): assert 0.1 0.2 pytest.approx(0.3)pytest.approx 允许你设定一个容差范围默认是相对误差 1e-6。在涉及金额、统计指标这类计算时切记先搞清楚阈值精度再决定是直接比大小、比相等还是用 approx。很多测试看起来“经常失败”一半以上的原因不是业务逻辑问题而是浮点比较太苛刻了。4.2 fixture解决“每个用例都需要准备数据”的通用方案在单元测试里前置和后置逻辑是最常见也最容易写乱的场景。比如你要测试一个操作数据库的函数每个测试用例开始之前都需要创建临时表、插入几条种子数据结束之后要清理掉这些数据。如果把这些逻辑直接复制到每个测试函数里代码冗余不说还容易因为忘记清理而污染其他测试。pytest 的 fixture 机制就是为了解决这个问题。fixture 本质上是一个带有 pytest.fixture 装饰器的函数它负责准备和清理环境测试函数将 fixture 作为参数传入后自动获得它的返回值。看一个例子import pytest pytest.fixture def sample_cart_items(): items [ {price: 10, quantity: 2}, {price: 5, quantity: 4}, ] yield items # 这个位置可以在用例结束后做清理 print(清理购物车临时数据...) def test_calculate_total(sample_cart_items): assert calculate_total(sample_cart_items) 40.0这里的 fixture 函数用 yield 分隔了“准备阶段”和“清理阶段”yield 之前的代码在测试用例开始前运行yield 之后的代码在测试用例结束之后运行。fixture 的参数作用域默认是函数级别的——每个测试函数都会独立执行一次 fixture保证了用例之间互不干扰。你也可以把 scope 参数改为 module 或 session让多个测试文件复用同一个环境这样可以显著减少重复建表的开销。但注意模块级或会话级 fixture 引入状态共享可能造成用例间依赖使用时要额外小心。fixture 的第二个好处是嵌套依赖。fixture 可以依赖其他 fixturepytest 会根据测试函数的参数自动解析依赖树。这种机制非常适合构建有层次的环境准备逻辑比如pytest.fixture def empty_db(): conn create_connection() yield conn conn.close() pytest.fixture def filled_db(empty_db): insert_seed_data(empty_db) return empty_db4.3 参数化同样的测试逻辑跑多组数据测试领域有个原则叫“每一条路径都需要一条用例”但你不能真的傻乎乎复制十遍同一个测试函数。pytest 的 pytest.mark.parametrize 装饰器支持将一组组参数依次注入同一个测试函数。沿用购物车的例子可以把多个边界情况写在一起import pytest from cart import calculate_total pytest.mark.parametrize(items,expected, [ ([], 0.0), ([{price: 10, quantity: 2}], 20.0), ([{price: 3.5, quantity: 2}], 7.0), ([{price: 0, quantity: 10}], 0.0), ]) def test_calculate_total(items, expected): assert calculate_total(items) expected运行时pytest 会针对每一组参数生成一个独立的测试用例节点哪个参数组合失败报告里就能直接看到是哪组入参出的问题。参数化这个神器我建议尽早养成使用习惯因为它能弥补“大家都喜欢测正常路径、容易忽略极值”的心理盲区。比如上面我顺手加了 price 为 0 的用例很多业务Bug恰恰就是在这种“看起来不该出现”的输入里冒出来的。4.4 跳过和预期失败管理那些不该中断测试的场景有些测试依赖于特定平台或特定第三方库在这些依赖不满足时直接把用例标记为跳过而不是让整个测试套件变红。使用非常简单import sys import pytest pytest.mark.skipif(sys.version_info (3, 10), reason需要 Python 3.10 以上版本) def test_new_syntax_only(): ...如果你有某个已知Bug还没修复但你又想保留这个测试用例作为提醒可以使用 xfail 标记pytest.mark.xfail(reason已知问题窗口宽度不为0时计算错误) def test_resize_with_nonzero_width(): ...xfail 的用例运行时会正常执行如果失败则报告为 xfailed期望中的失败不会让主流程fail但如果你后来把它修好了它会变成 xpass这时候你就知道该移除这个标记了。这个小功能以前被我用来挂“待办事项”效果非常好。5. 实操过程与核心环节实现从单测到集成测试完整案例5.1 被测对象写一个更贴近实际的项目模块为了把后续的集成测试讲明白我们设计一个贴近实际工作的场景一个用户注册模块注册时要校验用户名唯一性、密码强度、写入数据库。为了演示 mock 的用法这里假设 UserRepository 是一个需要连接数据库的类但我们不想在单测阶段真的连数据库。代码大概是这样的# user_service.py import hashlib import re class UserService: def __init__(self, user_repo): self.user_repo user_repo def register(self, username, password): 注册新用户。如果用户名已存在抛 ValueError成功则返回用户ID。 if not self._is_valid_password(password): raise ValueError(密码长度至少8位且必须包含字母和数字) existing self.user_repo.find_by_username(username) if existing: raise ValueError(用户名已存在) password_hash self._hash_password(password) user_id self.user_repo.create(username, password_hash) return user_id def _is_valid_password(self, password): if len(password) 8: return False if not re.search(r[A-Za-z], password): return False if not re.search(r\d, password): return False return True def _hash_password(self, password): salt fixed_salt_for_demo return hashlib.sha256((salt password).encode()).hexdigest()这里构造函数接收一个 user_repo 参数这就是依赖注入的雏形。在单测中我们可以传入一个 Fake 对象来彻底隔离数据库。5.2 mock 与依赖隔离用 pytest-mock 模拟数据库操作在进行单元测试时我们不想触碰真实的数据库。最粗暴的方案是写一个 Fake 类实现 find_by_username 和 create 接口class FakeUserRepo: def __init__(self): self.users {} def find_by_username(self, username): return self.users.get(username) def create(self, username, password_hash): self.users[username] password_hash return 1这种手写 Fake 的方式可控性强不过当接口方法很多时写起来比较累。另一种思路是用 mock也就是用 pytest-mock 插件里的 mocker fixture 来动态替换方法。看个例子def test_register_success(mocker): fake_repo mocker.Mock() fake_repo.find_by_username.return_value None fake_repo.create.return_value 100 service UserService(fake_repo) user_id service.register(alice, abc12345) assert user_id 100 fake_repo.create.assert_called_once()mocker.Mock() 创建了一个可以根据调用动态生成返回值的对象。你可以指定 find_by_username 的返回值是 Nonecreate 的返回值是 100然后验证方法是否按预期被调用。这样做的核心价值是你测的是 UserService 的注册逻辑而不是 UserRepository 的数据库访问逻辑两者的关注点是分开的。如果被测代码里直接创建了一个真实对象比如self.repo UserRepository()写在构造函数里那么 mock 它就需要用 mocker.patch 去替换类引用。所以更推荐上面这种依赖注入的设计——通过构造函数把 repo 传入——因为它天然为测试留了“后门”mock 起来零成本。这也是我在做代码评审时反复强调的一点写业务代码时就考虑可测试性比事后补测试省力得多。5.3 对“真实”的集成测试连一次真数据库单元测试隔离了外部依赖但隔离不等于万事大吉。验证完 UserService 的逻辑后我们还需要确认 UserRepository 在真实数据库上的查询语句、连接方式、表结构拼接都没问题。这就要写集成测试了。Python 常见的数据库方案是 SQLite它支持内存模式非常适合集成测试——不需要起一个独立的数据库服务直接连 :memory: 就能跑。我用 pytest 的 fixture 来创建内存数据库、执行建表语句并注入到被测对象里import sqlite3 import pytest from user_service import UserService from user_repository import UserRepository pytest.fixture def memory_db(): conn sqlite3.connect(:memory:) conn.execute(CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE, password_hash TEXT)) yield conn conn.close() pytest.fixture def service_with_db(memory_db): repo UserRepository(memory_db) return UserService(repo) def test_register_and_find_user_in_db(service_with_db): user_id service_with_db.register(alice, abc12345) assert user_id is not None repo UserRepository(service_with_db.user_repo.conn) found repo.find_by_username(alice) assert found is not None assert found[1] alice这个测试已经把单元测试和真实数据库之间的鸿沟填上了。它验证的不仅仅是注册函数返回一个ID还验证了 UserRepository 的 SQL 语句确实能在真实数据库上执行、数据确实被持久化到表中。这里用的是 SQLite 内存库所以测试跑完关闭连接就自动清理了不会污染任何真实数据。如果你项目里用的是 PostgreSQL 或 MySQL集成测试就不能这么轻量了需要准备测试库和清理策略。常见做法是用 Docker 起一个专属测试数据库容器pyproject.toml 里配置连接环境变量然后 pytest 连这个测试库跑。数据清理可以借助 pytest 的 fixture 在每个测试用例后执行 DELETE 或事务回滚保证用例间的数据隔离。5.4 集成测试框架的选用pytest 可以做到大而全如果你是用 Django自然首选 pytest-django 插件。它提供了 client fixture能让测试直接调用 Django 的测试客户端发起 HTTP 请求。看一个最简单的集成测试import pytest from rest_framework.test import APIClient pytest.mark.django_db def test_register_api(): client APIClient() response client.post(/api/register/, {username: alice, password: abc12345}, formatjson) assert response.status_code 201加了 pytest.mark.django_db 装饰器后pytest-django 会为这个测试用例创建独立的事务测试结束自动回滚数据库不会留下任何残留数据。这个机制解决了我前面说的“集成测试数据污染”问题是我强烈推荐大家深入了解的。如果是 FastAPI可以用 fastapi.testclient 直接发起测试请求用法和 requests 一模一样。5.5 覆盖率报告让“测试测了什么”可视化测试写了效果好不好覆盖率确实是直观的参考。安装 pytest-cov 后运行pytest --covuser_service --cov-reportterm-missing它会输出每个模块的行覆盖率以及哪些行没有被执行到Name Stmts Miss Cover Missing user_service 32 4 87% 28-33覆盖率数字不是越高越好但低于80%的时候你基本可以确定有重要逻辑没测到。我个人的经验是先关注“关键分支”而不是“数字大小”比如密码校验里每个正则分支、异常处理的每个 except 分支这些必须覆盖至于那些纯展示代码、第三方封装代码覆盖率低一点无伤大雅。6. 测试组织策略目录结构、命名规范与执行策略6.1 测试目录应该和业务代码同层还是分开关于测试代码放哪里社区有几种常见方案。小型项目直接在业务代码目录下创建 test_*.py 文件让 pytest 自动收集优点是简单直接。中等以上项目我倾向于把测试统一放在 tests/ 目录下同时保证业务代码模块可以通过 import 被测试代码引用。项目结构可以是这样my_project/ ├── src/ │ └── my_package/ │ ├── __init__.py │ ├── cart.py │ └── user_service.py ├── tests/ │ ├── test_cart.py │ ├── test_user_service.py │ └── conftest.py分开放的好处是业务代码和测试代码互不干扰部署打包时可以明确只打包 src/ 而不把测试带进生产环境。但要确保 Python 的导入路径能找到 src/my_package最优雅的方案是在项目根目录放一个 pyproject.toml 配置让 pytest 自动把 src 目录加入 sys.path。你手动在 conftest.py 里加 sys.path.insert 也行但不建议长期依赖这种hack做法。6.2 如何保证测试不互相干扰测试之间最怕的是“隐形依赖”——测试A创建了某个全局状态测试B在不知情的情况下依赖了这个状态一旦A先被删除或执行顺序变化B就会失败。要根治这个问题最好的办法是让每个测试都“自包含”所有前置数据都在自己的 fixture 里准备所有外部依赖都在自己的环境里运行。确保测试可以随机乱序、单独执行这是衡量测试质量的一个重要指标。配合 pytest-randomly 插件可以随机打乱测试执行顺序帮你发现测试之间的顺序依赖。如果你跑完随机排序后发现某个用例时好时坏那基本可以断定它默默消费了其他用例留下的状态。这种问题在真实业务代码里特别坑因为本地跑全绿CI 里却随机闪红排查起来非常头疼。6.3 执行策略哪些测试提交前必跑哪些发版前跑不同速度、不同稳定性的测试应该分层管理。我习惯把测试分成三层第一层快速单测不依赖任何外部服务一秒钟内跑完每次 git commit 前必跑。第二层集成测试依赖真实数据库或缓存速度相对慢提交PR、合并主干前跑。第三层端到端测试或全量回归可能涉及完整环境发版前按节奏跑。pytest 支持用 mark 标签来标记测试然后执行时按标签粗筛pytest.mark.integration def test_user_register_integration(): ...命令# 只跑单元测试跳过集成测试 pytest -m not integration # 只跑集成测试 pytest -m integration种分层方案能让开发节奏非常舒适平时写代码不被打扰紧要关头快速全量回归。7. 常见问题与排查技巧实录7.1 测试用例收集结果始终为 0 collected这是我最常被问到的问题之一而且基本都出在“命名不一致”上。pytest 默认收集 test_*.py 和 *test.py 文件以及文件内 test开头的函数。如果你把测试函数命名为 def check_add()pytest 一定不会执行。解决办法很简单命令pytest --collect-only可以先把 pytes 找出来的测试列出来如果这里什么都没有那不是测试没写对就是文件命名不符合默认规则。7.2 fixture 报错ScopeMismatch 或 fixture not foundfixture 报错通常是两种原因。一种是你把 fixture 定义在一个测试文件里但另一个测试文件也想用结果 import 不到。解决办法是把共享的 fixture 定义到 conftest.py 中pytest 会自动识别同目录及子目录的 conftest.py不需要显式导入。另一种是 fixture 作用域冲突一个 session 级 fixture 依赖了一个 function 级 fixturepytest 会直接拒绝执行因为它无法在 session 开始时预知 function 级 fixture 的返回值。解决方法是把被依赖的 fixture 改成相同或更高级别的作用域。7.3 测试里连数据库连到了开发库这是集成测试里比较严重的安全事故。如果你的代码读取了 DATABASE_URL 环境变量而测试时没把它切到测试库那测试就会往真实开发库里写数据。我建议在 conftest.py 里强制覆盖环境变量import os os.environ[DATABASE_URL] sqlite:///test.db但更稳妥的做法是使用 pytest-env 插件在 pytest 启动时就把测试环境变量注入避免测试代码执行顺序导致的环境变量设置滞后。7.4 模拟时间如何测试“距离上次登录超过30天”的逻辑业务里很多逻辑依赖当前时间比如判断会话是否过期、计算某种频率。如果直接调用 datetime.now()测试结果会随着真实时间飘移难以断言精确行为。常见的做法是让被测代码接收一个“时间源”参数或者用 pytest-freezegun 插件来冻结时间from freezegun import freeze_time freeze_time(2024-06-01 12:00:00) def test_session_expired(): session create_session(days_ago31) assert is_session_expired(session) is Truefreezegun 会冻结 datetime.datetime.now 的返回让逻辑可以精确对齐到某个时间点。这是一个在写时间敏感业务时绕不开的利器。7.5 常见问题速查表现象可能原因解決方法collected 0 items测试文件名/函数名不符合 rulepytest --collect-only 查看fixture not foundfixture 定义在别的文件未放入 conftest移到 conftest.py测试跑得慢每个用例都初始化重型 DB使用 session/module 级 fixture 或缓存偶发失败测试间共享状态用 pytest-randomly 找顺序依赖浮点断言失败直接 比较浮点改用 pytest.approx测试污染了真实数据库环境变量指向开发库启动时强制覆盖测试库地址mock 不起作用打补丁的路径和实际引用路径不一致patch 对象使用的模块路径不是定义路径7.6 独家避坑技巧不要在测试函数里写逻辑处理测试代码应该一句话就能看懂“它在断言什么”。有些同事写测试为了省事在测试函数里套 for 循环、if 分支、临时变量计算最后才 assert 一个值。这样写的麻烦是当测试失败时你很难判断是断言逻辑错了还是被测代码错了。建议做到“三行式”风格准备数据、执行被测函数、断言结果。简单到让人一眼能看懂才是好的单元测试。若确实需要循环验证一组场景用 parametrize 参数化而不是在函数内手工循环。8. 个人经验与进一步扩展的方向聊了这么多我想把视角拉回“习惯养成”这件事上。测试不是写了就完事它是一个需要持续维护的资产。我最开始写测试时总觉得它在拖慢我的开发速度后来才意识到真正拖慢我的是一次次手动验证、一个个回归Bug、一次次凌晨上线前的心虚。测试和业务代码一样也需要重构随着业务变化过时的断言要更新冗余的 fixture 要合并覆盖率盲区要补充。把测试当成“一等公民”对待而不是发版前的应付差事这才是它在工程化体系里真正的价值。未来你可以往这几个方向深挖一步。一个是Test-Driven DevelopmentTDD先写失败的测试再写让测试通过的最简实现最后重构这种“红-绿-重构”的节奏会倒逼你设计更清晰、边界更明确的代码接口。另一个是测试覆盖率与变异测试变异测试能通过引入小“代码变异”来检查你的测试是否真的能发现变化比单纯看覆盖率更严格。如果你在做微服务或后端接口契约测试也值得了解它能在服务提供方和消费方之间维护API兼容性降低改动带来的连锁崩溃。对我来说从“不写测试”到“先写测试”是整个Python水平进阶的分水岭。这章内容覆盖了核心概念、工具选型、单测与集成测试的完整实操以及这些年我自己踩过的坑。希望你照着写完第一个测试套件后也能体验到那种“改代码不再战战兢兢”的踏实感。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进