开篇:测试是代码的“安全网”,不是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 可以依赖其他 fixture,pytest 会根据测试函数的参数自动解析依赖树。这种机制非常适合构建有层次的环境准备逻辑,比如:
@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 的返回值是 None,create 的返回值是 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"}, format="json") assert response.status_code == 201加了 @pytest.mark.django_db 装饰器后,pytest-django 会为这个测试用例创建独立的事务,测试结束自动回滚,数据库不会留下任何残留数据。这个机制解决了我前面说的“集成测试数据污染”问题,是我强烈推荐大家深入了解的。如果是 FastAPI,可以用 fastapi.testclient 直接发起测试请求,用法和 requests 一模一样。
5.5 覆盖率报告:让“测试测了什么”可视化
测试写了,效果好不好,覆盖率确实是直观的参考。安装 pytest-cov 后,运行:
pytest --cov=user_service --cov-report=term-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 found
fixture 报错通常是两种原因。一种是你把 fixture 定义在一个测试文件里,但另一个测试文件也想用,结果 import 不到。解决办法是把共享的 fixture 定义到 conftest.py 中,pytest 会自动识别同目录及子目录的 conftest.py,不需要显式导入。另一种是 fixture 作用域冲突:一个 session 级 fixture 依赖了一个 function 级 fixture,pytest 会直接拒绝执行,因为它无法在 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_ago=31) assert is_session_expired(session) is Truefreezegun 会冻结 datetime.datetime.now 的返回,让逻辑可以精确对齐到某个时间点。这是一个在写时间敏感业务时绕不开的利器。
7.5 常见问题速查表
| 现象 | 可能原因 | 解決方法 |
|---|---|---|
| collected 0 items | 测试文件名/函数名不符合 rule | pytest --collect-only 查看 |
| fixture not found | fixture 定义在别的文件未放入 conftest | 移到 conftest.py |
| 测试跑得慢 | 每个用例都初始化重型 DB | 使用 session/module 级 fixture 或缓存 |
| 偶发失败 | 测试间共享状态 | 用 pytest-randomly 找顺序依赖 |
| 浮点断言失败 | 直接 == 比较浮点 | 改用 pytest.approx |
| 测试污染了真实数据库 | 环境变量指向开发库 | 启动时强制覆盖测试库地址 |
| mock 不起作用 | 打补丁的路径和实际引用路径不一致 | patch 对象使用的模块路径,不是定义路径 |
7.6 独家避坑技巧:不要在测试函数里写逻辑处理
测试代码应该一句话就能看懂“它在断言什么”。有些同事写测试,为了省事,在测试函数里套 for 循环、if 分支、临时变量计算,最后才 assert 一个值。这样写的麻烦是,当测试失败时,你很难判断是断言逻辑错了还是被测代码错了。建议做到“三行式”风格:准备数据、执行被测函数、断言结果。简单到让人一眼能看懂,才是好的单元测试。若确实需要循环验证一组场景,用 parametrize 参数化,而不是在函数内手工循环。
8. 个人经验与进一步扩展的方向
聊了这么多,我想把视角拉回“习惯养成”这件事上。测试不是写了就完事,它是一个需要持续维护的资产。我最开始写测试时,总觉得它在拖慢我的开发速度,后来才意识到真正拖慢我的是一次次手动验证、一个个回归Bug、一次次凌晨上线前的心虚。测试和业务代码一样,也需要重构:随着业务变化,过时的断言要更新,冗余的 fixture 要合并,覆盖率盲区要补充。把测试当成“一等公民”对待,而不是发版前的应付差事,这才是它在工程化体系里真正的价值。
未来你可以往这几个方向深挖一步。一个是Test-Driven Development(TDD),先写失败的测试,再写让测试通过的最简实现,最后重构,这种“红-绿-重构”的节奏会倒逼你设计更清晰、边界更明确的代码接口。另一个是测试覆盖率与变异测试,变异测试能通过引入小“代码变异”来检查你的测试是否真的能发现变化,比单纯看覆盖率更严格。如果你在做微服务或后端接口,契约测试也值得了解,它能在服务提供方和消费方之间维护API兼容性,降低改动带来的连锁崩溃。
对我来说,从“不写测试”到“先写测试”,是整个Python水平进阶的分水岭。这章内容覆盖了核心概念、工具选型、单测与集成测试的完整实操,以及这些年我自己踩过的坑。希望你照着写完第一个测试套件后,也能体验到那种“改代码不再战战兢兢”的踏实感。