3. 单元测试基础与pytest实战
后端模块的第一组可靠测试,目标不是“把覆盖率刷上去”,而是让测试成为可重复执行、失败原因清晰、修改代码后能快速反馈的安全网。pytest 之所以成为 Python 后端测试的主流选择,核心在于它用普通函数和原生 assert 就能表达测试意图,同时通过夹具(fixture)和参数化(parametrize)把重复的准备工作与多组输入收敛成可维护的结构。本节围绕一个典型的后端函数展开:一个用于校验并规范化用户注册数据的函数,它需要判断用户名长度、邮箱格式、密码强度,并返回规范化后的字典或抛出异常。
测试函数与断言:从“能跑”到“能说明问题”
pytest 的测试函数命名必须以 test_ 开头,文件通常命名为 test_*.py 或 *_test.py。测试函数内部使用 Python 原生 assert,pytest 会重写断言表达式,在失败时输出左右两侧的实际值,而不是只抛出一个 AssertionError。例如被测函数 normalize_username(raw) 要求去除首尾空白、转小写、长度在 3 到 20 之间,否则抛出 ValueError。对应的测试可以写成:
# app/validators.py
import re
def normalize_username(raw: str) -> str:
name = raw.strip().lower()
if not (3 <= len(name) <= 20):
raise ValueError("username length must be 3-20")
if not re.fullmatch(r"[a-z0-9_]+", name):
raise ValueError("username contains illegal characters")
return name
# tests/test_validators.py
import pytest
from app.validators import normalize_username
def test_normalize_username_strips_and_lowercases():
assert normalize_username(" Alice_01 ") == "alice_01"
def test_normalize_username_rejects_short_name():
with pytest.raises(ValueError, match="length"):
normalize_username("ab")
这里有两个关键点。第一,assert 后面直接写期望关系,不要用 assert result == True 这类冗余写法,直接写 assert result 或 assert result is True 更准确。第二,异常测试用 pytest.raises 作为上下文管理器,并通过 match 参数匹配异常消息中的关键片段,避免“只要抛异常就算通过”的宽松判断。如果被测代码没有抛出异常,pytest.raises 会直接让测试失败,这正是我们需要的保护。
夹具:把准备与清理从测试逻辑中剥离
当多个测试都需要构造同一份输入、启动同一个临时数据库或准备同一个客户端时,复制粘贴会迅速失控。pytest 的 fixture 用 @pytest.fixture 装饰一个函数,测试函数通过参数名声明依赖,pytest 负责注入。默认作用域是 function,即每个测试函数执行一次;也可以设为 module、class、session 来复用昂贵资源。对于注册数据校验,可以准备一个合法的基准数据夹具:
@pytest.fixture
def valid_payload():
return {
"username": "Alice_01",
"email": "alice@example.com",
"password": "Str0ngPass!",
}
def test_valid_payload_passes(valid_payload):
result = validate_registration(valid_payload)
assert result["username"] == "alice_01"
assert result["email"] == "alice@example.com"
夹具的价值在于“一处定义、多处复用、按需注入”。如果某个测试需要修改基准数据,可以在测试内部对返回的字典做浅拷贝后再改,避免污染其他测试。对于需要清理的资源,例如临时文件或数据库连接,使用 yield 把清理逻辑放在 yield 之后,pytest 会保证即使测试失败也会执行清理。后端测试中常见的 tmp_path、monkeypatch、capsys 都是 pytest 内置夹具,直接声明即可使用,不需要自己实现。
参数化:用一张表覆盖多组输入与边界
参数化是 pytest 最实用的能力之一。它把“同一逻辑、不同输入输出”的测试压缩成一个函数,并在报告中为每组参数生成独立的测试用例。对于用户名校验,边界值、非法字符、大小写混合、超长输入都值得覆盖:
@pytest.mark.parametrize(
"raw, expected",
[
(" Alice_01 ", "alice_01"),
("BOB", "bob"),
("user_123", "user_123"),
],
)
def test_normalize_username_valid(raw, expected):
assert normalize_username(raw) == expected
@pytest.mark.parametrize(
"raw, message",
[
("ab", "length"),
("a" * 21, "length"),
("bad name", "illegal"),
("用户名", "illegal"),
],
)
def test_normalize_username_invalid(raw, message):
with pytest.raises(ValueError, match=message):
normalize_username(raw)
参数化的参数名用字符串声明,多个参数用逗号分隔,数据用列表或元组提供。每组数据在 pytest 输出中会显示为 test_normalize_username_valid[ Alice_01 -alice_01] 这样的用例 ID,失败时能立刻定位是哪一组输入出了问题。如果参数组合较多,可以用 pytest.param(..., id="...") 给关键用例起可读的名字,例如 id="too_long",让报告更易读。
把第一组测试写可靠:结构、边界与失败可读性
可靠测试有三个可操作的判断标准。第一,测试之间相互独立:不依赖执行顺序,不共享可变状态。夹具默认每个测试函数重新执行,正是为了满足这一点;如果确实需要共享只读数据,再考虑提升作用域。第二,覆盖边界与异常路径:合法值、最小值、最大值、越界值、非法字符、空字符串、None 都应至少各有一组参数。第三,失败信息可读:断言尽量比较具体字段,而不是比较整个大字典;异常测试用 match 限定消息;参数化用例用 id 标注关键场景。
对于注册校验这类函数,一个实用的测试清单可以按下面的维度组织:
| 维度 | 示例输入 | 期望结果 |
|---|---|---|
| 正常值 | " Alice_01 " | 返回 "alice_01" |
| 最小长度 | "abc" | 返回 "abc" |
| 最大长度 | 20 个字符 | 返回规范化结果 |
| 过短 | "ab" | 抛出 ValueError,消息含 length |
| 过长 | 21 个字符 | 抛出 ValueError,消息含 length |
| 非法字符 | "bad name" | 抛出 ValueError,消息含 illegal |
| 非 ASCII | "用户名" | 抛出 ValueError,消息含 illegal |
运行方式也很直接:在项目根目录执行 pytest -q 得到简洁结果,执行 pytest -v 查看每个参数化用例的完整 ID,执行 pytest -k "username" 只跑名称匹配的测试。如果某个用例失败,pytest 会打印断言两侧的实际值、异常堆栈和用例 ID,定位成本远低于自己写 print 调试。把这些测试提交到版本库后,每次修改校验逻辑都能在几秒内得到反馈,这才是后端模块第一组可靠测试的真正价值。
valid_payload 夹具返回字典,再用 @pytest.mark.parametrize("field, value", [("username", "ab"), ("email", "not-an-email")]) 覆盖不同字段的非法值,测试函数内部只负责修改字段并断言异常,避免为每个字段重复写一遍准备逻辑。