返回博客

用 pytest 把测试写成活文档

我很长一段时间把测试当成「写完功能后要补的作业」。代码合并了,CI 过了,才想起来要补几个 assert。结果是:测试覆盖的是我已经确认能跑通的路径,对真正危险的边界和错误处理几乎一无所知。

后来在一个重构量较大的后端项目里,我被迫把 TDD 走通了一遍。才发现测试不只是「证明代码正确」,更是在用另一种语言写规格——它迫使你在动手之前先想清楚「这个函数应该对什么输入给出什么输出,失败时该怎么失败」。

先写失败,再写实现

TDD 的三步——红、绿、重构——听起来像教条,但它背后有一个工程直觉:当你先写测试时,你是以调用者的视角在设计接口,而不是先把实现堆出来再倒推接口。

# 第一步:写一个必然失败的测试,用来描述期望行为
def test_parse_price_handles_negative():
    with pytest.raises(ValueError, match="价格不能为负"):
        parse_price(-5.0)
 
# 第二步:写最少量的代码让测试变绿
def parse_price(value: float) -> float:
    if value < 0:
        raise ValueError("价格不能为负")
    return round(value, 2)

这个过程的价值不在于测试本身,而在于它让「预期行为」在代码里留下了痕迹。三个月后来看这段测试,你立刻知道负数价格是一个被明确处理的边界,而不是一个被悄悄忽略的遗漏。

Fixture:把测试的准备工作变成声明式配置

写多了测试之后,最让人烦的往往不是断言,而是前置状态的搭建——创建测试数据库、构造用户对象、模拟登录态……每个测试都要重复一遍。

pytest 的 fixture 系统解决的正是这个问题:

# conftest.py — 跨文件共享的夹具定义
@pytest.fixture
def db_session():
    """每个测试用独立事务,结束后自动回滚,互不污染。"""
    session = Session(bind=engine)
    session.begin_nested()
    yield session
    session.rollback()
    session.close()
 
@pytest.fixture
def auth_client(client):
    """返回已登录状态的测试客户端,省去每个测试都手动登录。"""
    client.post("/api/login", json={"username": "tester", "password": "test"})
    return client

fixture 的 scope 参数很关键:function(默认)每个测试独享,module 一个文件共享一次,session 整个测试跑一次。选错 scope 会导致测试之间共享状态,引出难以复现的随机失败。通用经验是默认用 function scope,只有昂贵的资源(如真实数据库连接)才升到 module 或 session

参数化:用数据驱动覆盖边界

很多时候,同一段逻辑需要用不同输入验证——正常值、零值、空字符串、超边界数字。如果每种情况写一个独立测试函数,代码就会膨胀成重复的样板。

@pytest.mark.parametrize 让你把「输入-期望」对做成数据表驱动:

@pytest.mark.parametrize("raw,expected", [
    ("  hello  ", "hello"),
    ("WORLD", "world"),
    ("", ""),
    ("  ", ""),
], ids=["leading-spaces", "uppercase", "empty", "whitespace-only"])
def test_normalize_text(raw, expected):
    assert normalize_text(raw) == expected

ids 参数让测试报告里每一行都有可读名字,失败时一眼就能定位是哪个 case 出了问题。

覆盖率目标是手段,不是目的

80% 覆盖率是个常见门槛,但覆盖率本身不保证质量。真正有价值的是关键路径 100% 覆盖 + 所有已知边界和错误分支都有对应测试。为了凑数字写的测试,往往比没有测试更危险——它制造了一种「被保护了」的错觉。

Mock 的边界:只隔离你无法控制的部分

Mock 是测试里最容易用错的工具。新手要么完全不用,导致单元测试依赖外部服务;要么 Mock 得太过细致,测试变成对实现细节的镜像——一旦重构,测试全部失效,根本起不到安全网的作用。

我认为合理的 Mock 边界是:隔离你无法控制的部分——第三方 API、数据库连接、文件系统、当前时间。你自己的业务逻辑,尽量让它真实运行。

@patch("mypackage.email_service.send")
def test_user_registration_sends_welcome_email(mock_send):
    """测试注册流程触发邮件,但不真的发邮件。"""
    register_user(email="test@example.com", password="secure123")
    mock_send.assert_called_once_with(
        to="test@example.com",
        subject="欢迎注册"
    )

另一个容易踩的坑是 Mock 的路径。@patch 要打在被测模块导入的路径,而不是被 Mock 对象定义的路径。如果 mypackage.user_service 里写了 from email_service import send,那么要 patch 的是 mypackage.user_service.send,而不是 email_service.send

一句话心法

好的测试不是在证明「代码写了」,而是在记录「代码应该做什么」——它是写给未来那个不记得上下文的自己看的。