为什么 pytest 取代了 unittest
Python 标准库自带 unittest,但写过的人都知道它啰嗦:要继承 TestCase 类、要用 self.assertEqual、要写一大堆样板代码。pytest 的设计哲学完全不同——写测试就像写普通函数,用 assert 语句,不需要类,不需要特殊方法名。
同样一个加法测试,unittest 要写十几行,pytest 只要三行:
# test_math.py
def test_add():
assert add(1, 2) == 3
pytest 的超能力在于:它自动发现所有 test_*.py 文件里的 test_ 开头的函数;失败时给出详细的上下文对比(哪个变量是什么值);fixture 系统让测试前置准备可以跨文件复用;插件生态极其丰富,覆盖率、mock、参数化都有现成方案。
测试不是负担,而是你改代码时的安全网。没有测试的重构等于裸奔,pytest 让这张网编得轻松。
- assertpytest 原生支持 Python assert,不需要 self.assertEqual。
- fixture用装饰器定义可复用的测试前置数据和环境。
- parametrize一个测试函数跑多组输入输出。
- mock替换外部依赖(数据库、API),让测试只测业务逻辑。
写第一个测试:断言与命名约定
安装 pytest 后,在项目里建一个 tests/ 目录,写 test_*.py 文件。pytest 的发现规则很简单:文件名以 test_ 开头,函数名以 test_ 开头,类名以 Test 开头。
# tests/test_calc.py
from myapp.calc import add, divide
def test_add_positive():
assert add(1, 2) == 3
def test_add_negative():
assert add(-1, -2) == -3
def test_divide_by_zero():
import pytest
with pytest.raises(ZeroDivisionError):
divide(1, 0)
运行 pytest 命令,pytest 会自动找到所有测试文件并执行。输出每个测试的通过/失败状态,失败时打印详细的变量对比。
几个常用断言模式:
| 场景 | 写法 |
|---|---|
| 相等 | assert a == b |
| 包含 | assert x in list |
| 布尔 | assert result is True |
| 抛异常 | with pytest.raises(SomeError) |
| 近似值 | assert abs(a - b) < 0.001 |
fixture:测试的前置准备复用
很多测试需要相同的前置数据——比如一个测试用的数据库连接、一个已登录的用户对象、一份测试用的 JSON。如果每个测试函数都手写一遍,代码会重复得让人发疯。fixture 就是解决这个问题的。
import pytest
from myapp.models import User
@pytest.fixture
def test_user():
"""返回一个测试用的用户对象,每个测试函数都能直接用"""
return User(name="测试用户", email="test@example.com")
def test_user_name(test_user):
assert test_user.name == "测试用户"
def test_user_email(test_user):
assert "@" in test_user.email
关键在于:测试函数的参数名如果和 fixture 函数名一样,pytest 会自动调用 fixture 并把返回值传进去。你不需要手动调用,只需要声明参数。
fixture 还可以指定作用域:@pytest.fixture(scope="module") 让整个文件只创建一次;scope="session" 整个测试会话只创建一次。对于建数据库连接这种昂贵操作,用 session 级 fixture 能大幅加快测试速度。
参数化测试:一次测多组输入
同一个函数,边界值、正常值、异常值都要测。写三个 test 函数复制粘贴太蠢了——pytest 的参数化装饰器让你一个函数跑多组用例:
import pytest
from myapp.calc import add
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
assert add(a, b) == expected
这段代码会跑四个测试用例,每组输入对应一次执行。pytest 会把每组参数的值显示在测试名里,失败时一眼就知道是哪组数据出了问题。
参数化特别适合验证业务规则:比如一个折扣计算函数,满 100 打九折、满 200 打八折、不满 100 不打折——把这些规则列成参数表,一个函数全覆盖。
mock:隔离外部依赖
你的函数可能要调数据库、发 HTTP 请求、读文件。但单元测试的目标是只测当前函数的逻辑,不测外部系统。外部系统应该被 mock 掉——假装它返回了预期结果,验证你的函数处理得对不对。
from unittest.mock import patch, MagicMock
from myapp.services import get_user_order_count
def test_get_user_order_count():
# mock 掉数据库查询,假装返回 5 条订单
with patch("myapp.services.db.query") as mock_query:
mock_query.return_value = [{"id": 1}, {"id": 2}, {"id": 3}]
result = get_user_order_count(user_id=42)
assert result == 3
# 验证确实调了数据库,且参数正确
mock_query.assert_called_once_with(user_id=42)
patch 把指定路径的函数替换成 MagicMock 对象,它记录每次调用的参数,你可以预设返回值。这样测试就不依赖真实数据库,跑得快、结果稳定、不需要准备测试数据。
mock 的核心原则:mock 你拥有的边界,不 mock 你正在测的函数。数据库、外部 API、时间函数这些是边界,mock 掉;业务逻辑函数本身不要 mock,要真跑。
覆盖率报告:测了多少才算够
写完测试,怎么知道测够了没有?覆盖率工具帮你统计:你的测试代码执行了源文件的百分之多少行。pytest 配合 pytest-cov 插件一键生成报告。
pip install pytest-cov
# 运行测试并生成覆盖率报告
pytest --cov=myapp --cov-report=term-missing
输出会列出每个文件的覆盖率百分比,以及哪些行没被测到。行覆盖率 80% 以上是健康项目的基本线,核心业务模块建议 90% 以上。
但要注意:覆盖率数字高不代表测试质量好。你可能写了很多 assert True 的无效测试——覆盖率 100% 但什么都没验证。真正重要的是:每个分支、每个边界条件、每个异常路径都有对应的断言。覆盖率只是手段,不是目标。
| 指标 | 含义 | 建议值 |
|---|---|---|
| 行覆盖率 | 被执行的代码行占比 | ≥ 80% |
| 分支覆盖率 | if/else 各分支是否都走到 | 核心模块 ≥ 90% |
| 函数覆盖率 | 被调用的函数占比 | ≥ 90% |
测试组织结构与 CI 集成
项目长大之后,测试文件怎么放是个问题。推荐的结构是:源码在 myapp/,测试在 tests/,目录结构镜像源码。
myapp/
calc.py
services.py
tests/
test_calc.py
test_services.py
conftest.py # 全局 fixture 放这里
conftest.py 是 pytest 的特殊文件:放在里面的 fixture 不需要 import,整个 tests 目录都能直接用。通用的数据库 fixture、测试客户端 fixture 都放这里。
CI 集成很简单:GitHub Actions 或 GitLab CI 里加一步 pytest --cov,每次提交自动跑测试。测试失败就拦住合并。这样代码仓库永远处于"可工作"状态——任何人 clone 下来跑一遍 pytest,全绿就是好的。
写测试的最佳时机是你写业务代码的同时,而不是上线前补。测试驱动开发(TDD)要求先写测试再写实现——虽然对大多数人来说有点反直觉,但它逼你先想清楚"这个函数到底该干什么"。