第 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 integration

conftest.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.parametrizepytest 不用手写循环
t.Run(name, func) 子测试parametrize ID / @pytest.mark失败定位到具体参数组
defer cleanup()fixture 的 yield 后清理pytest 按 scope 复用,更声明式
TestMain 会话级 setupscope="session" fixture同理
fake 实现接口注入MagicMock / fixture 注入能用注入别滥用 patch
go test -coverpytest --cov --cov-fail-under覆盖率意义相同:找盲区
build tag 分层@pytest.mark + -m分层测试同思路

一句话带走:pytest 用普通函数 + 原生 assert 省样板;fixture 按作用域管依赖与清理(就是 pytest 的 defer);parametrize 是把 Go 表驱动翻译过来、且更短;mock 隔离外部、能用注入就别 patch;coverage 是用来找没测到的盲区、不是质量奖章。把你 Go 里那套测试纪律原样搬过来,工具换皮即可,测试思维不用重学。

【思考题】

  1. 你在 Go 里习惯用「接口 + fake 实现」注入依赖做测试。到了 Python,有人告诉你「直接 patch 打桩最方便」。请基于这一章,分析这两种做法各自的收益与代价,并给出一个你会在真实项目里遵循的取舍原则(什么情况用注入、什么情况才用 patch)。如果 patch 路径写错了(打到了错误命名空间),会发生什么、怎么排查?
  2. 把下面这段 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)
                }
            })
        }
    }
  3. 项目 CI 里你设了 pytest --cov=myapp --cov-fail-under=80,但有个同事说「我们核心模块覆盖都 95% 了,质量没问题」。请结合这一章对 coverage 的讨论,指出这个说法可能哪里站不住脚,并给出至少两条「覆盖率数字之外的、判断测试是否真有效」的具体检查手段(比如看某一行是被『跑过』还是『被断言验证过』)。

【参考答案】

  1. 收益与代价:接口+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。
  2. pytest 翻译版:
    import pytest
     
    @pytest.mark.parametrize("in_, want", [(3, 3), (0, 0), (-3, 3)])
    def test_abs(in_, want):
        assert abs(in_) == want
    (abs 是内置,这里示意;若自写 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,信息更直给,不用手写格式化报错。
  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) 看「失败能否被发现」而非「通过率」:定期做「故障注入」——改一个常量/条件,若整套测试仍全绿,说明测试在糊弄。覆盖率是体检的「拍片」,片子上没阴影不代表没病,要结合断言、突变测试、错误路径覆盖一起看,才是真有效。