☰
Python后端工程化与测试12:覆盖率变绿以后,断言真的能抓住错误吗
2026/10/23 2:48:56 网站建设 项目流程

Python后端工程化与测试12:覆盖率变绿以后,断言真的能抓住错误吗

假设金额函数只有一行,测试调用一次却不检查返回值。覆盖率可以是100%,公式却可以任意写错。再假设测试只用quantity=1,断言也写了,运费被错误地按件数收取仍然不会暴露。这两种漏洞不在同一层:前者缺少有效观察,后者缺少能区分正确与错误实现的输入。本课分别用分支覆盖、性质测试和小规模变异实验检查它们。

测试指标是追问的入口,不是免检证书。我们不会为了漂亮数字删除异常分支,也不会把每行assert都当成高价值断言。目标是在可接受运行时间内,发现违反业务合同的具体实现,并能解释仍有哪些风险没有覆盖。

E11答案:证明失败和恢复属于同一条链

上一课练习故意占住真实SQLite写锁。答案在一个TestClient生命周期内完成“锁住→503→释放→201”,没有重建应用来洗掉可能泄漏的状态。最后检查表中只有一条记录,使503不只是响应外观,还能证明失败写入没有留下残片。

这里的finally是测试可靠性的组成部分。若503断言失败,锁仍然释放;否则下一次测试可能失败在无关位置,你会得到误导性症状。事务锁来自已成功执行的BEGIN IMMEDIATE,测试不用sleep等待,也不依赖机器速度判断何时可以开始请求。

&.\.venv\Scripts\python.exe-m pytest.\lessons\12\answer_previous.py-q

lessons/12/answer_previous.py

"""E11: a real SQLite write lock must fail safely and recover in the same app."""importsqlite3fromcontextlibimportclosingfrompathlibimportPathfromfastapi.testclientimportTestClientfromquoteledger.apiimportcreate_appfromquoteledger.migrationsimportmigratefromquoteledger.settingsimportSettingsfromquoteledger.sqlite_repoimportSQLiteRepositorydeftest_e11_real_lock_failure_then_recovery(tmp_path:Path)->None:database=tmp_path/"e11.db"migrate(database)app=create_app(settings=Settings(database=database,environment="test"),repository=SQLiteRepository(database,timeout=0.01),)payload={"sku":"e11-sku","supplier":"supplier","quantity":2,"unit_cents":5,"shipping_cents":3,}withTestClient(app)asclient:blocker=sqlite3.connect(database,timeout=0.01)try:blocker.execute("BEGIN IMMEDIATE")failed=client.post("/quotes",json=payload,headers={"X-Request-ID":"e11-busy"})assertfailed.status_code==503assertfailed.json()["error"]["code"]=="storage_busy"assertfailed.json()["request_id"]=="e11-busy"assertfailed.headers["X-Request-ID"]=="e11-busy"assertstr(database)notinfailed.textfinally:blocker.rollback()blocker.close()recovered=client.post("/quotes",json=payload)assertrecovered.status_code==201assertrecovered.json()["version"]==1withclosing(sqlite3.connect(database))asconnection:assertconnection.execute("SELECT COUNT(*) FROM quotes").fetchone()[0]==1

分支覆盖到底比行覆盖多知道什么

行覆盖回答哪些可执行行被执行过。分支覆盖进一步观察一个判断可能离开的路径是否被走过,例如合法request_id复用、非法request_id生成,或数据库更新成功、rowcount为0。某行被执行不代表它的两种结果都被观察;而两种结果都走过,也不代表断言检查了正确字段。

项目以应用src为覆盖范围,收集branch,并设置85%的统一门槛。门槛主要防止新增大量未保护代码,是团队约定,不是统计学意义的“85%正确率”。本课命令使用项目的pytest配置,同时输出未覆盖行;在课程根运行时显式传配置路径,避免当前目录影响阈值与收集范围。

&.\.venv\Scripts\python.exe-m pytest-c.\project\pyproject.toml.\project\tests--cov=quoteledger--cov-branch--cov-report=term-missing--cov-fail-under=85

读报告时先看遗漏对应什么风险。创建成功走过、唯一键冲突没走过,意味着覆盖率建议我们补“不能覆盖旧记录”的证据。迁移成功走过、事务中途失败没走过,意味着应该验证失败不标记版本、不留下半成品。相反,某个非常薄的命令行展示分支没有覆盖,不一定比低概率但破坏数据的回滚分支更紧迫。按风险选择新增测试,而不是机械地把红行染绿。

也不要到处添加pragma: no cover。排除语句是在修改度量口径,不能修复行为缺证据的问题。确实不可达或仅特定平台存在的分支,可以说明理由再排除;输入验证、资源释放、异常映射恰恰是应重点保留的部分。本课程未把未知程序错误吞成成功响应来提高通过率。

从几个例子走向一条普遍规则

手工案例告诉我们(quantity=2, unit=5, shipping=3)总价为13。性质测试把它提升成规则:在合法输入域内,总价始终等于单价乘数量加一次运费;扣掉商品部分以后剩下的必须恰好是运费。第二个等式不替代第一个,它以业务语言明确“只收一次”,使评审者更容易看见风险。

Hypothesis的strategy描述输入空间,不是简单的for循环随机数。st.integers(min_value=1,max_value=100)产生合法数量,金额覆盖0和较大的上限;失败时工具尝试缩小反例,给出更容易解释的最小输入。这一点很适合边界问题:一个庞大的金额反例最终可能被缩成quantity=2、shipping=1,直接揭露多收运费的规则。

我们设置max_examples=30控制每项性质的生成预算,deadline=None关闭单次执行时间判定,避免慢机器把语义测试误判成性能失败。它不表示无限运行,也不取消测试框架其他超时控制。30个样例不是穷举所有组合;工具有生成和缩减策略,但你仍要选择有意义的输入域并写下真实业务不变量。

@example(quantity=2, unit_cents=5, shipping_cents=3)保留已知回归案例。即使后续生成策略调整,这个案例也会被执行。发现生产缺陷时,应把缩减后的反例转成稳定、命名清楚的回归检查,不必每次重新依赖随机探索碰到它。Hypothesis的本地示例数据库可帮助重放,但不能替代提交到源码中的关键案例。

这里生成的是整数,不会生成bool。Python里bool是int的子类,HTTP端严格整数验证是否拒绝true,仍由API边界测试承担。若把领域金额性质通过当成HTTP校验正确,就是跨越了证据边界。生成器只对你声明的输入空间说话,不会替你补上所有遗漏需求。

不可变不只是不让赋值

领域对象使用frozen dataclass。一个测试直接尝试属性赋值,确认触发FrozenInstanceError;另一个性质测试保存原始对象完整字段,执行合法转换后检查原对象完全未变,新对象具有新状态与version+1,而id、总金额、created_at保持。两份证据不同:前者约束对象表面的写入行为,后者约束业务函数没有拿另一个身份或重新计算时间冒充更新。

只写assert changed != original不够,因为把金额翻倍也会使对象不相等。只写assert changed.status == target也不够,因为实现可能原地修改了旧对象。我们同时检查对象身份和业务身份:changed is not original表示新对象,changed.id == original.id表示仍是同一张报价单。Python的is与==在这里承担不同含义,不要混用。

终态性质针对confirmed和cancelled,再尝试两个目标,都必须抛InvalidState。调用时传终态对象的当前version,刻意绕开过期版本分支。若拿旧version=1测试,只能再次验证CAS前置检查,终态规则可能完全没执行。性质测试不天然比手工案例深,仍要理解规则的判定顺序。

下面是本课完整性质测试。它直接使用公开领域接口,不创建应用、不访问磁盘,运行快且失败信息集中。HTTP、SQLite落盘和线程原子性的证据已经放在对应层次;不要把所有层混进每个生成案例,使一次缩减都要启动服务器和数据库。

project/tests/test_course_properties.py

"""Properties state business rules; they do not replace HTTP or database tests."""fromdataclassesimportFrozenInstanceError,asdictfromdatetimeimportUTC,datetimeimportpytestfromhypothesisimportexample,given,settingsfromhypothesisimportstrategiesasstfromquoteledger.domainimportCreateQuote,InvalidState,make_quote,transition@settings(max_examples=30,deadline=None)@example(quantity=2,unit_cents=5,shipping_cents=3)@given(quantity=st.integers(min_value=1,max_value=100),unit_cents=st.integers(min_value=0,max_value=100_000_000),shipping_cents=st.integers(min_value=0,max_value=100_000_000),)deftest_total_charges_shipping_once(quantity:int,unit_cents:int,shipping_cents:int)->None:quote=make_quote(CreateQuote("property-sku","supplier",quantity,unit_cents,shipping_cents),quote_id="property-quote",now=datetime(2026,10,9,tzinfo=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(min_value=1,max_value=100),unit_cents=st.integers(min_value=0,max_value=100_000_000),shipping_cents=st.integers(min_value=0,max_value=100_000_000),confirm=st.booleans(),)deftest_transition_preserves_original_and_business_identity(quantity:int,unit_cents:int,shipping_cents:int,confirm:bool)->None:original=make_quote(CreateQuote("property-sku","supplier",quantity,unit_cents,shipping_cents),quote_id="property-quote",now=datetime(2026,10,9,tzinfo=UTC),)before=asdict(original)target="confirmed"ifconfirmelse"cancelled"changed=transition(original,target,1)assertchangedisnotoriginalassertasdict(original)==beforeassertchanged.status==targetassertchanged.version==original.version+1assertchanged.id==original.idassertchanged.total_cents==original.total_centsassertchanged.created_at==original.created_atfornext_targetin("confirmed","cancelled"):withpytest.raises(InvalidState):transition(changed,next_target,changed.version)deftest_quote_rejects_attribute_assignment()->None:quote=make_quote(CreateQuote("sku","supplier",1,20),quote_id="fixed",now=datetime(2026,10,9,tzinfo=UTC),)withpytest.raises(FrozenInstanceError):setattr(quote,"quantity",99)
&.\.venv\Scripts\python.exe-m pytest.\project\tests\test_course_properties.py-q

故意改错一次,看看断言是否会报警

变异实验从一个具体怀疑出发:如果有人把运费按件数计费,现有测试能否发现?我们只改临时副本中total_cents的公式,原始project完全保留。先在副本运行正确版本,确认测试能导入、依赖齐全并通过;再替换成unit_cents * quantity + shipping_cents * quantity,运行同一个金额性质测试,要求pytest以测试失败的退出码1结束。

为什么先跑基线?如果副本本来就导入失败,变异后的非零退出码没有价值。为什么不仅看“退出码不是0”?pytest的收集错误、参数错误或进程超时都不是测试杀死变异的证据。演示脚本还检查输出中确实出现目标测试和AssertionError,避免把环境故障误报成断言有效。

脚本使用TemporaryDirectory,只复制项目src及目标测试到临时目录。子进程cwd设为副本,PYTHONPATH指向副本src,这样已安装的editable工程不会抢先被导入。-p no:cacheprovider减少演示留下的缓存;Hypothesis示例库也留在临时目录。退出时副本整体清理,实验不会在主源码上先改后还原,因此即使中途中断,也没有“忘记恢复”的风险。

lessons/12/mutation_demo.py

"""Run one intentional semantic mutation only in an automatically removed copy."""from__future__importannotationsimportastimporthashlibimportjsonimportosimportshutilimportsubprocessimportsysimporttempfilefrompathlibimportPathdefrun()->None:course=Path(__file__).resolve().parents[2]project=course/"project"original=project/"src"/"quoteledger"/"domain.py"before=hashlib.sha256(original.read_bytes()).hexdigest()withtempfile.TemporaryDirectory(prefix="quoteledger-mutation-")astemporary:sandbox=Path(temporary)command=[sys.executable,"-m","pytest","--rootdir",str(sandbox),"tests/test_course_properties.py::test_total_charges_shipping_once","-q","-p","no:cacheprovider",]shutil.copytree(project/"src",sandbox/"src",ignore=shutil.ignore_patterns("__pycache__","*.pyc"),)(sandbox/"tests").mkdir()shutil.copy2(project/"tests"/"test_course_properties.py",sandbox/"tests"/"test_course_properties.py",)environment=os.environ.copy()environment["PYTHONPATH"]=str(sandbox/"src")environment["PYTHONUTF8"]="1"environment["PYTHONDONTWRITEBYTECODE"]="1"environment["HYPOTHESIS_STORAGE_DIRECTORY"]=str(sandbox/".hypothesis")defpytest_run()->subprocess.CompletedProcess[str]:returnsubprocess.run(command,cwd=sandbox,env=environment,capture_output=True,text=True,encoding="utf-8",timeout=60,check=False,)baseline=pytest_run()ifbaseline.returncode!=0:raiseRuntimeError(f"Baseline did not pass:\n{baseline.stdout}{baseline.stderr}")copied_domain=sandbox/"src"/"quoteledger"/"domain.py"source=copied_domain.read_text(encoding="utf-8")tree=ast.parse(source)quote_class=next(itemforitemintree.bodyifisinstance(item,ast.ClassDef)anditem.name=="Quote")total_property=next(itemforiteminquote_class.bodyifisinstance(item,ast.FunctionDef)anditem.name=="total_cents")returns=[itemforiteminast.walk(total_property)ifisinstance(item,ast.Return)]iflen(returns)!=1orreturns[0].end_linenoisNone:raiseRuntimeError("Expected exactly one return in Quote.total_cents")statement=returns[0]lines=source.splitlines(keepends=True)indent=" "*statement.col_offset lines[statement.lineno-1:statement.end_lineno]=[f"{indent}return self.unit_cents * self.quantity + self.shipping_cents * self.quantity\n"]mutation="".join(lines)ast.parse(mutation)# A syntax error is not evidence of a useful assertion.copied_domain.write_text(mutation,encoding="utf-8")mutant=pytest_run()output=mutant.stdout+mutant.stderrifnot(mutant.returncode==1and"test_total_charges_shipping_once"inoutputand"AssertionError"inoutput):raiseRuntimeError(f"The intended mutation was not rejected:\n{output}")after=hashlib.sha256(original.read_bytes()).hexdigest()ifafter!=before:raiseRuntimeError("Original domain.py changed while the experiment was running")report={"experiment":"shipping charged once versus shipping per item","baseline_exit":baseline.returncode,"mutant_exit":mutant.returncode,"original_sha256_before":before,"original_sha256_after":after,"baseline_output":baseline.stdout+baseline.stderr,"mutant_output":output,}report_path=course/"reports"/"lesson-12-mutation.json"report_path.parent.mkdir(parents=True,exist_ok=True)report_path.write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding="utf-8")print("Baseline passed; intentional mutation failed with AssertionError.")print("Original domain.py SHA-256 unchanged.")print(output)print(f"Report:{report_path}")if__name__=="__main__":run()
&.\.venv\Scripts\python.exe.\lessons\12\mutation_demo.py

预期先看到baseline通过,再看到变异版本在test_total_charges_shipping_once失败,显式回归例计算出错误总价16而预期13。命令本身在成功完成这个预期失败实验后返回0;它表示演示完成,不表示变异实现正确。完整实际运行记录保存在课程reports,不能把本段预期输出当作运行证据。

一次被杀死的变异,只说明这类错误能被当前测试识别。它不能证明其他运算错误、SQL字段错位或并发缺陷都会被抓住;有些变异在既定输入域内与原实现等价,也不应该为了提高“杀死率”写无意义断言。成熟项目可用变异测试工具批量执行,本课选择一个可解释的小实验,让成本和证据对应得上。

E12:把不变量写清,再用临时副本检验一次

使用最终 project 的领域公开接口和 Hypothesis(每项 max_examples=30,deadline=None),生成 quantity=1…100、unit_cents 与 shipping_cents=0…100000000 的严格整数,并同时检验 total_cents=unit_centsquantity+shipping_cents、运费只收一次;对 draft 执行合法 confirm/cancel 后,原 Quote 的字段保持不变,新对象 id/金额/created_at 不变,状态为目标且 version 恰增1;对每种终态再尝试两个目标都抛 InvalidState。保留 quantity=2、unit_cents=5、shipping_cents=3 的显式回归例。至少解释一个性质无法证明的边界(HTTP校验、SQLite持久化或线程原子性)。最后在临时副本中把 total_cents 故意改为 unit_centsquantity+shipping_cents*quantity,运行金额性质测试并保留失败摘要;原始源码不得改动。

下一课提供完整答案,并把Ruff、严格应用类型检查和回归测试组成重构门槛。它们各自发现不同类别的问题;格式通过、类型通过、测试通过,三者都不能单独替代对业务合同的理解。

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

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

立即咨询