Python后端工程化与测试14:你电脑上全绿,为什么合并后别人还是跑不起来
开发者提交前忘了运行mypy,或者只在自己电脑里装过一个没有写进依赖的包,代码都可能在合并后才出问题。团队不能靠“每个人记得点一遍”保证质量。持续集成的实际作用,是让每次变更在干净环境中执行约定的检查,并把结果绑定到那次变更,而不是额外发明一套只在服务器上才能理解的魔法命令。
这一课把上一课的quality入口放进GitHub Actions。我们会逐段解释工作流文件、验证失败退出怎样向上传递,再讨论代码审查应关注什么。课程不会向你的GitHub账户创建仓库或推送,也不会把本地执行结果说成远程工作流已经通过;提供的是可以放进仓库的配置和已经在本地跑过的同源命令。
E13答案:非零退出码才是拒绝信号
从课程根目录运行python lessons/14/answer_previous.py。完整脚本在临时目录依次制造类型错误和格式错误,不改正式应用。每次调用外部工具都保存stdout、stderr和returncode,并对结果作断言。预期输出最后包含:
E13 PASS: type and format gates reject defects, repaired behavior stays correct类型错误阶段不是检查终端有没有红色文字,而是检查返回码非零,且输出包含return-value。工具在CI里通常没有交互式彩色终端,但退出码仍然稳定表达成功或失败。修复后mypy返回0,说明这个具体类型矛盾已经消失。
格式错误则由formatter判定。在format --check失败后,脚本运行真正的格式化命令,再次check成功。最后执行total(10)==13,把“形式改变”和“行为保持”联系起来。仅检查格式变绿会漏掉有人在修格式时顺手改公式的问题;这个例子很小,但审查大型格式PR时原则相同。
报告保存在reports/quality-failure-exercise.json。它展示的是两种已知缺陷会被抓住,不是证明工具能找出所有缺陷。把门禁当作有限但可依赖的检查,比把它描述成自动代码审查员更准确。
GitHub工作流文件放在哪里
交付文件位于delivery/.github/workflows/quality.yml。把整个课程目录作为仓库根目录时,将delivery下的.github复制到仓库根目录即可。GitHub识别的是根目录的.github/workflows,不会自动读取一个任意命名的delivery子目录。这里把配置放在delivery中,是为了让发布动作由你在目标仓库中明确完成。
工作流假设仓库根目录有requirements-verified.txt、project和tools,与课程完整目录一致。如果只把project上传,路径就需要相应调整,不能原样照搬根目录布局不同的YAML。日常排查“CI找不到文件”,第一步就应该检查checkout后实际目录,而不是先重装所有依赖。
on: push和pull_request分别让提交与PR触发检查;permissions: contents: read足够读取仓库,本课不需要写仓库、推标签或上传PyPI的凭据。job使用Python3.11的Linux runner,并设置总时间上限,避免一个异常测试无限占用执行资源。这是计划运行的平台配置,实际远程结果应在目标仓库第一次执行后检查。
一个job里的五件事
第一步checkout取得本次提交的代码。第二步setup-python安装或选择3.11,并按依赖清单建立pip下载缓存。缓存用于减少重复下载,不能替代安装命令,也不应该把开发者的整个venv作为跨系统缓存。Windows与Linux的二进制扩展和解释器路径不同,直接复用虚拟环境通常不可取。
第三步安装固定工具链,再安装当前project。依赖文件列出交付时验证过的版本;--no-build-isolation在这里有明确前提:前一步已经安装setuptools和wheel等构建工具,不需要构建器再创建另一份隔离环境下载它们。普通项目也可以使用默认构建隔离,关键是知道两种模式的依赖来自哪里,不要为了“命令短”随便关掉安全边界。
第四步只有python tools/quality.py。我们没有在YAML里复制另一套pytest参数,所以本地改变门槛或覆盖范围时,CI也执行同一入口。入口任一步失败都会非零退出,这个step失败,后面的正常构建step默认不再执行。除非有明确理由,不要给测试step加continue-on-error: true,否则红灯也可能被包装成可合并结果。
第五步构建wheel和源码分发包,用upload-artifact保存dist目录。artifact是与这次运行关联的可下载文件,并不等于发布到PyPI。工作流没有发布凭据和发布动作,读者可以先检查构建内容、在另一环境安装,再决定是否发布。把“构建”“保存”“发布”当作不同状态,能避免一段复制来的YAML带来意外外部动作。
PR里该让别人看什么
一个重构PR可以这样描述:“此前HTTP路由直接访问SQLite,测试金额规则也要起数据库;现在路由调用QuoteService,金额和状态规则提取为纯领域函数,响应合同保持;原有合同测试与新增仓库契约通过。”这比列出“增加domain.py、service.py、repository.py”更容易让审查者判断价值。
若PR新增expected_version,应明确写出前后行为:两个操作者基于同一个草稿版本提交不同操作时,以前后写覆盖前写,现在一个成功、另一个409。它不是内部改名,调用方必须增加请求字段。迁移如何给旧记录补version1、发布顺序和失败恢复,也应该与代码一起审查。
审查时先沿一条请求走:输入在哪校验,时间与ID谁生成,事务由谁拥有,错误在哪转换,日志会不会泄露请求体。再看测试是否通过公开接口验证结果,而不是把每个私有方法都mock掉。一个看起来分层整齐的项目,如果测试只能证明“某个方法被调用一次”,仍可能在事务失败后留下坏数据。
当CI红灯而本地全绿
先对齐解释器版本、依赖文件、执行目录和同一个提交。开发者电脑的editable安装可能指向另一份源码;CI刚checkout的路径则没有这种历史状态。检查quoteledger.__file__往往比怀疑整个pip系统更有效。对齐环境后仍失败,再看是否测试依赖真实时间、用例顺序或共享临时文件。
Windows上大小写不敏感的路径,在Linux上可能找不到文件;文件没有提交、本地却存在,也会造成类似症状。不要在CI里临时sys.path.append让导入侥幸通过,应修正包布局与分发内容。第15课会专门在源码目录之外安装wheel,尽早发现这一类问题。
如果是时间断言偶发失败,不要第一反应加三次重跑。锁与竞争测试可以用Barrier/Event确定执行关系;真实超时仍需要宽容度和明确预算。重跑能缓解基础设施偶发失败,但如果它只让不确定的测试偶尔变绿,就会慢慢降低团队对红灯的信任。
练习E14:失败不能被下一条命令冲掉
在临时目录搭建最小project/src、project/tests和tools布局,复制本课quality入口,在src里写一个引用undefined_name的函数。执行quality,断言整体命令非零退出、输出包含F821,并且没有继续打印mypy阶段命令。正式源码保持不变。
再检查工作流:调用同一个quality入口、成功后构建dist、通过upload-artifact保存wheel或源码包,没有PyPI发布动作。说明“本地脚本拒绝错误”与“GitHub runner已执行成功”两种证据的区别。下一篇提供完整可运行答案,并开始验证真正要交给别人的安装包。
本课完整答案与工作流
quality.py沿用上篇,无需为CI重写。下面给出E13答案和可以复制到目标仓库的完整YAML。为方便核对,本课也列出同源质量入口;任何参数更新必须在这里同步。
lessons/14/answer_previous.py
"""E13:让质量工具在隔离副本中先拒绝坏代码,再接受修复。"""importjsonimportosfrompathlibimportPathimportsubprocessimportsysimporttempfile ROOT=Path(__file__).resolve().parents[2]defmain():cases=[]withtempfile.TemporaryDirectory(prefix="quality-failure-")asfolder:root=Path(folder);target=root/"sample.py"# 独立小模块让每种工具的职责清楚;不改应用生产源码。target.write_text('def total(value: int) -> int:\n return "wrong"\n',encoding="utf-8")defrun(args):env=os.environ.copy();env["PYTHONUTF8"]="1"returnsubprocess.run([sys.executable,*args],cwd=root,env=env,capture_output=True,text=True,encoding="utf-8",timeout=30)failed=run(["-m","mypy","--strict",str(target)])assertfailed.returncode!=0and"return-value"infailed.stdout cases.append(dict(case="type_rejected",output=failed.stdout))target.write_text('def total(value: int) -> int:\n return value + 3\n',encoding="utf-8")assertrun(["-m","mypy","--strict",str(target)]).returncode==0target.write_text('def total( value:int)->int:\n return value+3\n',encoding="utf-8")failed=run(["-m","ruff","format","--check",str(target)])assertfailed.returncode!=0cases.append(dict(case="format_rejected",output=failed.stdout+failed.stderr))fixed=run(["-m","ruff","format",str(target)])assertfixed.returncode==0assertrun(["-m","ruff","format","--check",str(target)]).returncode==0assertrun(["-m","mypy","--strict",str(target)]).returncode==0result=run(["-c","from sample import total; assert total(10)==13; print('behavior=13')"])assertresult.returncode==0cases.append(dict(case="repaired",output=result.stdout,source=target.read_text(encoding="utf-8")))(ROOT/"reports/quality-failure-exercise.json").write_text(json.dumps(dict(status="PASS",cases=cases),ensure_ascii=False,indent=2),encoding="utf-8")print("E13 PASS: type and format gates reject defects, repaired behavior stays correct")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()delivery/.github/workflows/quality.yml
name:QuoteLedger qualityon:push:pull_request:permissions:contents:readjobs:quality:runs-on:ubuntu-latesttimeout-minutes:15steps:-uses:actions/checkout@v4-uses:actions/setup-python@v5with:python-version:'3.11'cache:pipcache-dependency-path:requirements-verified.txt-name:Install verified toolchain and applicationrun:|python -m pip install -r requirements-verified.txt python -m pip install --no-build-isolation -e ./project-name:Run the same local quality gatesrun:python tools/quality.py-name:Build distributionsrun:python-m build--no-isolation--outdir dist project-name:Save build artifactsuses:actions/upload-artifact@v4with:name:quoteledger-distributionspath:dist/*if-no-files-found:error