第 25 章 测试工程:pytest 全家桶
这一章让你从「能写 assert 就行」升级到「用 pytest 把测试写成可维护的工程资产」——fixture 管依赖、parametrize 做表驱动、mock 隔离外部、coverage 量盲区。
墨叔:老哥,你在 Go 里写测试,函数名 TestXxx(t *testing.T),表驱动一个 tests := []struct{...}{...} 循环 t.Run,覆盖率 go test -cover。这套你闭着眼都能写。那我问你:到了 Python,你是继续 python -m unittest 继承 TestCase、还是换 pytest?又怎么把 Go 那套表驱动、子测试、t.Helper 的精髓平移过来?
老哥:……我之前都是 unittest,因为最像 Go 的 testing。但听说 pytest 更爽,具体爽在哪没深究。
墨叔:今天就把这事儿说透。pytest 不是「另一个测试框架」那么简单,它把测试从「写断言」升级成「写可复用、可组合的工程件」。咱们按四块讲:pytest 比 unittest 好在哪、fixture 复用与作用域、parametrize 表驱动、mock 外部依赖、coverage 与它的意义——全程对照 testing.T。
一、pytest 比 unittest 好在哪:少写样板,多写意图
先看同一段逻辑,两种写法:
# unittest 风格:样板重,每个测试都是方法,断言靠 self.assertXXX
import unittest
class TestPrice(unittest.TestCase):
def test_positive(self):
self.assertEqual(price(10, 0.1), 11.0)
def test_zero(self):
self.assertEqual(price(0, 0.1), 0.0)# pytest 风格:普通函数 + 原生 assert,零继承
def test_positive():
assert price(10, 0.1) == 11.0
def test_zero():
assert price(0, 0.1) == 0.0差距在哪?pytest 直接用 Python 原生 assert,报错时能自动展开表达式两边的值和差异(unittest 的 assertEqual 只说「不等」,pytest 告诉你「期望 11.0 实际 11.0000000001」,浮点误差一目了然)。不用继承 TestCase、不用 self.、不用记一堆 assertXxx 方法名。
墨叔:老哥,你对比 Go。Go 的 testing 也是「普通函数 + t.Error」,没有类继承,和 pytest 这版神似。unittest 反而像 JUnit 那套「继承 + 方法」的 Java 遗风。所以我的建议:新项目一律 pytest,它更 Pythonic,也更接近你 Go 的直觉。
pytest 还有几个「免费」能力:
- 自动发现:目录下
test_*.py或*_test.py、函数名test_*,直接pytest跑,不用像 unittest 那样python -m unittest discover配一堆。 - 失败即停 / 末次失败:
pytest -x第一个失败就停;pytest --lf只重跑上次失败的——对应你 Go 里手动过滤go test -run TestXxx。 - 夹具自动注入:函数签名写啥 fixture,pytest 就给你注入啥(见下节),不用
self.setUp()里手动赋值。
运行命令对照:
pytest -q # 安静跑全部
pytest -k "price" # 只跑名字含 price 的(≈ go test -run Price)
pytest test_x.py::test_zero -v二、fixture:依赖注入,替代 setUp/tearDown
unittest 里你用 setUp/tearDown 准备和清理资源,所有测试方法共享,粒度粗、容易互相污染。pytest 的 fixture 是「按用例按需注入、且自带作用域」的依赖。
import pytest
from pathlib import Path
@pytest.fixture
def tmp_config(tmp_path: Path) -> Path:
p = tmp_path / "app.yaml"
p.write_text("db: sqlite:///:memory:\n", encoding="utf-8")
return p # 函数返回即「注入的值」
def test_load_config(tmp_config): # 签名里写同名,pytest 自动注入
cfg = load_config(tmp_config)
assert cfg["db"].startswith("sqlite")fixture 的威力在作用域(scope),决定它建几次、复用给多少用例:
@pytest.fixture(scope="function") # 默认:每个测试函数重建一次
@pytest.fixture(scope="module") # 整个文件共享一个(数据库连接级别)
@pytest.fixture(scope="session") # 整个测试会话共享一个(最贵资源,如起服务)
def db():
conn = create_db()
yield conn # yield 之前是 setup,之后是 teardown
conn.close() # 用例跑完自动执行,异常也执行(异常安全)注意 yield 写法:yield 前面的准备、yield 后面的清理,无论测试成功失败都会跑清理——这就是 pytest 版的 defer/with。作用域选对,能省大量重复开销:session 级的 fixture(比如起一个测试数据库)只建一次,几百个用例共用;function 级的 fixture 保证用例间不串状态(隔离性)。
墨叔:对照 Go 你想——Go 没有 fixture 概念,你靠 t.Run 子测试 + 手动 defer 准备/清理,或者用 TestMain 做会话级 setup。pytest fixture 把「setup/teardown + 作用域 + 依赖注入」三件事合成一个机制,比 Go 的手写 defer 更声明式。代价是「隐式注入」新人读代码时要追 fixture 定义,但换来的是用例极干净。
真实项目里 fixture 常用来隔离外部依赖:给测试造一个假的 Redis、一个临时 SQLite、一份 mock 配置——这些「可复用测试件」放进 conftest.py,整个目录的测试都能用,不用每个文件 import。
三、parametrize:表驱动测试,平移 Go 的精髓
你在 Go 里最爱表驱动:
tests := []struct{ a, b, want float64 }{
{10, 0.1, 11.0},
{0, 0.1, 0.0},
{-5, 0.1, -4.5},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v_%v", tt.a, tt.b), func(t *testing.T) {
if got := price(tt.a, tt.b); got != tt.want {
t.Errorf("price(%v,%v)=%v want %v", tt.a, tt.b, got, tt.want)
}
})
}pytest 用 @pytest.mark.parametrize 做完全同构的事:
import pytest
@pytest.mark.parametrize(
"amount, rate, want",
[
(10, 0.1, 11.0),
(0, 0.1, 0.0),
(-5, 0.1, -4.5),
],
)
def test_price(amount, rate, want):
assert price(amount, rate) == want每个参数组合会生成独立的一个测试用例,失败时报告「哪一组参数挂了」(test_price[10-0.1-11.0] 这种 ID),和 Go 的 t.Run 子测试命名一个意思。多个 parametrize 叠加还是笛卡尔积,轻松覆盖组合:
@pytest.mark.parametrize("currency", ["cny", "usd"])
@pytest.mark.parametrize("vip", [True, False])
def test_discount(currency, vip):
... # 4 个组合自动展开墨叔:老哥,你品这性价比。表驱动测试的本质是「把『测试数据』和『测试逻辑』分离」——加用例只加一行数据,不复制粘贴函数体。Go 你靠 []struct + t.Run,pytest 靠 parametrize,思路 100% 一致,写法 pytest 还更省(不用手写循环和 t.Run)。这是 Go 工程师到 Python 最该平移的习惯,没有之一。
边界用例也要进表:空列表、None、负数、超大数、非法字符串——把「我觉得不会有人这么传」的都列进去,这正是「高级工程师测试思维」和「脚本小子测试思维」的分水岭。
四、mock 外部依赖:unittest.mock 隔离世界
真实项目里函数很少是纯的——它调数据库、发 HTTP、读配置、碰时间。测试时你要把这些外部依赖换成可控的假货,否则测试慢、不稳定、还要真实环境。Go 里你靠「接口 + 传 fake 实现」注入。Python 没强制接口,但标准库 unittest.mock 能「运行时替换对象」。
最常用 patch:临时把某个名字替换成 MagicMock(记录调用、可预设返回值)。
from unittest.mock import patch
def test_fetch_user():
# 把 http_client.get 在测试期间替换成 mock,预设返回
with patch("myapp.http_client.get") as mock_get:
mock_get.return_value = Mock(status=200, json=lambda: {"id": 1, "name": "墨"})
user = fetch_user(1)
assert user.name == "墨"
mock_get.assert_called_once_with("/users/1") # 还能断言「真的按预期调用了」关键理解:patch("myapp.http_client.get") 的路径要写「被测试代码** import 进来的那个名字**」,不是定义的地方——这是 pytest/Python mock 最容易错的点(patch 按属性查找路径,要打到使用方命名空间)。mock 能 return_value 设返回值、side_effect 设抛异常、assert_called_* 系列断言调用,等于把外部依赖变成「可观测、可编排」的假对象。
墨叔:对照 Go 再品——Go 你给 UserService 注入一个 UserRepo 接口,测试传 fakeRepo{users: ...}。Python 因为鸭子类型,连接口都不用定义,MagicMock 自带所有方法,直接 patch 上就行,更省事但更「隐式」(你没声明「这个函数依赖什么」,靠 patch 路径暗示依赖)。代价是 patch 路径写错测试就「假没打上、真依赖被调」,所以 pytest 里我更推荐把依赖通过参数/fixture 注入,再传 mock,而不是到处 patch——能用注入就别用 patch,和 Go「靠接口注入 fake」的纪律一致。
贴近项目的例子:测一个「下单」逻辑,但不想真连支付网关。
@pytest.fixture
def fake_gateway():
gw = MagicMock()
gw.charge.return_value = {"ok": True, "txn": "T123"}
return gw
def test_place_order(fake_gateway):
order = place_order(items=[...], gateway=fake_gateway)
assert order.paid is True
fake_gateway.charge.assert_called_once() # 验证确实走了支付,且只一次五、coverage:覆盖率量的是「盲区」,不是「质量」
跑测试加覆盖率:
pytest --cov=myapp --cov-report=term-missing它会告诉你每个文件哪些行从没被测试执行到(term-missing 直接标出行号)。这是覆盖率最有用的部分——它不是「越高越好」的奖章,而是「给我看我还没测的地方」的探照灯。
墨叔:老哥,这里有个 Go 工程师常有的误区,我得点破。你 go test -cover 看到 85%,容易以为「覆盖率高 = 质量好」。错。覆盖率是「被执行过」的度量,不是「被验证过正确」的度量——一行代码被跑过但没断言、或只走了 happy path,覆盖率照样算进去了。所以:
- 覆盖率的意义是找盲区:看到
order.py只有 40% 覆盖,你就知道「错误处理分支、边界情况根本没测」,补测试的方向清晰。 - 别盲目追 100%:getter/setter、
__repr__、纯透传代码强行 100% 是浪费;更重要的是「核心逻辑 + 错误分支」高覆盖。 - 和 parametrize 配合:表驱动把边界用例穷举了,覆盖率自然上去,且每一行都有断言护着——这才是「高质量覆盖」。
真实项目里把阈值写进 CI:pytest --cov=myapp --cov-fail-under=80,低于 80% 直接红。这和 Go 里 go test -cover 接 CI 门禁同思路,只是 pytest 的 --cov-fail-under 把「门禁」内建了。
老哥:那单元测试、集成测试、端到端,pytest 怎么组织?
墨叔:用**标记(marker)**分层,跑的时候按需选:
@pytest.mark.unit
def test_price(): ...
@pytest.mark.integration
def test_order_with_db(db): ...
# 跑的时候:pytest -m unit / pytest -m integrationconftest.py 里注册 marker 避免警告。这样 CI 里「unit 快测每次都跑,integration 连库慢测单独阶段跑」,和 Go 里你用 build tag / 目录分层完全对应。
五·补、异常路径也要测:pytest.raises
表驱动测的是「正常返回对不对」,但高级工程师更在意「出错时行为对不对」。pytest 用 pytest.raises 断言「这段代码必须抛指定异常」,对照 Go 里你写 if err == nil { t.Fatal("应报错") }。
import pytest
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError): # 期望抛这个异常,不抛就失败
divide(1, 0)
def test_charge_rejected_on_insufficient():
with pytest.raises(InsufficientError) as exc:
charge(user_id=1, amount=99999)
assert exc.value.code == "BALANCE_LOW" # 还能检查异常对象的字段pytest.raises(Exc) 当上下文管理器用:块内抛了 Exc 就通过,没抛就失败;捕获到的异常对象赋给 exc.value,你接着断言它的属性(错误码、消息)——这正是「验证错误分支行为」,补足覆盖率里最难覆盖的那块。配合 parametrize 还能把「正常 / 各类异常」写在一张表里:
@pytest.mark.parametrize("amount, exc", [
(100, None), # 正常:不抛
(0, ValueError), # 金额为零:抛 ValueError
(-1, ValueError), # 负数:抛 ValueError
])
def test_charge(amount, exc):
if exc is None:
assert charge(1, amount) is not None
else:
with pytest.raises(exc):
charge(1, amount)墨叔:老哥,你对照 Go 看——go test 里你用 if err != nil { t.Fatal } 反向验证「不该出错时出错」,用 if err == nil { t.Errorf } 验证「该出错时没出错」。pytest.raises 把后者直接变成断言语法糖,而且能顺带校验异常内容。错误路径测试是最容易被漏的覆盖率盲区,把它和 parametrize 合起来用,核心模块的「异常分支」也能穷举进表里。
六、对照 Go 收个尾
老哥:整体看下来,pytest 和 Go testing 是殊途同归?
墨叔:骨架同源,表达更省。给你速查:
Go testing 习惯 | pytest 对应 | 差异提醒 |
|---|---|---|
func TestXxx(t *testing.T) | def test_xxx(): | pytest 免继承、原生 assert |
tests := []struct{...} + t.Run | @pytest.mark.parametrize | pytest 不用手写循环 |
t.Run(name, func) 子测试 | parametrize ID / @pytest.mark | 失败定位到具体参数组 |
defer cleanup() | fixture 的 yield 后清理 | pytest 按 scope 复用,更声明式 |
TestMain 会话级 setup | scope="session" fixture | 同理 |
| fake 实现接口注入 | MagicMock / fixture 注入 | 能用注入别滥用 patch |
go test -cover | pytest --cov --cov-fail-under | 覆盖率意义相同:找盲区 |
| build tag 分层 | @pytest.mark + -m | 分层测试同思路 |
一句话带走:pytest 用普通函数 + 原生 assert 省样板;fixture 按作用域管依赖与清理(就是 pytest 的 defer);parametrize 是把 Go 表驱动翻译过来、且更短;mock 隔离外部、能用注入就别 patch;coverage 是用来找没测到的盲区、不是质量奖章。把你 Go 里那套测试纪律原样搬过来,工具换皮即可,测试思维不用重学。
【思考题】
- 你在 Go 里习惯用「接口 + fake 实现」注入依赖做测试。到了 Python,有人告诉你「直接
patch打桩最方便」。请基于这一章,分析这两种做法各自的收益与代价,并给出一个你会在真实项目里遵循的取舍原则(什么情况用注入、什么情况才用 patch)。如果patch路径写错了(打到了错误命名空间),会发生什么、怎么排查? - 把下面这段 Go 表驱动测试,完整翻译成 pytest 的
parametrize写法(含负数、零、正常三组数据,且失败时能看出是哪组挂了)。并说明 pytest 版本相比 Go 版本,在「新增一个用例」和「看失败报告」上各有什么体验差异。func TestAbs(t *testing.T) { tests := []struct{ in, want int }{{3, 3}, {0, 0}, {-3, 3}} for _, tt := range tests { t.Run(strconv.Itoa(tt.in), func(t *testing.T) { if got := abs(tt.in); got != tt.want { t.Errorf("abs(%d)=%d want %d", tt.in, got, tt.want) } }) } } - 项目 CI 里你设了
pytest --cov=myapp --cov-fail-under=80,但有个同事说「我们核心模块覆盖都 95% 了,质量没问题」。请结合这一章对 coverage 的讨论,指出这个说法可能哪里站不住脚,并给出至少两条「覆盖率数字之外的、判断测试是否真有效」的具体检查手段(比如看某一行是被『跑过』还是『被断言验证过』)。
【参考答案】
- 收益与代价:接口+fake 注入(Python 里表现为「把依赖作为函数参数/fixture 传入,测试传 MagicMock 或手写 fake 对象」)——收益是依赖关系显式写在签名里,读代码就知道这函数依赖什么,mock 打在调用方、路径不可能错,重构时编译器/类型检查能帮你查所有调用点;代价是要多写一层可注入的结构(Go 有接口,Python 要手动把依赖参数化),前期略繁。patch 打桩——收益是「零侵入」,不改生产代码签名,一行
with patch(...)就替换;代价是 patch 路径是字符串、运行时才解析,写错命名空间(比如该打myapp.service.client却打了myapp.client.Client定义处)测试会「假没打上、真的外部依赖被调用」,表现为测试变慢/连真库/或神秘失败,且重构改名时 patch 不报错只静默失效。取舍原则:优先依赖注入——把外部依赖(DB/网关/HTTP client)设计成可传参/fixture,测试传 mock;仅在无法改签名(第三方库的全局函数、时间函数time.time、无法注入的模块级单例)时才用patch,且 patch 路径务必写「使用方命名空间.属性」,并在测试里加assert_called_once之类验证「mock 真的被用到了」来防止打空。排查打错:测试时若发现走的是真实依赖(连了真库/有真实网络),或mock.assert_called报「从未调用」,优先怀疑 patch 路径——用pytest --tb=long看、或临时在 patch 目标处加日志确认替换是否生效;更稳的是用 fixture 注入替代 patch。 - pytest 翻译版:
(import pytest @pytest.mark.parametrize("in_, want", [(3, 3), (0, 0), (-3, 3)]) def test_abs(in_, want): assert abs(in_) == wantabs是内置,这里示意;若自写my_abs替换即可。in_因in是关键字故加下划线。)失败报告里 pytest 会显示test_abs[3-3]、test_abs[-3-3]这种参数组 ID,直接定位哪组挂。新增用例差异:Go 版要往tests切片加一行struct;pytest 版往parametrize的列表加一个元组,都不对函数体动刀,但 pytest 连循环和t.Run都省了,更短。看失败报告差异:Go 用t.Errorf要自己拼abs(%d)=%d want %d才知道期望与实际;pytest 用原生assert,失败自动展开assert abs(-3) == 3两侧值并显示where abs(-3) = 3,信息更直给,不用手写格式化报错。 - 「95% 覆盖=质量没问题」站不住脚,原因:① 覆盖率是「被执行」度量,不是「被验证」度量——那 95% 里可能大量行只是被跑过、没有任何
assert校验结果(比如只调用了函数没断言返回值),这种「假覆盖」数字好看实则没护城河。② 可能只走了 happy path,错误分支/异常分支虽被执行但没测「异常时行为是否正确」。③ getter、__repr__、日志等无关紧要代码被算进分母,虚高整体比例。判断测试有效的具体手段:a) 看断言密度与关键路径:抽查核心函数测试,确认返回值和副作用都有assert/assert_called,而非「调用一下就结束」;可用pytest --cov-report=term-missing看未覆盖行,再用mutmut/突变测试思想——故意改生产代码一处,看测试是否失败,不失败说明那块测试无效。b) 强制错误分支覆盖:用 parametrize 把异常/边界用例显式列入,CI 里对核心模块单独要求错误路径也有用例;或接pytest-cov配fail_under只针对业务逻辑模块而非全仓。c) 看「失败能否被发现」而非「通过率」:定期做「故障注入」——改一个常量/条件,若整套测试仍全绿,说明测试在糊弄。覆盖率是体检的「拍片」,片子上没阴影不代表没病,要结合断言、突变测试、错误路径覆盖一起看,才是真有效。