pytest 上手很快,写顺却不一定快。
最常见的情况通常是:
- 第一条测试很好写
- 第二条也还行
- 到第十条开始,准备数据越来越重复
- 后面加 fixture 之后,又开始出现“看不懂数据从哪来”
这说明问题通常不在 pytest 本身,而在夹具设计没处理好两个方向的平衡:
如果太追求“复用”,很容易变成一层套一层的 fixture,读测试像追线索。
如果只顾“直观”,每条测试又会复制大量准备代码。
这一篇直接围绕一个实际功能展开:做一个优惠券结算模块的单元测试。
这个模块要处理这些场景:
- 无优惠券时正常结算
- 满减券生效
- 过期券不生效
- 多张券里只允许一张生效
用这个场景把三件事讲清楚:
- 单元测试最小骨架怎么写
- fixture 到底该抽到什么粒度
- 怎么避免 fixture 过度封装
一、这篇文章要解决什么问题
读完这一篇,应该能独立完成这些动作:
- 给一个纯业务函数写最小单元测试
- 识别什么数据值得抽成 fixture
- 让 fixture 同时保持可读和可复用
- 识别几类典型坏味道,例如 fixture 链太深、测试入参不可见、状态被共享
如果这些动作能独立做出来,pytest 在项目里就不会再只是“会跑”,而是开始能稳定支撑测试代码演进。
二、先看要测试的实际功能
先把被测逻辑放出来。
pricing.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| from datetime import datetime
def calc_final_amount(origin_amount, coupons, now=None): if now is None: now = datetime.now()
final_amount = origin_amount applied_coupon = None
for coupon in coupons: if coupon["expired_at"] < now: continue if coupon["type"] == "minus" and origin_amount >= coupon["threshold"]: final_amount = origin_amount - coupon["value"] applied_coupon = coupon["name"] break
if final_amount < 0: final_amount = 0
return { "origin_amount": origin_amount, "final_amount": final_amount, "applied_coupon": applied_coupon, }
|
先写一个最小调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| from datetime import datetime from pricing import calc_final_amount
result = calc_final_amount( 120, [{ "name": "满100减20", "type": "minus", "threshold": 100, "value": 20, "expired_at": datetime(2018, 8, 1, 0, 0, 0), }], now=datetime(2018, 7, 1, 0, 0, 0), )
print(result)
|
输出:
1
| {'origin_amount': 120, 'final_amount': 100, 'applied_coupon': '满100减20'}
|
三、先写最小单元测试,不要一上来就抽 fixture
一开始如果急着抽 fixture,往往还没看清重复到底在哪。
先写最小测试更稳:
test_pricing.py:
1 2 3 4 5 6 7 8 9 10 11
| from datetime import datetime
from pricing import calc_final_amount
def test_calc_final_amount_without_coupon(): result = calc_final_amount(120, [], now=datetime(2018, 7, 1, 0, 0, 0))
assert result["origin_amount"] == 120 assert result["final_amount"] == 120 assert result["applied_coupon"] is None
|
执行:
输出:
1 2
| . [100%] 1 passed in 0.02s
|
这一步最重要的是先把断言风格定住:
四、什么时候值得开始抽 fixture
如果继续加两三条测试,很快就会看到重复:
- 同样的
now
- 类似结构的 coupon
- 相近的 origin amount
这时 fixture 就值得上场了。
1. 先抽最稳定、最不容易引起歧义的固定数据
1 2 3 4 5 6 7
| import pytest from datetime import datetime
@pytest.fixture def fixed_now(): return datetime(2018, 7, 1, 0, 0, 0)
|
2. 再抽基础 coupon
1 2 3 4 5 6 7 8 9
| @pytest.fixture def valid_minus_coupon(): return { "name": "满100减20", "type": "minus", "threshold": 100, "value": 20, "expired_at": datetime(2018, 8, 1, 0, 0, 0), }
|
3. 测试立刻会更短
1 2 3 4 5
| def test_calc_final_amount_with_valid_coupon(fixed_now, valid_minus_coupon): result = calc_final_amount(120, [valid_minus_coupon], now=fixed_now)
assert result["final_amount"] == 100 assert result["applied_coupon"] == "满100减20"
|
fixture 的实际价值这时候就很明确了:
五、fixture 设计最容易犯的错是什么
1. 把所有东西都抽成 fixture
错误方向通常像这样:
origin_amount 也是 fixture
coupon_list 也是 fixture
expected_result 也是 fixture
最后测试长成:
1 2 3
| def test_xxx(order_amount, coupon_list, expected_result, fixed_now): result = calc_final_amount(order_amount, coupon_list, now=fixed_now) assert result == expected_result
|
表面上很“整洁”,实际上阅读成本反而更高,因为:
- 看测试本身根本不知道当前输入是什么
- 要来回跳 fixture 才能读懂场景
2. 一个更实际的判断标准
更适合抽成 fixture 的通常是:
- 多条测试共享的固定上下文
- 语义明确的基础数据
- 准备过程本身比较啰嗦
不一定非要抽成 fixture 的通常是:
- 一眼能看懂的简单数字
- 只在当前用例里出现一次的场景输入
- 测试重点本身
所以 fixed_now 值得抽,origin_amount=120 通常不值得抽。
六、fixture 怎么兼顾复用和可读性
1. 用“基础 fixture + 测试内局部修改”比“万能 fixture”更稳
例如:
1 2 3 4 5 6 7 8
| def test_calc_final_amount_with_expired_coupon(fixed_now, valid_minus_coupon): expired_coupon = dict(valid_minus_coupon) expired_coupon["expired_at"] = datetime(2018, 6, 1, 0, 0, 0)
result = calc_final_amount(120, [expired_coupon], now=fixed_now)
assert result["final_amount"] == 120 assert result["applied_coupon"] is None
|
这里的做法很实用:
- fixture 提供基础数据
- 当前测试自己把场景变化说清楚
读起来会比“再额外套一层 expired_coupon fixture”直观很多。
2. 一个实际错误:直接修改 fixture 返回对象
错误写法:
1 2 3
| def test_expired_coupon(fixed_now, valid_minus_coupon): valid_minus_coupon["expired_at"] = datetime(2018, 6, 1, 0, 0, 0) result = calc_final_amount(120, [valid_minus_coupon], now=fixed_now)
|
如果后面别的测试还依赖 valid_minus_coupon,就可能被污染。
更稳的做法:
1 2
| expired_coupon = dict(valid_minus_coupon) expired_coupon["expired_at"] = datetime(2018, 6, 1, 0, 0, 0)
|
3. 一个更隐蔽的错误:嵌套结构浅拷贝不够
如果 coupon 里还有嵌套列表或字典,单纯 dict() 也未必够用。
这时要么:
- 手动重新构造当前测试需要的数据
- 要么用
copy.deepcopy()
别为了省一行代码,把后面几条测试都变成不稳定状态。
七、什么时候该用 fixture factory
有时候基础 fixture 还是不够,因为同一类数据只是部分字段不同。
这时比起写很多固定 fixture,更顺手的做法是让 fixture 返回一个“工厂函数”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @pytest.fixture def coupon_factory(): def _build(**overrides): coupon = { "name": "满100减20", "type": "minus", "threshold": 100, "value": 20, "expired_at": datetime(2018, 8, 1, 0, 0, 0), } coupon.update(overrides) return coupon
return _build
|
调用:
1 2 3 4 5 6
| def test_calc_final_amount_with_factory(fixed_now, coupon_factory): coupon = coupon_factory(value=30, name="满100减30") result = calc_final_amount(120, [coupon], now=fixed_now)
assert result["final_amount"] == 90 assert result["applied_coupon"] == "满100减30"
|
这种写法在“数据结构相同、场景参数不同”时非常顺手。
八、把测试真正整理成可维护版本
现在把前面的 fixture 和测试风格串起来。
test_pricing.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| from datetime import datetime
import pytest
from pricing import calc_final_amount
@pytest.fixture def fixed_now(): return datetime(2018, 7, 1, 0, 0, 0)
@pytest.fixture def coupon_factory(): def _build(**overrides): coupon = { "name": "满100减20", "type": "minus", "threshold": 100, "value": 20, "expired_at": datetime(2018, 8, 1, 0, 0, 0), } coupon.update(overrides) return coupon
return _build
def test_calc_final_amount_without_coupon(fixed_now): result = calc_final_amount(120, [], now=fixed_now)
assert result["origin_amount"] == 120 assert result["final_amount"] == 120 assert result["applied_coupon"] is None
def test_calc_final_amount_with_valid_coupon(fixed_now, coupon_factory): coupon = coupon_factory() result = calc_final_amount(120, [coupon], now=fixed_now)
assert result["final_amount"] == 100 assert result["applied_coupon"] == "满100减20"
def test_calc_final_amount_with_expired_coupon(fixed_now, coupon_factory): coupon = coupon_factory(expired_at=datetime(2018, 6, 1, 0, 0, 0)) result = calc_final_amount(120, [coupon], now=fixed_now)
assert result["final_amount"] == 120 assert result["applied_coupon"] is None
|
执行:
可能输出:
1 2
| ... [100%] 3 passed in 0.03s
|
九、什么时候 fixture 已经过度了
如果一个测试文件出现这种情况,就要警惕:
- fixture 超过 8 到 10 个
- 一个测试函数一口气注入 5 个以上 fixture
- 阅读测试时必须跳来跳去才能看懂输入
这通常说明问题不一定在 pytest,而是:
一个很实用的判断标准是:
如果不打开 fixture 定义,就看不懂这条测试到底在测什么,那通常已经有点过度封装了。
十、一个实际排错场景
这类测试里一个非常常见的实际问题是:单独跑一条测试能过,整文件一起跑时某几条开始互相影响。
这时排查顺序通常很直接:
- 先看 fixture 返回的是不是可变对象
- 再看测试里有没有直接修改 fixture 返回值
- 再看是不是浅拷贝不够
如果最后发现测试里写了:
1
| valid_coupon["value"] = 999
|
而 valid_coupon 又被多条测试共用,那根因通常就不是业务逻辑错了,而是 fixture 数据被污染了。
十一、一个实际练习
可以直接把这一篇变成一个完整练习。
练习目标:给一个“订单运费计算函数”补一套 pytest 单元测试。
要求:
- 至少覆盖 3 个正常场景和 2 个边界场景
- 抽一个固定时间或固定配置的 fixture
- 抽一个数据工厂 fixture
- 不要把所有简单参数都抽成 fixture
- 至少补一个可变对象污染的错误场景
如果这个练习能独立做完,说明 fixture 设计怎么兼顾可读性和复用性,这一层已经开始真正掌握。
十二、这篇文章学完以后,下一步应该补什么
如果这一篇已经能跟着做完,下一步最适合继续补的是:
- 接口测试、数据校验和测试数据准备怎么组织更顺手
- mock、monkeypatch、fixture 和参数化测试分别适合解决什么问题
- 测试目录和测试数据文件怎样继续拆
因为到这一步,已经开始从“会写测试”走向“测试代码本身也要可维护”的阶段了。
十三、结语
pytest 的 fixture 最有价值的地方,不是让测试看起来更高级,而是让重复数据准备开始有秩序。
真正顺手的 fixture 设计,通常只解决两件事:
- 把稳定重复的上下文抽出来
- 让每条测试的场景重点依然清楚
只要这两个目标没丢,单元测试就会越来越稳;一旦过度抽象,测试就会开始变成另一种难读代码。