技术文章2025 年 9 月 11 日约 4 分钟

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

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

测试的价值不在于「写了多少条」,而在于「改代码的时候敢不敢跑」。我最早写脚本从来不写测试,直到有一次重构一个计费函数,改完上线才发现两个边界条件被弄反了,线上算错了一周的单。从那以后,凡是会被反复改的逻辑,我都会先补测试。这篇文章讲 pytest 里真正常用的东西,以及我踩过的坑。

#先跑起来:一个最小用例

python
# 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)
python
# 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

跑一下:

bash
python -m pytest -q
# ........                                                                  [100%]
# 1 passed in 0.03s

这里有个第一次写测试的人最容易踩的坑:浮点数不要直接 == 比较。round() 能救一部分,但跨 Python 版本、跨平台时浮点尾差仍然存在。金额这种敏感场景,要么用 Decimal 做运算,要么断言用 pytest.approx:

python
def test_with_approx():
    assert total_price(0.1, 3, 0.0) == pytest.approx(0.3)

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

fixture 是 pytest 和 unittest 最大的体验差异。测试需要数据库连接、临时文件、登录态时,fixture 把这些准备工作集中管理,还能控制作用域:

python
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 的 scope 决定复用范围:function(默认,每个用例都新建)、module(模块内复用一次)、session(整个测试会话一次)。连接数据库这种贵的资源用 session/module 级,普通数据用 function 级,避免用例之间互相污染。

临时文件永远用内置的 tmp_path,而不是自己造 /tmp/xxx 目录:

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

tmp_path 每个用例自动是独立目录,跑完自动清理,用例之间天然隔离,测试顺序无关——这是「测试不依赖执行顺序」最省事的实现方式。

#参数化:别复制粘贴用例

同一逻辑多组输入,用 @pytest.mark.parametrize,失败时定位精确到参数:

python
@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.raises(ValueError) 断言「这段代码必须抛 ValueError」,不抛就失败。参数化数据写多了之后要注意:数据本身也是要维护的资产,别为了凑数堆一堆重复用例。

#外部依赖:fixture + monkeypatch 隔离

测试里最忌讳真的去请求外部 API、读写生产数据库。正确做法是把外部调用 mock 掉:

python
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"]

def test_fetch_price(monkeypatch):
    class FakeResp:
        def raise_for_status(self):
            pass
        def json(self):
            return {"price": 12.5}

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

monkeypatch 的好处是自动还原:用例结束就把 requests.get 换回去,不用手动清理。注意 mock 的路径要打在实际被调用的模块上(模块内名字),比如 "pricing.requests.get",别打在 "requests.get" 上——后者只对直接 import requests 的模块生效。

#经验:CI 里翻车的三类问题

我遇到过三次「本地全绿、CI 挂掉」,原因各不相同:

  • 时区和语言环境:本地系统时区是 Asia/Shanghai,CI 是 UTC,测试里对时间戳字符串的断言就崩了。测试里永远用固定时区(pytz.timezone 或 zoneinfo)构造时间。
  • 随机顺序和共享状态:两个用例共用同一个模块级变量,一个改了另一个读,单跑都过、合跑就挂。fixture 返回的对象尽量给用例「只读」的副本。
  • 网络不通:CI 沙箱里没有外网,凡是会发请求的用例必须 mock。把「会联网的用例」用 @pytest.mark.network 标记,本地手动跑,CI 里 -m "not network" 排除。

最后补一句:测试文件和被测代码放在一起还是分开,团队定一个规则就行。我更习惯 tests/ 目录 + tests/conftest.py 放共享 fixture,这样 python -m pytest tests/ -q 就能跑全量,--maxfail=1 让 CI 在第一个失败就停下来,日志不至于刷屏。

写于 2025 年 9 月 11 日

栏目
技术文章
约
4 分钟
字数
3.1K
阅读
166

本文为原创记录,转载请注明出处。如果这篇替你省了时间,欢迎留言说说你踩到的坑。

同题 · related

留言 · remarks

00 条

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

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