Python 单元测试入门:用 pytest 写出可靠测试
从 fixture、参数化到 mock 和 CI 排障,把 pytest 从「能跑通」练到「跑得稳」。
测试的价值不在于「写了多少条」,而在于「改代码的时候敢不敢跑」。我最早写脚本从来不写测试,直到有一次重构一个计费函数,改完上线才发现两个边界条件被弄反了,线上算错了一周的单。从那以后,凡是会被反复改的逻辑,我都会先补测试。这篇文章讲 pytest 里真正常用的东西,以及我踩过的坑。
#先跑起来:一个最小用例
# 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.03s这里有个第一次写测试的人最容易踩的坑:浮点数不要直接 == 比较。round() 能救一部分,但跨 Python 版本、跨平台时浮点尾差仍然存在。金额这种敏感场景,要么用 Decimal 做运算,要么断言用 pytest.approx:
def test_with_approx():
assert total_price(0.1, 3, 0.0) == pytest.approx(0.3)#fixture:把「准备数据」从用例里拆出去
fixture 是 pytest 和 unittest 最大的体验差异。测试需要数据库连接、临时文件、登录态时,fixture 把这些准备工作集中管理,还能控制作用域:
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.82fixture 的 scope 决定复用范围:function(默认,每个用例都新建)、module(模块内复用一次)、session(整个测试会话一次)。连接数据库这种贵的资源用 session/module 级,普通数据用 function 级,避免用例之间互相污染。
临时文件永远用内置的 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 每个用例自动是独立目录,跑完自动清理,用例之间天然隔离,测试顺序无关——这是「测试不依赖执行顺序」最省事的实现方式。
#参数化:别复制粘贴用例
同一逻辑多组输入,用 @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.raises(ValueError) 断言「这段代码必须抛 ValueError」,不抛就失败。参数化数据写多了之后要注意:数据本身也是要维护的资产,别为了凑数堆一堆重复用例。
#外部依赖:fixture + monkeypatch 隔离
测试里最忌讳真的去请求外部 API、读写生产数据库。正确做法是把外部调用 mock 掉:
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.5monkeypatch 的好处是自动还原:用例结束就把 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
- Python 文件操作实战:安全整理下载目录用 pathlib 写安全的文件整理脚本:预览模式、防覆盖、递归扫描与原子替换。
- Python 爬虫实战:用 requests 抓取并保存文章列表用 requests + BeautifulSoup 写懂规矩的采集脚本:会话复用、超时重试、指数退避、限速与断点续抓。
- 把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程在 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s。附完整 YAML 清单(Deployment、Service、PVC、Ingress、Certificate 等)与全部配置命令,记录部署、数据迁移与切换上线的踩坑过程。
留言 · remarks
00 条还没有留言,来说点什么吧。