Python:单元测试与 pytest 夹具设计,怎么兼顾可读性和复用性

pytest 上手很快,写顺却不一定快。

最常见的情况通常是:

  • 第一条测试很好写
  • 第二条也还行
  • 到第十条开始,准备数据越来越重复
  • 后面加 fixture 之后,又开始出现“看不懂数据从哪来”

这说明问题通常不在 pytest 本身,而在夹具设计没处理好两个方向的平衡:

  • 可读性
  • 复用性

如果太追求“复用”,很容易变成一层套一层的 fixture,读测试像追线索。
如果只顾“直观”,每条测试又会复制大量准备代码。

这一篇直接围绕一个实际功能展开:做一个优惠券结算模块的单元测试

这个模块要处理这些场景:

  1. 无优惠券时正常结算
  2. 满减券生效
  3. 过期券不生效
  4. 多张券里只允许一张生效

用这个场景把三件事讲清楚:

  1. 单元测试最小骨架怎么写
  2. fixture 到底该抽到什么粒度
  3. 怎么避免 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
pytest -q

输出:

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
pytest -q

可能输出:

1
2
...                                                                  [100%]
3 passed in 0.03s

九、什么时候 fixture 已经过度了

如果一个测试文件出现这种情况,就要警惕:

  • fixture 超过 8 到 10 个
  • 一个测试函数一口气注入 5 个以上 fixture
  • 阅读测试时必须跳来跳去才能看懂输入

这通常说明问题不一定在 pytest,而是:

  • 数据工厂拆得过细
  • 场景表达被抽象过头

一个很实用的判断标准是:

如果不打开 fixture 定义,就看不懂这条测试到底在测什么,那通常已经有点过度封装了。

十、一个实际排错场景

这类测试里一个非常常见的实际问题是:单独跑一条测试能过,整文件一起跑时某几条开始互相影响。

这时排查顺序通常很直接:

  1. 先看 fixture 返回的是不是可变对象
  2. 再看测试里有没有直接修改 fixture 返回值
  3. 再看是不是浅拷贝不够

如果最后发现测试里写了:

1
valid_coupon["value"] = 999

valid_coupon 又被多条测试共用,那根因通常就不是业务逻辑错了,而是 fixture 数据被污染了。

十一、一个实际练习

可以直接把这一篇变成一个完整练习。

练习目标:给一个“订单运费计算函数”补一套 pytest 单元测试。

要求:

  1. 至少覆盖 3 个正常场景和 2 个边界场景
  2. 抽一个固定时间或固定配置的 fixture
  3. 抽一个数据工厂 fixture
  4. 不要把所有简单参数都抽成 fixture
  5. 至少补一个可变对象污染的错误场景

如果这个练习能独立做完,说明 fixture 设计怎么兼顾可读性和复用性,这一层已经开始真正掌握。

十二、这篇文章学完以后,下一步应该补什么

如果这一篇已经能跟着做完,下一步最适合继续补的是:

  1. 接口测试、数据校验和测试数据准备怎么组织更顺手
  2. mock、monkeypatch、fixture 和参数化测试分别适合解决什么问题
  3. 测试目录和测试数据文件怎样继续拆

因为到这一步,已经开始从“会写测试”走向“测试代码本身也要可维护”的阶段了。

十三、结语

pytest 的 fixture 最有价值的地方,不是让测试看起来更高级,而是让重复数据准备开始有秩序。

真正顺手的 fixture 设计,通常只解决两件事:

  • 把稳定重复的上下文抽出来
  • 让每条测试的场景重点依然清楚

只要这两个目标没丢,单元测试就会越来越稳;一旦过度抽象,测试就会开始变成另一种难读代码。