
Python后端工程化与测试02修改一行金额公式之前先让pytest替你盯住行为假设现在收到一个需求“把金额计算挪到单独函数里。”你选中一行代码复制、粘贴页面照常能打开就提交了改动。两天后才有人发现运费被乘了数量。问题并不在复制粘贴而在验证只覆盖了quantity1两种公式在这一输入下结果相同。测试的价值不是让每个函数都有一个同名测试文件而是选出能区分正确和错误实现的例子。先兑现上一课的断言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.pyE01直接执行本文件不需要启动Uvicorn。frompathlibimportPathimportsys COURSEPath(__file__).resolve().parents[2]STAGECOURSE/checkpoints/01sys.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)]:responseclient.post(/quotes,json{sku:BOOK-001,supplier:alpha,quantity:quantity,unit_cents:1299,shipping_cents:500,},)assertresponse.status_code201,response.text quoteresponse.json()assertquote[total_cents]totalassertquote[status]draftassertresponse.headers[location]f/quotes/{quote[id]}ids.append(quote[id])pathf/quotes/{ids[0]}confirmedclient.post(path/confirm)assertconfirmed.status_code200assertconfirmed.json()[status]confirmedassertclient.post(path/confirm).status_code409assertclient.post(path/cancel).status_code409assertclient.get(/quotes/does-not-exist).status_code404print(E01 PASS: totals, terminal states, and missing quote)if__name____main__:main()从一个大脚本拆出可定位的失败普通脚本有一个缺点第一个assert失败后面的检查全停了。pytest会发现名称以test_开头的测试函数分别执行并报告结果。我们按照业务场景拆测试创建金额、终态冲突、缺失报价。这样看到失败名称时先知道破坏了哪段合同而不是先去追“第47行是什么”。一个好测试通常可以读成三句准备可控输入执行一次被测行为断言可观察结果。这就是常说的Arrange、Act、Assert。不要为了格式而强行每句只写一行准备复杂数据可以使用辅助函数但辅助函数不能顺手把真正的断言吞掉。下面这个断言仍然关注HTTP输出responseclient.post(/quotes,jsonpayload)assertresponse.status_code201assertresponse.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:yieldvalueyield之前是准备阶段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阶段到临时练习目录把金额公式故意改成“运费也乘数量”再让测试导入这个副本。quantity2应失败quantity1可能仍通过。这种小实验比追求一个漂亮的测试数量更有教育意义它验证断言确实能抓住我们在意的错误。不要修改正式快照后忘记恢复快照是后续章节对照行为的依据。如果出现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和供应商但每个案例真正关心的字段应留在调用点。比如金额测试直接写quantity2读者不用展开三层fixture才知道为什么总额是3098。可读性决定了以后有人修改规则时能否判断这条失败是发现回归还是测试本身需要更新。测试代码也是需要维护的代码。删除一条重复断言可以减少噪声但删除一个不同的失败场景会减少保护范围评审时要说明它们是否真的验证同一条合同。E02证明两份应用互不污染继续使用checkpoints/01创建两个独立app和TestClient。在A创建的报价不能在B读取到B应返回404。再使用参数化分别提交quantity1、100、0、True断言前两者201、后两者422对成功请求断言总额为1299 * quantity 500。每个案例独立创建资源不能依赖测试执行顺序。把测试保存为一个可由pytest单独执行的文件下一课给出完整答案。本课没有改动业务代码仍然引用01阶段。保护网的粒度是“客户可观察的合同”接下来移动目录时这些测试应继续告诉我们安装方式改变了报价行为是否仍然相同。本课完整测试文件lessons/02/test_behavior.py第02课同一基线的四个独立行为案例。frompathlibimportPathimportsysimportpytestfromfastapi.testclientimportTestClient STAGEPath(__file__).resolve().parents[2]/checkpoints/01sys.path.insert(0,str(STAGE))fromappimportcreate_app# noqa: E402pytest.fixturedefclient():# function作用域每个案例得到独立应用yield后with负责清理客户端。withTestClient(create_app())asvalue:yieldvaluedefpayload(quantity1):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):responseclient.post(/quotes,jsonpayload(quantity))assertresponse.status_code201,response.textassertresponse.json()[total_cents]expecteddeftest_terminal_state(client):quoteclient.post(/quotes,jsonpayload()).json()pathf/quotes/{quote[id]}assertclient.post(path/confirm).status_code200assertclient.post(path/confirm).status_code409assertclient.post(path/cancel).status_code409deftest_missing_quote(client):assertclient.get(/quotes/missing).status_code404