☰
Python后端工程化与测试13:代码能跑,还需要Ruff和类型检查吗
2026/10/24 6:32:26 网站建设 项目流程

Python后端工程化与测试13:代码能跑,还需要Ruff和类型检查吗

一次重构后,pytest全部通过,但同事发现两个问题:新函数声明返回整数,某条分支却返回了字符串;另一个文件仅调整了格式,代码审查里却出现几十行无意义差异。前者可能是测试未覆盖的类型错误,后者让真正的业务变化更难被看见。它们不适合靠增加十个类似的接口测试解决,也不应该全推给审查者的眼力。

这一课为QuoteLedger建立三道各司其职的检查:Ruff处理静态错误与统一格式,mypy检查应用的类型合同,pytest继续验证运行行为。它们最终进入同一个命令入口,下一篇再把这个入口放进持续集成。我们会故意制造坏代码,证明这些检查能够拒绝错误,避免把“装了几个工具”当成工程化完成。

E12答案:随机生成之前,先保留能手算的例子

上一课要求金额、不可变对象和状态机三组性质。完整答案位于lessons/13/test_answer.py,在课程根目录、安装好最终项目后执行:

python-m pytest lessons/13/test_answer.py-q python lessons/13/mutation_answer.py

第一个命令运行两项性质测试,每项max_examples=30,并关闭单例执行时间上限deadline=None。关闭deadline是为了不把机器忙碌误判为业务失败,不是给测试整个进程无限运行时间;全套验收仍会限制命令总时间。第一项生成合法数量和金额,直接断言单价乘数量再加一次运费。显式@example(quantity=2, unit_cents=5, shipping_cents=3)保证13分这条重要回归始终出现,不依赖随机生成碰巧选到。

第二项先保存原Quote的字段快照,再执行合法确认或取消,检查旧对象保持原样,新对象身份、金额和创建时间不变,只有目标状态和递增一次的version变化。最后对两个终态再次尝试两种转换,都必须抛InvalidState。这里传入当前version,故意排除版本冲突的干扰:我们正在验证终态规则,而不是把两个错误混在一个断言中。

性质测试没有证明数据库并发原子性。它只调用领域公开函数,不创建真实SQLite文件;同样没有证明HTTP入口拒绝布尔值或多余字段。因此前两篇的API、真实数据库与竞态测试仍要保留。测试类型之间应补充证据,不能因为生成了几十个例子,就替换其他层的测试。

第二个命令只在临时目录复制domain.py,把运费公式故意改成随数量重复收费,再运行金额测试。预期这个子测试退出1并出现AssertionError;外围答案脚本确认它确实失败,才报告实验通过。reports/mutation-e12.json保存失败摘要,原始源码前后字节一致。如果变异后测试仍然全绿,说明断言或生成空间存在缺口,不能简单宣布“变异实验完成”。

Ruff不是另一套业务测试

Ruff可以发现未定义名称、未使用导入、部分明显错误,并统一导入和格式。比如把quote_id误拼成未定义的qoute_id,这种错误不必等某个HTTP请求刚好走到那条分支才暴露。它不会知道“运费应该只收一次”,因为错误公式仍然是合法Python,业务测试才理解这个承诺。

格式检查的价值也不只是看起来整齐。所有人使用同一格式后,一个PR里出现的差异更接近真实逻辑变化;审查者不用反复讨论空格和换行。不要在修复状态机的一次提交中顺手格式化全仓库,造成几百行噪音。可以先单独提交纯格式变更,再提交逻辑改动,每一步都运行测试。

在project目录运行:

python-m ruff check src tests python-m ruff format--check src tests

check运行lint规则;format --check只判断当前文件是否符合格式,不修改文件。开发中确认需要修正时可以运行ruff format src tests,再查看变化。check --fix也只能自动解决一部分问题,不能把它理解成“一键修复程序”。规则来自pyproject,范围是应用与测试;早期历史阶段保留当课形态,不要求为了最终格式统一而篡改学习过程。

mypy为什么能发现测试没执行到的问题

假设函数签名是def total(value: int) -> int,实现却return "wrong"。mypy无需执行函数就能发现返回值违反类型声明;pytest只有在某条测试调用并检查相应返回值时才可能发现。静态分析与动态测试的工作方式不同,所以组合使用才有意义。

应用源码启用严格检查,是为了让模块边界可信:仓库返回Quote而不是任意字典,service获取的是明确的clock与IDFactory,状态值有有限范围。不要为了快速变绿在配置里加入ignore_errors=true,那相当于把门禁的报警器拔掉。遇到类型报错先读数据从哪里进入、怎样缩小范围;确实需要类型断言时,也应该解释该断言依据什么运行约束。

类型检查不会执行Pydantic,不会证明来自网络的JSON真的满足注解。quantity: int只是开发者承诺,HTTP请求仍需要严格输入模型,领域构造仍需要业务校验。尤其bool是int的子类,外部输入的“严格整数”合同不能只依靠静态提示。你在类型工具里看到全绿,只代表它在给定分析范围内未发现矛盾,不是安全审计或完整正确性证明。

python-m mypy src

本课程严格检查应用src,测试主要由Ruff和实际执行保护。这个范围是有意选择:测试中的动态夹具、参数化和故障注入与应用公共合同承担的责任不同。如果团队希望测试也严格typing,可以逐步扩大范围;不要在没有实践需求时为了追求“所有工具全部最严格”让教程变成注解体操。

一条门禁命令如何保持可解释

从课程根目录运行:

python tools/quality.py

脚本按顺序运行lint、format检查、mypy,最后pytest分支覆盖。每步使用sys.executable -m,确保调用当前虚拟环境的模块,而不是PATH里另一个Python装的工具;工作目录明确为project,避免读错配置。任何一步非零退出,脚本立刻同样失败,后面的绿色结果不能覆盖前面的失败。

测试阶段使用--cov=quoteledger --cov-branch,覆盖应用包并统计分支。当前85%门槛针对启用分支统计后的综合覆盖率:已覆盖语句与分支合计,除以可执行语句与分支合计,并非仅分支比例。本轮综合91.09%,其中语句93.72%、分支77.50%,具体原始数字见验收说明。这个门槛帮助发现覆盖明显退化,不是“正确率85%”。看到缺失行应先理解风险:事务回滚、状态冲突和异常关闭值得优先补;为了提高百分比专门断言日志字符串是否存在,却不检查失败后的数据状态,可能仍然放过关键错误。

覆盖报告生成reports/coverage.json,供本次验收记录。应用各模块的覆盖数字以实际执行为准;不要用一个总百分比掩盖迁移或仓库完全没测。变异实验则补上另一条证据:至少针对运费规则,错误实现确实会被现有测试发现。它只是一个小型、透明的变异演示,没有声称实现完整mutation testing平台。

工具报错时怎样处理

格式错误先确认是否仅空白变化,运行formatter后重新看diff。lint出现F821先找名称来源,不要在错误行加忽略。mypy报告return-value时沿函数全部分支检查返回类型,尤其异常后有没有返回None;不要用cast把一个真正的字符串宣称成整数。pytest失败则回到业务合同,判断是实现错误、测试预期错误,还是有意改变了外部行为。

有意新增version合同属于功能变更,应更新调用方、API模型和对应测试。把数据库访问提取成仓库属于重构,正常业务响应应该保持。两者若混在一个大提交中,很难判断失败来自哪一部分。本课程保留阶段目录就是为了让这种差异可查看;日常工作则可以借助小提交和代码审查保持同样的清晰度。

练习E13:让门禁先红一次

在临时目录新建小模块,声明接收int并返回int,却返回字符串。运行mypy strict并断言命令非零退出且报告return-value;修成return value + 3后必须通过。接着故意破坏该模块的格式,ruff format --check必须失败,格式化后再次检查必须通过,最后调用函数确认total(10)==13。

不要改坏正式project,不要只打印“已模拟失败”。必须检查子进程退出码,保留诊断输出,并说明格式修复为什么没有改变业务。下一篇给出完整答案与运行结果。这样你会亲眼看到每一道门禁拦住的究竟是哪一种问题。

本课完整代码

下面列出E12性质、隔离变异和统一质量入口的完整实现,另列项目质量配置。配置文件中的参数与上文命令共同定义检查范围,不能把配置当装饰。

lessons/13/test_answer.py

"""E12答案:金额、不可变性、版本与终态性质,各30个生成例子。"""fromdataclassesimportasdictfromdatetimeimportdatetime,timezonefromhypothesisimportexample,given,settings,strategiesasstimportpytestfromquoteledger.domainimportCreateQuote,InvalidState,make_quote,transition@settings(max_examples=30,deadline=None)@example(quantity=2,unit_cents=5,shipping_cents=3)@given(quantity=st.integers(1,100),unit_cents=st.integers(0,100000000),shipping_cents=st.integers(0,100000000))deftest_amount_charges_shipping_once(quantity,unit_cents,shipping_cents):quote=make_quote(CreateQuote("KB-001","atlas",quantity,unit_cents,shipping_cents),quote_id="fixed-id",now=datetime(2026,1,1,tzinfo=timezone.utc))assertquote.total_cents==unit_cents*quantity+shipping_centsassertquote.total_cents-unit_cents*quantity==shipping_cents@settings(max_examples=30,deadline=None)@given(quantity=st.integers(1,100),unit_cents=st.integers(0,100000000),shipping_cents=st.integers(0,100000000),target=st.sampled_from(["confirmed","cancelled"]))deftest_transition_keeps_original_and_rejects_terminal(quantity,unit_cents,shipping_cents,target):before=make_quote(CreateQuote("KB-001","atlas",quantity,unit_cents,shipping_cents),quote_id="fixed-id",now=datetime(2026,1,1,tzinfo=timezone.utc))snapshot=asdict(before)changed=transition(before,target,expected_version=1)assertasdict(before)==snapshotassertchangedisnotbeforeassertchanged.id==before.idandchanged.created_at==before.created_atassertchanged.total_cents==before.total_centsassertchanged.status==targetandchanged.version==2fornext_targetin["confirmed","cancelled"]:withpytest.raises(InvalidState):transition(changed,next_target,expected_version=2)

lessons/13/mutation_answer.py

"""E12变异答案:临时复制领域模块,证明错误的运费公式会被测试抓住。"""importjsonimportosfrompathlibimportPathimportshutilimportsubprocessimportsysimporttempfile ROOT=Path(__file__).resolve().parents[2]defmain():original=ROOT/"project/src/quoteledger/domain.py"original_bytes=original.read_bytes()source=original.read_text(encoding="utf-8")old="self.unit_cents * self.quantity + self.shipping_cents"new="self.unit_cents * self.quantity + self.shipping_cents * self.quantity"assertsource.count(old)==1,"领域公式发生变化,需要先审阅变异位置,不能猜测替换"withtempfile.TemporaryDirectory(prefix="quoteledger-mutation-")asfolder:sandbox=Path(folder)package=sandbox/"quoteledger";package.mkdir()(package/"__init__.py").write_text("",encoding="utf-8")(package/"domain.py").write_text(source.replace(old,new),encoding="utf-8")shutil.copyfile(Path(__file__).with_name("test_answer.py"),sandbox/"test_mutant.py")env=os.environ.copy();env["PYTHONPATH"]=str(sandbox);env["PYTHONUTF8"]="1"result=subprocess.run([sys.executable,"-m","pytest","-q","--rootdir",str(sandbox),"test_mutant.py::test_amount_charges_shipping_once"],cwd=sandbox,env=env,capture_output=True,text=True,encoding="utf-8",timeout=30)assertresult.returncode==1,result.stdout+result.stderrassert"AssertionError"inresult.stdoutand"explicit example"inresult.stdout.lower(),result.stdoutassertoriginal.read_bytes()==original_bytes report=dict(status="PASS",expected_test_exit=1,source_unchanged=True,output=result.stdout+result.stderr)(ROOT/"reports/mutation-e12.json").write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding="utf-8")print("PASS: mutant killed by explicit amount regression; original domain unchanged")if__name__=="__main__":main()

tools/quality.py

"""本地与CI复用的质量门禁,任一检查失败立即停止。"""frompathlibimportPathimportosimportsubprocessimportsys ROOT=Path(__file__).resolve().parents[1]PROJECT=ROOT/"project"defmain():commands=[["-m","ruff","check","src","tests"],["-m","ruff","format","--check","src","tests"],["-m","mypy","src"],["-m","pytest","-q","--cov=quoteledger","--cov-branch","--cov-report=term-missing","--cov-report=json:../reports/coverage.json","--cov-fail-under=85"],]env=os.environ.copy()env["PYTHONUTF8"]="1"(ROOT/"reports").mkdir(exist_ok=True)forcommandincommands:print("python "+" ".join(command),flush=True)result=subprocess.run([sys.executable,*command],cwd=PROJECT,env=env)ifresult.returncode:raiseSystemExit(result.returncode)print("All quality gates passed")if__name__=="__main__":main()

project/pyproject.toml

[build-system] requires = ["setuptools>=75"] build-backend = "setuptools.build_meta" [project] name = "quoteledger" version = "1.0.0" description = "报价单工程化、测试与重构课程项目" readme = "README.md" requires-python = ">=3.11" dependencies = ["fastapi==0.115.12", "pydantic==2.13.5", "uvicorn==0.34.2"] [project.optional-dependencies] dev = ["pytest==8.3.5", "httpx==0.28.1", "pytest-cov==6.1.1", "hypothesis==6.131.9", "ruff==0.11.13", "mypy==1.15.0", "build==1.2.2.post1"] [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] target-version = "py311" line-length = 88 [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I"] [tool.mypy] python_version = "3.11" strict = true files = ["src"] [tool.coverage.run] branch = true source = ["quoteledger"] [tool.coverage.report] show_missing = true fail_under = 85

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

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

立即咨询