☰
Python后端工程化与测试02:修改一行金额公式之前,先让pytest替你盯住行为
2026/10/21 2:22:57 网站建设 项目流程

Python后端工程化与测试02:修改一行金额公式之前,先让pytest替你盯住行为

假设现在收到一个需求:“把金额计算挪到单独函数里。”你选中一行代码,复制、粘贴,页面照常能打开,就提交了改动。两天后才有人发现运费被乘了数量。问题并不在复制粘贴,而在验证只覆盖了quantity=1:两种公式在这一输入下结果相同。测试的价值不是让每个函数都有一个同名测试文件,而是选出能区分正确和错误实现的例子。

先兑现上一课的断言

E01要求的不是建立测试框架,而是把报价合同写成可执行条件。参考答案在文件开头通过Path(__file__).resolve()定位交付目录,再显式加入01阶段的导入路径。因此你从其他目录启动它,也不会误用后面阶段的包。每次运行脚本都会启动一个新的Python进程;Python模块导入缓存不会跨进程污染本次答案。

$PY=(Resolve-Path.\.venv\Scripts\python.exe).Path &$PY.\lessons\02\answer_previous.py

预期输出为E01 PASS: totals, terminal states, and missing quote。如果任意一个断言失败,脚本不会打印这条成功信息。TestClient直接在进程里驱动ASGI应用,不占用9200端口;它适合验证路由、输入模型和响应转换,但不能证明真实TCP连接、代理配置和服务重启后的行为都正确。

lessons/02/answer_previous.py

"""E01:直接执行本文件;不需要启动Uvicorn。"""frompathlibimportPathimportsys COURSE=Path(__file__).resolve().parents[2]STAGE=COURSE/"checkpoints"/"01"sys.path.insert(0,str(STAGE))importappasbaseline# noqa: E402fromfastapi.testclientimportTestClient# noqa: E402defmain()->None:# 来源断言保证答案没有误用其他专题或后续阶段。assertPath(baseline.__file__).resolve()==(STAGE/"app.py").resolve()withTestClient(baseline.create_app())asclient:ids:list[str]=[]forquantity,totalin[(1,1799),(2,3098)]:response=client.post("/quotes",json={"sku":"BOOK-001","supplier":"alpha","quantity":quantity,"unit_cents":1299,"shipping_cents":500,},)assertresponse.status_code==201,response.text quote=response.json()assertquote["total_cents"]==totalassertquote["status"]=="draft"assertresponse.headers["location"]==f"/quotes/{quote['id']}"ids.append(quote["id"])path=f"/quotes/{ids[0]}"confirmed=client.post(path+"/confirm")assertconfirmed.status_code==200assertconfirmed.json()["status"]=="confirmed"assertclient.post(path+"/confirm").status_code==409assertclient.post(path+"/cancel").status_code==409assertclient.get("/quotes/does-not-exist").status_code==404print("E01 PASS: totals, terminal states, and missing quote")if__name__=="__main__":main()

从一个大脚本拆出可定位的失败

普通脚本有一个缺点:第一个assert失败,后面的检查全停了。pytest会发现名称以test_开头的测试函数,分别执行并报告结果。我们按照业务场景拆测试:创建金额、终态冲突、缺失报价。这样看到失败名称时,先知道破坏了哪段合同,而不是先去追“第47行是什么”。

一个好测试通常可以读成三句:准备可控输入,执行一次被测行为,断言可观察结果。这就是常说的Arrange、Act、Assert。不要为了格式而强行每句只写一行;准备复杂数据可以使用辅助函数,但辅助函数不能顺手把真正的断言吞掉。下面这个断言仍然关注HTTP输出:

response=client.post('/quotes',json=payload)assertresponse.status_code==201assertresponse.json()['total_cents']==3098

第一条assert先确认请求成功,再读取关键字段。没有它,失败时可能只看到KeyError,看不出服务原本返回了422。也不建议把整个响应字典硬编码下来:id和created_at本来就会变化;过度固定无关字段,会让测试在合法调整中频繁报错。

fixture不只是“少写几行初始化”

fixture声明测试运行前需要的资源。@pytest.fixture修饰一个名为client的函数,测试形参也叫client时,pytest会把fixture结果传进去。它依靠名字匹配,不是普通Python自动按类型查找依赖。我们默认使用function作用域,即每个测试重新创建app和TestClient。

@pytest.fixturedefclient():withTestClient(create_app())asvalue:yieldvalue

yield之前是准备阶段,yield之后是清理阶段;with会在测试结束时关闭客户端,即使测试抛出断言异常。不要为了节省几毫秒把有状态的app改成session作用域。那会让测试B读到测试A留下的数据,甚至产生“单独运行通过、全量运行失败”的顺序依赖。对本项目,建立内存应用的成本很低,隔离优先。

工厂隔离还需要专门验证。两个fixture各自成功并不能证明它们的字典不同:如果源码意外把存储移到全局,单个测试也可能通过。E02会要求在同一个测试中创建app A与B,用A创建资源,再确认B读取同一id返回404。这条测试直接表达隔离要求,避免借助清理全局字典掩盖实现问题。

参数化让“边界”成为可以查看的案例

@pytest.mark.parametrize('quantity, expected', [(1, 1799), (2, 3098)])会让同一测试函数运行两次。前一个字符串声明参数名,后一个列表提供每组值。pytest失败报告会包含具体参数,使“二件商品重复收运费”可以直接定位。参数化适合相同合同的多组输入,不适合把创建、取消、迁移塞进一张难以阅读的大表。

我们还要检查数量1、100、0和True。选择这些值有不同原因:1与100覆盖包含的下上界,0覆盖刚刚越界,True覆盖Python布尔值与整数的特殊关系。再多的随机正整数也替代不了True这个语义边界。对金额同理:0有意义,负数非法,上限用来限制异常输入造成的数据规模。

有些文章遇到失败就使用pytest.raises(Exception)。这个写法太宽:被测代码哪怕因为拼错变量名抛出NameError,也会“通过”。raises适合直接调用会抛异常的Python函数,并应指定预期类型。当前我们主要走HTTP边界,应用已经把业务错误转换成409,所以应该断言响应状态,而不是期望TestClient抛出业务异常。第04课直接测试领域函数时,才会使用pytest.raises(InvalidState)。

运行、筛选,以及一次有目的的破坏

&$PY-m pytest.\lessons\02\test_behavior.py--rootdir.\lessons\02-q &$PY-m pytest.\lessons\02\test_behavior.py--rootdir.\lessons\02-k total-vv

第一条的预期结果是4 passed;第二条只选择名称包含total的测试,显示每个参数化案例的名称。-q让输出简短,-vv增加细节,-k做名称表达式筛选。调试时只跑相关测试能缩短反馈,但提交前应跑完整范围,不能因为某个局部命令通过就声称所有合同都安全。

可以复制01阶段到临时练习目录,把金额公式故意改成“运费也乘数量”,再让测试导入这个副本。quantity=2应失败,quantity=1可能仍通过。这种小实验比追求一个漂亮的测试数量更有教育意义:它验证断言确实能抓住我们在意的错误。不要修改正式快照后忘记恢复;快照是后续章节对照行为的依据。

如果出现ModuleNotFoundError: fastapi,先运行& $PY -m pip show fastapi确认解释器;如果报告零个测试,检查文件名与函数名;如果端口被占用,说明你误启动了Uvicorn,本课TestClient并不需要监听端口。pytest退出码0表示所选测试通过,不表示没有未被测试的需求。

失败报告要帮助你找到原因

pytest会重写assert表达式,失败时通常展示左右两边的实际值。assert response.status_code == 201, response.text的第二部分是失败说明,仅在失败时显示响应内容。这对教学环境中的小型报价请求很有帮助;以后若响应包含敏感数据,则应挑选安全字段输出,不能为了调试把整份业务记录永远写进日志。

测试中预期金额1799和3098应来自业务推导,不要直接调用被测的金额函数计算expected,再拿它与同一函数的输出比较。那样即使公式写错,两边仍然一致。对于大量输入,可以用独立、不共享实现细节的参考公式;对于关键例子,用人工能核算的常量更容易评审。

参数化会把一个函数展开为多个测试案例,所以完整文件虽然只有三个test函数,结果是四个passed。成功数量只能用于确认发现了哪些案例,不能作为质量排名。若某条测试始终被跳过,或者根本没有被收集到,命令也可能没有出现你期望的断言失败;因此第一次运行新文件时应查看收集数量和具体名称。

命令中的--rootdir固定pytest的测试根目录。我们从与源码无关的临时目录执行答案时,如果让工具自行寻找共同父目录,它可能遍历无关目录;Windows用户目录还有指向旧位置的特殊链接,可能出现权限错误。限定测试根目录是在明确验收范围,不是在忽略业务失败。它也不负责修正Python包导入,导入路径仍由答案显式选择阶段。

不要在测试代码里捕获AssertionError然后只打印一条失败消息。那会把测试失败变成正常返回,外部命令看到退出码零,错误反而被隐藏。同样,不要用assert status in (200, 201, 409)宽泛接受多个状态,只因为当前不确定实现会返回什么。先把需求中的唯一预期写清楚,失败再回到合同和实现核对。

当测试套件变大时,准备数据的辅助函数可以统一默认sku和供应商,但每个案例真正关心的字段应留在调用点。比如金额测试直接写quantity=2,读者不用展开三层fixture才知道为什么总额是3098。可读性决定了以后有人修改规则时,能否判断这条失败是发现回归还是测试本身需要更新。

测试代码也是需要维护的代码。删除一条重复断言可以减少噪声,但删除一个不同的失败场景会减少保护范围;评审时要说明它们是否真的验证同一条合同。

E02:证明两份应用互不污染

继续使用checkpoints/01,创建两个独立app和TestClient。在A创建的报价不能在B读取到,B应返回404。再使用参数化分别提交quantity=1、100、0、True,断言前两者201、后两者422;对成功请求断言总额为1299 * quantity + 500。每个案例独立创建资源,不能依赖测试执行顺序。把测试保存为一个可由pytest单独执行的文件;下一课给出完整答案。

本课没有改动业务代码,仍然引用01阶段。保护网的粒度是“客户可观察的合同”;接下来移动目录时,这些测试应继续告诉我们,安装方式改变了,报价行为是否仍然相同。

本课完整测试文件

lessons/02/test_behavior.py

"""第02课:同一基线的四个独立行为案例。"""frompathlibimportPathimportsysimportpytestfromfastapi.testclientimportTestClient STAGE=Path(__file__).resolve().parents[2]/"checkpoints"/"01"sys.path.insert(0,str(STAGE))fromappimportcreate_app# noqa: E402@pytest.fixturedefclient():# function作用域:每个案例得到独立应用,yield后with负责清理客户端。withTestClient(create_app())asvalue:yieldvaluedefpayload(quantity=1):return{"sku":"BOOK-001","supplier":"alpha","quantity":quantity,"unit_cents":1299,"shipping_cents":500,}@pytest.mark.parametrize("quantity, expected",[(1,1799),(2,3098)])deftest_total(client,quantity,expected):response=client.post("/quotes",json=payload(quantity))assertresponse.status_code==201,response.textassertresponse.json()["total_cents"]==expecteddeftest_terminal_state(client):quote=client.post("/quotes",json=payload()).json()path=f"/quotes/{quote['id']}"assertclient.post(path+"/confirm").status_code==200assertclient.post(path+"/confirm").status_code==409assertclient.post(path+"/cancel").status_code==409deftest_missing_quote(client):assertclient.get("/quotes/missing").status_code==404

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询