Python 工程交付:CLI、pytest 与同伴复现

课前先保留判断:课前预习;课后用你的结果复验:课后复习。

今天从哪里开始

订单规则、函数和 JSON 保存都已有了。今天换一位同学来用:他没有你的终端历史,也不知道该先运行哪个文件。如果必须由你在旁边补步骤,这个工具还没交接完成。

我先把操作收进一个清楚的命令行入口,再用 pytest 重复检查正常和失败路径,最后交给同伴从说明文档启动。今天整合已有功能,不另起一套订单系统。

flowchart LR
  A[已有规则与快照] --> B[日志和明确失败]
  B --> C[CLI与测试交付]

今天要学会什么

  • 将已学 while 与程序入口接入累计项目
  • 用装饰器与 wraps 增加日志并保持调用合同
  • 用自定义异常、传播和原因链区分错误
  • 用 pytest、隔离文件与同伴复现交付工具

课堂结束时,用自己的项目检查:

  • 能做出来:独立增加一个边界测试并让错误实现变红,并展示正常输入的结果。
  • 能查出问题:针对“测试共享正式数据、CLI 重写业务、只有测试数量无风险对应”中的风险,说明输入、预期与实际差异;失败后核对数据没有被误改。
  • 能解释修改:提交代码、测试和修改前后结果,讲清自己接受或拒绝了哪一处 AI 建议。

CLI、规则函数和状态各管什么

CLI 输入输出、规则函数和运行时状态的职责

中函数调用后返回调用位置的执行示意

图中示例展示的是两个最小函数,今天把它迁移到 CLI:main() 调用订单规则函数,规则函数返回结果以后,控制流回到 main() 继续展示或进入下一轮。调用不是“跳过去就不回来”,也不是把两段函数代码复制到一起运行。

用户输入 / 菜单
→ CLI 解析与展示
→ 规则函数校验、计算、查询、状态动作
→ 返回业务结果或明确失败
→ CLI 展示给用户

职责边界:

位置负责不负责
src/order_delivery.py读取选择、转换输入、调用函数、展示结果复制金额公式、重新写状态条件
src/order_rules.py业务校验、金额、查询、状态动作自己启动菜单、读取键盘
内存数据与 JSON 文件内存处理本次操作,文件保存需跨运行保留的数据把内存改变当作已经写入文件
tests/检查已声明行为证明所有未知情况都正确

CLI 一旦复制业务规则,前几天的规则修一次、CLI 还要再修一次,两个入口会产生不一致。

CLI 为什么只做薄薄一层?

它负责收集输入、转换格式和展示结果,业务校验与状态变化放在可复用函数或服务中。换成 HTTP 后业务规则才不用复制一份。

while 循环与退出条件

W01-D02 使用 for 处理已知集合;CLI 菜单不知道用户要操作几次,因此常用 while:

running = True
 
while running:
    choice = input("请选择操作:").strip()
    if choice == "0":
        running = False

每个 while 都要能回答:

  • 循环条件是什么?
  • 哪条路径会改变条件或执行 break?
  • 用户输入未知命令时,是继续还是退出?
  • 是否可能永远没有出口?

今天只在命令行界面(Command-Line Interface,CLI)入口使用 while,不把它扩展成新的循环专题。

为什么要写 if name == “main”

def main():
    ...
 
 
if __name__ == "__main__":
    main()

直接运行 python -m src.order_delivery 时会进入 main();其他代码导入 cli.py 时不会自动启动交互菜单。这样测试或后续模块可以导入函数,不会被 input() 卡住。

不用背一句“Python 固定模板”,要能解释它保护了“导入”和“直接运行”两种入口。

为什么导入规则模块时,不应该立即弹出输入菜单?

导入是为了复用定义,自动启动菜单会阻塞测试和其他入口。把启动调用放在 if name == “main” 保护下,函数定义保持可独立导入。

每次报价都要记录结果,怎样不复制一份业务规则

今天客服试算增加“调用记录”:报价开始、成功或被拒绝时有明确记录,计算结果与原规则完全相同。昨天的闭包能返回函数,现在让包装函数在原函数前后做工作,这就是函数装饰器。

先从包装一个只接收 total 的函数开始,画出“外层接函数 → 返回 wrapper → 调用 wrapper → 调用原函数 → 返回原结果”。下面是独立机制例:

from functools import wraps
 
def trace(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print("开始", function.__name__)
        result = function(*args, **kwargs)
        print("成功", function.__name__)
        return result
    return wrapper
 
@trace
def double(value):
    return value * 2
 
print(double(3), double.__name__)

输出开始、成功,随后6与double。@trace 等价于定义函数之后执行 double = trace(double)。D03 的 *args/**kwargs 把参数原样转发,return 把结果原样带回;遗漏 return 会让原来正确的金额变成 None。wraps 保留函数名称、文档等元数据,便于排错,不负责金额校验。

**练习:**只给本人报价入口加装饰器,比较装饰前后258.00与错误数量行为。暂时删除 return 或 wraps,观察哪些检查能发现变化;不要装饰所有函数形成重复日志。

自定义异常:让界面知道哪些失败可以向用户说明

商城用户输入数量错误,与开发者把变量名拼错,是两类事情。我继承 ValueError 为订单输入命名,调用者就能按业务类型处理:

class OrderInputError(ValueError):
    pass
 
def parse_quantity(raw):
    try:
        quantity = int(raw.strip())
    except ValueError as error:
        raise OrderInputError("数量必须是整数") from error
    if quantity < 1:
        raise OrderInputError("数量至少为1")
    return quantity

pass 是没有新增语句的类体;继承让 OrderInputError 同时属于 ValueError。raise ... from error 保留原因链,显示给用户的说明不必丢掉开发排错线索。异常沿“规则 → 服务 → CLI”的调用链向上传播,直到匹配的 except;没有处理就保留 Traceback。except 中单独 raise 是重新抛出当前异常。

我给坏快照命名 SnapshotError。读取函数报告问题且不写文件,CLI 提示保留现场;无文件仍与坏文件区别处理。此处只是 Python 异常分类,尚未涉及 HTTP 状态码。

日志记录发生过什么,异常决定怎样失败

print 适合面向操作人的即时结果;logging 为排错记录级别、来源和事件。入口调用 basicConfig 配置输出,模块用 getLogger(name) 取得自己的记录器。

import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger.info("开始报价")
logger.warning("数量校验未通过")

DEBUG 用于细节,INFO 用于正常事件,WARNING 用于值得关注的拒绝,ERROR 用于失败。记录日志不会把操作变成成功。项目 audit_call 在 except ValueError 中记录拒绝后 raise,else 中记录成功并返回;TypeError 等程序缺陷保留原异常。课堂只记录函数名和结果分类,不输出手机号、令牌或整笔客户资料。

**边界练习:**给被包装函数故意制造 TypeError,应继续暴露;输入0得到业务拒绝;输入2返回258.00。三条检查保护不同边界,不能都改成“返回0”。

自动测试与 pytest:先准备环境,再写断言

手动走过一遍菜单,下一次改代码还得重走。把金额、非法取消和文件恢复的预期写进测试,就能快速发现哪条原有规则被改坏。

手工演示适合观察完整旅程;自动测试把输入、动作和预期保存为可重复执行的检查。本轮统一 pytest:在当前 Python 3.12 虚拟环境运行 python -m pip install pytest==9.1.1,再用 python -m pytest --version 验证;若课程已安装同版本则复用。安装失败先解决环境,不把缺依赖当业务红灯。

测试文件命名 test_.py,测试函数命名 test_。下面是依赖你函数模块的完整测试文件示例,保存到 tests/test_order_rules.py,从项目根运行。

from decimal import Decimal
import pytest
from src.order_rules import calculate_line_total_yuan, cancel_order
 
def test_line_total_uses_decimal_yuan():
    # Arrange 准备;Act 执行;Assert 核对。
    actual = calculate_line_total_yuan("129.00", 2)
    assert actual == Decimal("258.00")
 
def test_paid_order_cannot_be_cancelled():
    order = {"order_id": "O20260827001", "order_status": "PAID"}
    before = order.copy()
    with pytest.raises(ValueError):
        cancel_order(order)
    assert order == before

pytest.raises 表示预期指定异常;没有异常或异常类型不对都会失败。不能捕获 Exception 后直接通过,也不能只检查有错误而不检查业务状态。

在测试文件顶部先导入自己昨日完成的 from src.models import OrderLine, save, load。下面的 tmp_path 是 pytest 提供的对象,框架按测试函数参数名注入,不是 Python 自动出现的全局变量。with pytest.raises(...) 使用昨天已学的 with,要求块内抛出约定异常。

测试隔离与 tmp_path

每个测试自己创建订单,避免共享可变字典。pytest 的 fixture 是测试前准备的资源;tmp_path 是框架提供的独立临时目录。下面是项目片段,复用前一日的 save/load 和模型:

def test_snapshot_roundtrip(tmp_path):
    line = OrderLine("P1001", Decimal("129.00"), 2)
    path = tmp_path / "order.json"
    save(path, line)
    assert load(path) == line

它不会改正式订单文件。损坏文件用另一条测试确认错误被报告且原内容保留。高级 fixture 和参数化可在后续测试中逐步展开。

测试为什么要用 tmp_path?

每次测试操作独立临时文件,避免修改真实订单或与其他测试互相影响。测试结束留下的文件不是正式业务持久化证据。

测试数量要映射业务风险

今天要求不少于 10 条有业务含义的测试,不从零编十个随机数字:

风险组代表行为
金额单明细、两种商品、批量总额、Decimal 元金额
数量1 成功、0 失败、非整数输入由入口处理
资源已知/未知商品、已知/未知订单
状态待支付支付、待支付取消、已支付取消拒绝
无半更新失败后状态前后相同
集合空订单项或空批次有明确行为

十条重复 正常成功路径 只证明同一条规则换了数字。每个测试都要能回答“如果哪条业务规则被改坏,它会先红”。

只演示一次正确下单,能证明工具可交付吗?

还要检查非法数量、损坏文件和状态冲突等失败路径,确认错误可解释且不会破坏原数据;同伴复现与自动测试分别验证不同风险。

红灯 → 最小修复 → 全量回归

红灯、最小修复、目标测试与全量回归

每个人亲手改坏一条已经受保护的规则:

  • 金额从乘法改成加法;
  • 允许 PAID→CANCELLED;
  • 查询未知订单返回第一笔;
  • 数量 0 被接受。

先预测哪个测试会红。若全量仍绿,说明测试没有保护目标。修复时只改目标职责:

python -m pytest tests/test_order_rules.py -k paid_order -q
python -m pytest tests -q
python -m src.order_delivery
git diff

先跑目标测试节省定位时间,随后必须全量回归,防止局部修复破坏其他行为。

README 是同伴的运行合同

最小 README 应包含:

  • 项目是什么、明确不是什么;
  • Python 版本与项目根目录;
  • 目录职责;
  • 怎样运行测试;
  • 怎样启动 CLI;
  • 一条正常旅程和一条失败旅程;
  • 运行时状态与 JSON 保存、恢复的操作方式;
  • 已知限制;
  • 完成状态只写真实做过、能够复验的项目。

交接时除了源码,还应给对方哪些信息?

需要版本、依赖安装方式、启动命令、配置样例、样本输入和预期结果;用固定提交从干净目录复现,补齐作者记忆中的隐藏步骤。

Git 让审查过程可回放

git status --short
git diff
git add .
git commit -m "test: protect paid order cancellation rule"
git log --oneline --decorate -n 10
  • status 看哪些文件变化;
  • diff 看具体改了什么;
  • commit(提交记录) 保存一个有意义的快照;
  • log 让同伴看到开发顺序。

一次把所有文件提交成“final”会丢失红灯、修复和审查过程。

同伴复现

测试在作者电脑通过以后,还剩最后一个问题:别人能不能按说明重现?让同伴只看 README(项目说明),把卡住的位置记下来,再由作者修正文档或代码。

同伴只拿到学生仓、合同和 README,从新终端执行:

  1. 确认 Python 与当前目录;
  2. 运行全量测试;
  3. 启动 CLI;
  4. 创建—查询—支付—非法取消;
  5. 记录环境、前置、输入、实际、预期和复现次数;
  6. 若未发现问题,也写明覆盖范围,不写“绝对无 Bug”。

W01 的复现仍在同类本地环境内。跨干净 Linux 环境重建工程是 W02 的作品验收要求,不能在 W01 提前宣称完成。

测试全绿是否等于同学能运行?

测试依赖当前环境;同伴还需从固定提交安装依赖、找到入口和样本配置。只靠作者记忆完成的隐藏步骤必须补进 README。

回归范围与风险对应

先跑刚失败的目标测试,再跑当前项目全部 tests。单元测试保护规则,模型/文件测试保护序列化和持久化边界,CLI 测试保护输入、输出和退出。真实数据库、HTTP 和 Docker 将在下一周接入,今天的绿灯不能证明那些环境已经可用。

安装或路径错误先按环境手册修复,不能把 ModuleNotFoundError 当作业务红灯。至少保留一个故意改坏规则后测试变红、修复后恢复的证据。

统一测试入口,避免重复学习两套框架

我继续沿用 W01-D03 的普通 assert,今天用 pytest 收集测试。assert actual == expected 是结果判断;pytest.raises(ValueError) 明确预期异常;tmp_path 给每条测试独立目录。项目只保留 pytest 主线,标准库 unittest 作为阅读已有项目时的补充。

from decimal import Decimal
import pytest
 
def test_money_roundtrip():
    assert str(Decimal("129.00")) == "129.00"
 
def test_bad_decimal():
    from decimal import InvalidOperation
    with pytest.raises(InvalidOperation):
        Decimal("不是金额")

以上是独立测试语法例;学生还要为自己的订单规则编写正常、错误、边界测试。先把合法实现改坏一次,确认对应测试会失败,再恢复。测试框架能收集文件,不等于业务风险已经覆盖。

把累计工程交给同伴运行

我保留自己的 src/order_rules.py 与 src/models.py,新增 src/order_delivery.py,将报价、状态操作、价格快照保存恢复接在同一个菜单。日包 rule_cli 的骨架按文件补入,不覆盖本人已经完成的规则和模型。

python -m src.order_delivery
python -m pytest tests -q

入口约定:1报价、2查看当前订单、3模拟支付、4取消、5保存单行快照、6恢复单行快照、7创建订单并选中、8按ID查询并选中、9保存全部订单、10恢复全部订单、0退出。支付与取消作用于当前选中订单;恢复全部先在临时列表完成校验,成功后再替换。5/6说明单行机制,9/10完成累计项目交接。

同伴先走“报价0被拒绝 → 报价2得到258.00 → 模拟支付 → 取消被拒绝 → 状态仍PAID → 保存新快照 → 退出 → 新进程恢复”。分别提交实际输入输出。当前支付只是本地状态模拟,没有调用支付平台或变动资金。

个人再补一条能够击穿错误实现的测试,例如装饰器漏 return、非法取消先改状态、读取坏快照后覆盖。先见红灯、最小修复、旧测试回归,最后用 diff 说明 AI 改动范围。

学生自己动手,AI 只能改你允许改的地方

我先保存当前可运行版本,再完成“独立增加一个边界测试并让错误实现变红”。让 AI 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。

错误与边界怎样验收

今天重点检查:测试共享正式数据、CLI 重写业务、只有测试数量无风险对应。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。

留给下一阶段

这一周从金额计算走到能交接的订单工具:规则可解释、文件能恢复、修改后有测试可查。下一周多人和多对象查询会暴露单文件存储的限制,我们再把已经明确的业务关系迁到 MySQL。

官方资料