Python 单元测试入门:用 pytest 写出可靠测试

从 fixture、参数化到 mock 和 CI 排障,把 pytest 从「能跑通」练到「跑得稳」。

技术文章约 14 分钟174 次阅读

#这篇文章解决什么问题

测试的价值不在于「写了多少条」,而在于「改代码的时候敢不敢跑」。我最早写脚本从来不写测试,直到有一次重构一个计费函数,改完上线才发现两个边界条件被弄反了,线上算错了一周的单。从那以后,凡是会被反复改的逻辑,我都会先补测试。本文讲 pytest 里真正常用的东西:最小用例怎么写、浮点断言为什么不能直接 ==、fixture 与参数化怎么组织、外部依赖怎么在运行时隔离,以及「本地全绿、CI 挂掉」的三类常见原因。本文命令以 Python 3.13 + pytest 9.1 环境实测通过为例,除特别标注版本限制的选项外,pytest 7+ 均适用。

先说最基础的事实:测试的本质是行为契约与回归保护。你写一个测试,等于把函数在当前输入下的行为固化成一份可执行契约;之后每次改动代码,运行测试都在做同一件事——把新行为与旧契约逐条差分。差分一致,说明改动没有破坏既有行为,你可以放心合入;差分不一致,测试红掉,逼你在合入之前解释清楚「为什么行为必须变」。理解了这一点,测试的价值就不再取决于数量,而取决于它覆盖了多少你不想悄悄改变的行为。

#pytest 是怎么把测试跑起来的

写用例之前先弄清 pytest 的运行机制,后面很多「坑」都能从机制上解释:

  • 收集(collection):pytest 启动后递归扫描目录,默认收集文件名匹配 test_*.py 或 *_test.py 的模块;模块内再收集名字以 test_ 开头的函数,以及 Test 开头的类中以 test_ 开头的方法。收集到的每一条都是独立的「用例节点(test item)」,会被单独执行。
  • 断言重写(assertion rewriting):pytest 在导入测试模块时通过钩子改写 assert 语句,让断言失败时能直接输出左右操作数的具体值,而不是裸的 AssertionError。这是它对比 unittest 的 assertEqual 最主要的体验优势。
  • 用例隔离:每个用例节点由 pytest 单独调用,函数内的局部变量互不可见;跨用例共享唯一通道是 fixture(见下文)。这从机制上要求你把「每个用例独立可跑」当默认。

#最小用例:先让一个断言通过

被测函数是一个带校验和舍入的计费函数,先写成独立模块:

# pricing.py
def total_price(unit_price: float, quantity: int, discount: float = 0) -> float:
    if unit_price < 0 or quantity < 0:
        raise ValueError("价格和数量不能为负数")
    if not 0 <= discount <= 1:
        raise ValueError("折扣必须在 0 到 1 之间")
    return round(unit_price * quantity * (1 - discount), 2)

同目录放测试文件,文件名与函数名都要符合上面的收集规则:

# test_pricing.py
import pytest
from pricing import total_price


def test_total_price_with_discount():
    assert total_price(19.9, 2, 0.1) == 35.82

运行:

python -m pytest -q

预期输出(耗时与机器有关,结构一致即可):

.                                                                    [100%]
1 passed in 0.05s

-q 是 quiet,只输出一行摘要;-v 会逐条列出用例名。这里用 python -m pytest 而不是 pytest,是为了确保调用的是当前 Python 环境对应的版本,避免多环境时命令行入口指向别的解释器。

验证路径:故意改错一个参数(比如把 35.82 改成 35.83)再跑,应该看到一条明确失败信息,同时给出断言比较的两个值——这证明断言重写在工作;改回来后恢复全绿。

#浮点数为什么不能直接 == 比较

第一次写测试的人最容易在这里栽跟头。上面用例能通过,是因为 round() 恰好把 19.9 * 2 * 0.9 舍入到了与字面量 35.82 相同的浮点表示。但「恰好」不等于「可靠」:IEEE 754 二进制浮点数无法精确表示 0.1 这类十进制小数,0.1 + 0.2 在 Python 里是 0.30000000000000004。用一条命令当场验证:

python -c "print(0.1 + 0.2); print(0.1 + 0.2 == 0.3)"

预期输出:

0.30000000000000004
False

机制上,round(x, 2) 只是把结果舍入到最近的可表示浮点数,它不能消除「十进制小数在二进制下本来就是近似值」这一事实;跨 Python 版本、跨架构时舍入与尾差仍可能不同。所以有两条纪律:

  • 金额这类精确数值场景,运算层用标准库 decimal.Decimal,不要用 float 累加;
  • 断言层统一用 pytest.approx 按容差比较,而不是按位相等:
def test_with_approx():
    assert total_price(0.1, 3, 0.0) == pytest.approx(0.3)


def test_float_addition():
    assert 0.1 + 0.2 == pytest.approx(0.3)

pytest.approx 默认同时启用相对误差(rel=1e-6)与绝对误差下限(abs=1e-12),实际值与期望值的差距不超过容差即通过;需要收紧或放宽时显式传参,例如 pytest.approx(0.3, rel=1e-3)。

验证路径:把 test_float_addition 的断言改成 == 0.3 再跑,这个用例会失败,失败信息会同时给出两个数——这是观察「浮点近似」最直接的方式;total_price 那条改为 == 0.3 却能通过,恰好演示了「round() 能救一部分,但不值得依赖」。

#fixture:把「准备数据」从用例里拆出去

fixture 是 pytest 与 unittest 最大的体验差异。它解决测试里最常重复的一件事——准备前置数据与资源(数据库连接、临时文件、登录态、配置),并把「怎么造」从「怎么用」里拆掉,还能按作用域控制生命周期。

#fixture 本质上是依赖注入

从机制看,pytest 在执行用例前会扫描测试函数的参数名,去全局 fixture 注册表里找同名工厂,调用它并把返回值注入参数。所以 fixture 是**依赖注入(Dependency Injection)**的测试实现:用例只声明「我需要什么」(参数名),不关心「怎么造出来」(工厂里的逻辑):

import pytest

@pytest.fixture
def sample_order():
    return {"items": [{"price": 19.9, "qty": 2}], "discount": 0.1}


def test_order_total(sample_order):
    assert total_price(
        sample_order["items"][0]["price"],
        sample_order["items"][0]["qty"],
        sample_order["discount"],
    ) == 35.82

换一组数据、换一种登录态,只改 fixture,用例体不动。fixture 还可以依赖另一个 fixture(参数里声明即可),pytest 会按依赖图先构造被依赖的一方——需要会话级连接、模块级数据、函数级快照时,这是组织资源生命周期最干净的方式。

#scope:fixture 实例的生命周期

scope 决定「同一个 fixture 工厂的结果被缓存多久、复用于哪些用例」:

  • function(默认):每个用例重新调用工厂,实例互不共享,天然隔离;
  • module:同一模块内所有用例共享一次构造结果;
  • session:整个测试会话共享一次。

选择依据不是个人偏好,而是资源代价与共享风险的权衡:数据库连接、启动一次要几秒的重型客户端用 module/session 级缓存;普通数据一律 function 级,避免用例之间互相污染——共享实例一旦被某个用例改动,其他用例看到的就不再是干净的前置状态,测试就从「每用例独立」退化成「依赖执行顺序」。

验证路径:给 fixture 加一行 print("setup"),运行 python -m pytest --setup-show -q,观察每个用例前它被调用的次数;把 scope="module" 加上再跑,同一模块内 setup 只出现一次。

#临时文件用 tmp_path,不要自己造目录

需要写文件的用例,永远用 pytest 内置的 tmp_path,而不是自己拼 /tmp/xxx:

def test_write_and_read(tmp_path):
    target = tmp_path / "out.txt"
    target.write_text("hello")
    assert target.read_text() == "hello"

tmp_path 是 pathlib.Path 对象,每个用例自动拿到独立目录,测试结束后由 pytest 统一清理。机制上,pytest 为整个会话维护一个根临时目录(默认在系统临时目录下的 pytest-of-<用户名>/pytest-<编号>,可用 --basetemp 指定),每个用例在根目录下分到唯一子目录——这一个机制同时解决了三个问题:不污染源码目录、用例间天然隔离、执行顺序无关。你不需要也不应该手动清理它。

验证路径:在用例里断言 tmp_path.exists();或故意不清理、跑完后去系统临时目录看目录结构,确认每个用例一个独立目录。

#参数化:用数据驱动展开用例

同一逻辑多组输入,不要复制粘贴用例,用 @pytest.mark.parametrize:

@pytest.mark.parametrize("unit, qty, expected", [
    (10, 0, 0),
    (8.5, 2, 17.0),
    (3.333, 3, 10.0),
])
def test_normal_cases(unit, qty, expected):
    assert total_price(unit, qty) == pytest.approx(expected)


@pytest.mark.parametrize("args", [(-1, 1), (10, -1), (10, 1, 1.5)])
def test_rejects_invalid_input(args):
    with pytest.raises(ValueError):
        total_price(*args)

机制上,参数化发生在收集阶段:pytest 把装饰器里的每组参数展开成一个独立用例节点,节点 id 带参数值(例如 test_normal_cases[8.5-2-17.0])。因此:

  • 失败报告精确到「哪一组参数」——python -m pytest -q 的失败摘要会直接列出 test_normal_cases[8.5-2-17.0],而不是笼统的「这个用例挂了」;
  • 可以用 -k 按关键词过滤运行,例如 python -m pytest -q -k "normal_cases" 只跑这三组展开用例;
  • 每组参数单独计为一条用例。

pytest.raises(ValueError) 是异常契约:上下文里的代码必须抛 ValueError,不抛就失败;要精确到异常消息可加 match=(接受正则)。它与参数化组合起来,正好覆盖边界值的两个方向:合法输入返回预期值、非法输入抛预期异常。

一个工程判断:参数化数据本身也是要维护的资产。数据应当来自边界与等价类的分析(0、负数、上界、下界、典型值),而不是为了凑数堆重复用例;如果两组数据永远同生共死,它们大概率只是一条用例。

验证路径:python -m pytest --collect-only -q 能看到展开后的全部节点与参数 id;故意让第二组参数失败再跑,失败信息会明确标识 [8.5-2-17.0]。

#外部依赖:用 monkeypatch 在运行时隔离

测试里最忌讳真的去请求外部 API、读写生产数据库:慢、不稳定、还可能产生真实费用。正确做法是把外部调用在运行时替换掉。

先看被测代码——它通过模块级名字 requests.get 发起请求:

import requests

def fetch_price(symbol: str) -> float:
    resp = requests.get(f"https://api.example.com/price/{symbol}", timeout=5)
    resp.raise_for_status()
    return resp.json()["price"]

用 monkeypatch 把外部调用替换成假响应:

def test_fetch_price(monkeypatch):
    class FakeResp:
        def raise_for_status(self):
            pass

        def json(self):
            return {"price": 12.5}

    monkeypatch.setattr("pricing.requests.get", lambda *a, **k: FakeResp())
    assert fetch_price("DEMO") == 12.5

#monkeypatch 的机制:运行时替换 + 自动还原

monkeypatch.setattr("pricing.requests.get", fake) 做的事情是:解析字符串路径,把 pricing.requests 模块(也就是 pricing 模块命名空间里名字 requests 指向的那个模块对象)上的 get 属性临时替换为 fake;该用例结束时在 teardown 阶段自动还原——被替换的属性恢复原值、被新增的属性删除。所以不需要手动清理,补丁也不会泄漏到其他用例。如果只想在代码块内临时补丁,可以用 with monkeypatch.context() as m:,退出代码块即还原。

一个必须写对的细节:补丁要打在名字真正被查找的地方。本例中 fetch_price 每次调用时执行 requests.get(...),requests 这个名字来自 pricing 模块的全局命名空间(因为写的是 import requests),get 是模块对象上的属性,名字查找发生在调用时——所以打 "pricing.requests.get" 与打 "requests.get" 指向同一个模块属性,都有效。但一旦代码写成 from requests import get,get 就变成了 pricing 模块全局命名空间里的函数对象本身,此时替换 requests.get 不再影响它,必须打 "pricing.get"。稳妥的习惯是:明确被测模块的名字解析方式,把路径写在查找发生的位置上(模块内路径),这也是上面示例选择 "pricing.requests.get" 的原因。

验证路径:把 monkeypatch.setattr 那行注释掉再跑,用例会真的发起网络请求(在无外网环境里直接失败)——这正好演示了不 mock 的后果;全绿则说明补丁生效且已还原。

#什么时候不该 mock

mock 隔离的是「外部世界」,不是「被测逻辑」。集成测试(真的验证与真实服务交互、真实数据库写入)是另一类测试,不要用 monkeypatch 把被测系统自己也替换掉——那测的不是系统,而是你的假想。单元测试用 mock 保证快速、确定、可重复;端到端验证交给专门的集成测试阶段。

#CI 里翻车的三类问题

「本地全绿、CI 挂掉」我遇到过三次,原因各不相同,但都能从机制上解释:本地与 CI 的环境差异,会暴露那些「依赖环境而非依赖代码」的用例。

#1. 时区与语言环境

本地系统时区是 Asia/Shanghai,CI 容器默认 UTC,代码里如果拿本地时区生成时间戳,断言字符串就崩。机制上,datetime.now() 返回的是不带时区信息的 naive 时间,strftime 用什么时区完全取决于运行环境。正确做法是构造时间时显式指定时区,用标准库 zoneinfo(Python 3.9+;更早版本用第三方库 pytz):

from datetime import datetime, timezone
from zoneinfo import ZoneInfo


def format_ts(ts: datetime) -> str:
    return ts.astimezone(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")


def test_format_ts():
    ts = datetime(2026, 8, 21, 10, 0, 0, tzinfo=timezone.utc)
    assert format_ts(ts) == "2026-08-21 18:00:00 +0800"

ZoneInfo("Asia/Shanghai") 读取的是系统时区数据库(tzdata);极简容器镜像可能没装,需要显式安装 tzdata 包。

验证路径:在 UTC 环境下跑这条用例——TZ=UTC python -m pytest -q——它仍然通过,因为时区是显式指定的,与系统环境无关。

#2. 随机顺序与共享状态

两个用例共用同一个模块级变量,一个改了另一个读,单跑都过、合跑就挂,且挂的时机取决于执行顺序,最难排查。机制上,模块级变量是进程内共享的,任何用例的写操作都会留下痕迹。对策:

  • fixture 返回的对象尽量给用例「只读」的副本(dict(copy)、不可变数据结构);
  • 可变状态必需的场景,用 function 级 fixture 每次新建,或用 fixture 的 yield 形式在用例结束后清理;
  • 黄金标准是让每个用例都不依赖「别人先跑过」。

验证路径:用第三方插件 pytest-random-order 打乱用例顺序跑(python -m pytest -q --random-order,注意这是需要安装的插件);不想引入插件就手动调整用例定义顺序。结果发生变化,就说明存在共享状态污染。

#3. 网络不通

CI 沙箱常没有外网。凡是会发请求的用例必须 mock(见上一节);确实需要真实网络的用例,用自定义标记标出来,默认排除:

import pytest

@pytest.mark.network
def test_hits_real_api():
    # 只有本地手动跑,CI 不执行
    ...
# pytest.ini
[pytest]
markers =
    network: 需要真实网络访问,CI 中默认排除

本地手动跑:python -m pytest -m "network" -q;CI 里排除:python -m pytest -m "not network" -q。不注册标记也能运行,但 pytest 会报 PytestUnknownMarkWarning 警告;注册是让标记成为团队可查的约定。

#目录组织与 CI 收尾

测试文件和被测代码放一起还是分开,团队定一个规则即可。我更习惯 tests/ 目录 + tests/conftest.py 放共享 fixture:

project/
├── pricing.py
└── tests/
    ├── conftest.py
    └── test_pricing.py

conftest.py 里的 fixture 对同一目录树下的所有用例自动可见,无需 import,适合放共享数据、共享补丁。

一个新人常踩的坑:tests/ 下如果没有 __init__.py,pytest 会把 tests/ 自身加入 sys.path,from pricing import total_price 会报 ModuleNotFoundError: No module named 'pricing'。两种可复现的解法:一是 tests/ 下放一个空的 __init__.py,让 pytest 把项目根目录加入 sys.path;二是在 pyproject.toml 里给 pytest 配 pythonpath(pytest 7.0+):

[tool.pytest.ini_options]
pythonpath = ["."]

CI 收尾命令:

python -m pytest tests/ -q -m "not network" --maxfail=1

--maxfail=1 让 CI 在第一个失败就停下来,日志不至于刷屏;失败定位交给断言重写与参数化节点 id。

#什么时候不该用单元测试

单元测试不是万能的。一次性脚本、纯展示逻辑、改了就扔的代码,写测试的收益低于成本;已经被更贵的集成测试覆盖的纯转发层,也不必重复。判断标准回到开篇那句:只有会被反复改的逻辑,才值得用测试把行为钉死。如果一段代码写完就没再动过,它的回归风险很低,测试的边际价值也低。下一步可以从 pytest --help 看输出相关的选项,或给工程加上覆盖率统计(第三方插件 pytest-cov),把「敢不敢跑」变成「跑完知道自己测到了哪里」。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

Python 单元测试入门:用 pytest 写出可靠测试 · LXH·BLOG