写单元测试这件事,在很多项目里都是最容易被忽略的一环。大家总觉得功能都跑通了,测试可有可无。但等到需求改动、代码重构的时候,心里那根弦就绷紧了。今天咱们就从实际使用的角度,把 pytest 这个 Python 世界里最趁手的测试工具拆开来看一看,让不同基础的朋友都能写出既好看又管用的测试代码。本文所有示例统一使用 Python 3.10 + pytest 7.x 这个技术栈。
一、为什么非要有单元测试
单元测试说白了,就是给代码里一个个独立的函数或者方法单独“体检”。我们每天写的代码,会随着时间越来越复杂。今天改了一个逻辑,明天可能就把另一个功能带崩了。如果没有一套自动化的测试在背后盯着,这种问题就像暗雷一样,等上线了才会炸出来。
单元测试最直接的价值,是给了我们一个“安全网”。改完代码跑一遍测试,如果全绿,心里就有底。而且写测试这件事本身也能逼着你把代码写得更容易使用、更清晰。比如一个函数依赖了外部网络、数据库,你为了测它,就得想办法把依赖拆开,结果让代码结构也变得更健康了。
那么选什么测试框架呢?pytest 在 Python 社区里几乎成了公认的标准。它上手简单,功能却非常强。和 Python 自带的 unittest 相比,pytest 写起来更自然,不需要写一堆类和方法,就是一个普通的函数加上 assert 断言就行。同时插件生态特别丰富,从覆盖率到 mock 一应俱全,这就是我们选它的理由。
二、先搭一个最小的pytest环境
安装 pytest 非常简单,用 pip 就行。建议在虚拟环境里操作,避免把系统环境搞乱。
pip install pytest
装完之后,我们来写一个最简单的函数和一个对应的测试。这里我们建一个项目目录,里面放两个文件。
demo/
├── calc.py
└── test_calc.py
calc.py 里是一个加法函数:
# calc.py
def add(a, b):
"""两个数字相加"""
return a + b
注意这个函数本身很简单,但我们要做的是验证它对各种输入都能给出正确结果。
接着写测试文件:
# test_calc.py
from calc import add
def test_add():
"""验证加法函数的基本行为"""
result = add(2, 3)
assert result == 5 # 断言结果等于 5
这里有一个非常重要的约定:测试文件名要以 test_ 开头,测试函数名也要以 test_ 开头。pytest 会自动发现并执行这些文件里的测试函数。如果你用了类,那么类名应该以 Test 开头,方法名同样以 test_ 开头。这是 pytest 默认的发现规则,我们最好遵守,否则跑起来会很别扭。
在终端里运行:
pytest -v
-v 的作用是让输出更详细,你会看到一共跑了几个测试、每个测试叫什么、是否通过。如果一切正常,你会看到绿色的 PASSED。
三、fixture:测试数据的魔法师
很多测试需要准备数据、连接资源、清理环境。如果每个测试里都重复做一遍这些事,代码会变得又臭又长。pytest 的 fixture 就是为了解决这个问题的。
fixture 可以理解成一个“物料准备函数”,它把你的测试数据和测试本身分离开来。举例来说,我们有一个接口函数,需要传入一个字典对象:
# process_demo.py
def process_score(score_dict):
"""根据名字更新分数"""
score_dict["score"] += 1
return score_dict
我们希望测试的时候,每次都能有一个初始化的字典。用 fixture 来做:
# test_fixture_demo.py
import pytest
@pytest.fixture
def sample_score():
"""给测试准备一个初始分数字典"""
data = {"name": "张三", "score": 90}
return data
def test_process_score(sample_score):
"""验证分数加一的行为"""
result = process_score(sample_score)
assert result["score"] == 91
测试函数只需要在参数里声明 sample_score,pytest 就会自动调用这个 fixture 函数,把返回值传给测试。我们不需要在测试里再去构造数据,清爽很多。
fixture 还能控制作用域。比如有的数据只适合在某个模块里准备一次,可以用 scope 参数。默认是函数级,也就是说每个测试函数都会重新执行一次 fixture。另外还有一个 autouse 选项,可以让所有测试自动使用某个 fixture,不需要显式传参。不过我的建议是,显式声明比自动使用更清晰,所以除非特殊情况,否则不要随便用 autouse。
再看一个更接近实际的例子:测试临时文件操作。pytest 内置了一个 tmp_path fixture,可以直接用。
# test_tmp_demo.py
def write_file(path, content):
"""把内容写入文件"""
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return path
def test_write_file(tmp_path):
"""验证写文件功能"""
target = tmp_path / "demo.txt" # tmp_path 会自动生成临时目录
write_file(str(target), "hello")
assert target.read_text(encoding="utf-8") == "hello"
这里我们不需要手动清理临时目录,pytest 会帮我们处理掉,非常省心。
四、参数化:一条测试跑遍多种情况
如果我们要测的输入有好几组,比如加法函数有十组数据,难道要写十个测试函数?当然不。pytest 的参数化功能可以用一组数据驱动同一个测试函数,让它跑多次。
# test_param_demo.py
import pytest
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add_with_params(a, b, expected):
"""参数化测试加法函数"""
result = a + b
assert result == expected
注意这里我们直接用了加法运算,也可以复用之前定义好的 add 函数。parametrize 里的第一个字符串 "a,b,expected" 是参数名,逗号分隔。后面的列表里每个元组对应一组参数。运行这个测试时,pytest 会生成四条测试用例,如果其中一条失败,其他依然会继续执行。
参数化特别适用于数据量比较大的场景。比如接口测试中,同样的接口要验证几十组不同字段的请求,参数化能让你的代码量减少一大半。
五、异常和边界情况怎么测
好的测试不光要看正常情况,更要验证异常情况是否被正确处理。比如一个除法函数,除数为 0 时应该抛出异常。pytest 提供了 pytest.raises 来捕捉异常。
# divide_demo.py
def divide(a, b):
"""两个数相除"""
if b == 0:
raise ValueError("除数不能为0,请检查")
return a / b
测试异常:
# test_divide_demo.py
import pytest
def test_divide_zero():
"""验证除数为零时抛出异常"""
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "除数不能为0" in str(exc_info.value)
exc_info.value 就是捕获到的异常对象,我们可以对它做进一步的断言。这种方式比 try/except 写起来更简洁,也更符合测试的语义。异常测试经常会让人忽略,但恰恰是它们能在关键时刻帮你堵住 bug。
六、巧用插件:mock 和覆盖率
pytest 本身很克制,很多高级功能都靠插件来实现。这里我推荐两个几乎必备的插件:pytest-mock 和 pytest-cov。
先安装它们:
pip install pytest-mock pytest-cov
6.1 mock:把外部依赖“藏起来”
我们经常要测试的函数会调用其他模块,比如发送邮件、请求外部 API、读写数据库。这些操作在单测里要么慢,要么不可控。mock 的核心思想是用一个替身对象来替换掉真实的依赖,然后验证这个替身有没有被正确调用。
假设我们有一个用户通知模块:
# notify.py
def send_email(address, content):
"""真实场景下,这里会连接邮件服务器发送邮件"""
raise NotImplementedError("邮件服务尚未接入")
def notify_user(user_email):
"""给用户发送欢迎邮件"""
send_email(user_email, "欢迎加入我们!")
我们想测试 notify_user 是否正确调用了 send_email,但又不想真的发邮件。这时候用 pytest-mock 很方便:
# test_notify_demo.py
from notify import notify_user
def test_notify_user(mocker):
"""验证通知函数的调用逻辑"""
mock_send = mocker.patch("notify.send_email") # 替换掉真正的 send_email
notify_user("someone@example.com")
mock_send.assert_called_once_with("someone@example.com", "欢迎加入我们!")
注意 mocker 是 pytest-mock 提供的一个 fixture,不需要自己创建。mocker.patch 的第一个参数是目标函数的完整路径,这个路径要和我们代码中调用时的模块路径一致。比如 notify.py 里调用的是自己模块内的 send_email,所以这里应该 patch 为 "notify.send_email",而不是 "notify.notify_user"。
mock 能解决大多数外部依赖,但是别滥用。如果某个函数内部有极其复杂的逻辑,你全 mock 掉了,那也是在骗自己。所以 mock 应该集中在“边界”位置,只模拟那些外部、不稳定、速度慢的部分。
6.2 覆盖率:知道哪些代码没测过
覆盖率能够告诉我们测试代码覆盖了被测代码的多少行。当然,覆盖率不是越高越好,但它能帮我们发现完全没测到的分支。
用 pytest-cov 可以很容易地监控覆盖率:
pytest --cov=calc --cov-report=html
这里的 --cov=calc 表示我们要统计 calc 这个模块的覆盖率。运行完之后会在当前目录生成一个 htmlcov 文件夹,用浏览器打开里面的 index.html 就能看到哪些行没有被执行。如果你想在终端直接看摘要,可以不加 html:
pytest --cov=calc
你会看到类似这样的输出(注意,这里只是一个示意):
Name Stmts Miss Cover
---------------------------
calc.py 5 1 80%
覆盖率只是一个参考指标。追求 100% 覆盖率很容易让人走偏,因为有些防御性代码分支很难触发。我个人的经验是,核心业务代码覆盖率能到 80% 以上就可以了,剩下的重点看有没有遗漏的关键逻辑。
七、测试文件怎么组织更舒服
项目一大,测试文件如果随便乱放很快会变得混乱。常见的做法是把测试放在独立的 tests 目录里,与被测模块分开。然后利用 conftest.py 来共享 fixture。
一个典型的项目结构像这样:
project/
├── app/
│ ├── __init__.py
│ ├── calc.py
│ └── notify.py
├── tests/
│ ├── conftest.py
│ ├── test_calc.py
│ └── test_notify.py
└── requirements.txt
conftest.py 里的 fixture 默认对整个 tests 目录下的所有测试生效。所以你可以在里面定义一些通用的测试数据、临时资源等,而每个测试文件里只需要专注于各自的测试逻辑。
另外我还建议在测试文件中使用命名清晰的测试函数。比如 test_add_positive_numbers、test_divide_by_zero 这样,一看名字就知道在测什么。如果以后某个测试挂了,你能快速定位,不需要去读函数体内部。
八、注意事项和常见坑
测试虽然写起来爽,但有些坑还是要避开。
第一,不要去测试实现细节。比如你测试一个对象调用了哪些私有方法,或者某个临时变量名称是什么,这些一重构就碎。应该站在使用者的角度,只关心输入和输出以及对外部可见的行为。
第二,测试之间不要有依赖顺序。每个测试都应该是独立的,不能依赖前面某个测试的执行结果。如果你发现一个测试必须在另一个测试之后运行,那这多半是设计出了问题。
第三,注意浮点数的比较。比如 0.1 + 0.2 == 0.3 在 Python 里是 False。测试浮点结果时,应该用 pytest.approx。
# test_float_demo.py
import pytest
def test_float():
assert (0.1 + 0.2) == pytest.approx(0.3)
第四,不要为了跑得更快而跳过真实的集成测试。单元测试只验证单个单元,但模块之间的协作还需要靠集成测试来把关。pytest 也可以写集成测试,但是这两种测试的目的不同,别混为一谈。
第五,测试代码也是代码,同样需要维护。不要因为测试文件多就不敢重构,必要的时候可以像写业务代码一样仔细。
九、pytest 的优缺点和应用场景
优点方面,pytest 最大的好处是表达自然。你不需要会复杂的测试框架知识,只要会写函数和 assert,你就能写测试。它的插件体系也极其丰富,几乎你能想到的测试需求都有对应插件,比如并行执行、随机顺序、超时控制等等。再者,pytest 对 CI(持续集成)的支持非常好,随便一个自动化流程里都能轻松接入。
缺点是,pytest 的隐式魔法比较多。比如 fixture 的依赖解析、conftest 的作用域,这些对初学者来说有时候会有点困惑。另外,如果项目很大,测试数量上万,pytest 的内存占用和运行速度可能会变得不理想,需要额外使用如 pytest-xdist 之类的插件来并行加速。
说到应用场景,pytest 几乎可以覆盖所有 Python 项目的测试需求。从数据分析脚本到 Web 后端,从算法模型到命令行工具,只要可以用 Python 写,就能用 pytest 来测。特别是与 web 框架结合,比如 FastAPI、Flask 等,pytest 配合 fixture 和 mock 可以很方便地模拟请求、数据库会话。
十、总结
写测试不是一时兴起的事,而是让自己代码更可靠的投资。pytest 能帮我们高效地做单元测试,但真正重要的还是测试意识和设计思路。先从最简单的函数开始写起,把常用的 fixture、参数化、异常测试和覆盖率用起来,慢慢你就会发现,有了测试之后,改代码不再是一件心惊胆战的事。遇到问题别怕,多写、多跑、多琢磨,测试也会越写越顺手。
Comments