第 5 章 编码与单元测试
学习目标
- 理解"编码是设计的精确化"这一原则
- 掌握编码规范与可读性实践
- 理解重构(refactoring)及其安全网(自动化测试)
- 掌握单元测试的基本原则(Arrange-Act-Assert、隔离、可重复)
- 理解 TDD(测试驱动开发)的红-绿-重构循环
- 了解代码覆盖率与静态分析
- 掌握代码评审(Code Review)的要点
5.1 编码的定位
- 编码不是软件工程的主体,而是把设计精确化为机器可执行形式;
- 但代码是最终交付物,也是缺陷的聚集地,因此编码质量直接决定维护成本;
- 两条纪律:
- 代码即文档——好代码自解释,注释解释"为什么"而非"是什么";
- 先有安全网,再做修改——没有测试保护的代码,任何重构都是赌博。
编码阶段的输入-输出
| 输入 | 输出 | 质量判据 |
|---|---|---|
| 详细设计(接口契约、类图) | 可编译代码 | 与契约一致,无"顺手改设计" |
| 编码规范(团队) | 风格一致代码 | Linter 全绿 |
| 单元测试策略 | 单测套件 | 行为级断言,AAA 结构,CI 全绿 |
| 设计中的边界/异常分析 | 边界处理与错误码 | 异常流有实现且有测试 |
"顺手改设计"的治理:编码中发现设计问题 → 不走"私改"通道,走轻量 CR(影响小)或正式变更(影响大);任何改动同步更新设计工件。这是"编码是精确化"纪律的配套机制。
5.2 编码规范与可读性
命名(最重要的可读性)
| 对象 | 约定 | 反例 → 正例 |
|---|---|---|
| 变量 | 名词,表达含义而非类型 | d→orderDeadline |
| 函数 | 动词短语,名实相符 | process()→calculateRefundAmount() |
| 类 | 名词,职责单一 | Manager2→InventoryReservationService |
| 布尔 | is/has/should/can 前缀 | flag→isPaid |
| 常量 | 全大写/语义名 | 100→FREE_SHIPPING_THRESHOLD |
| 集合 | 复数 + Of 语义 | list→pendingOrders |
命名自检:能否不看实现,仅凭名字推断行为?能否用名字直接写进测试名(test_已发货订单不可退款)?
其他要点
- 函数短小:一个函数只做一件事,建议 <30 行、参数 ≤4;
- 避免深嵌套:用卫语句(guard clause)提前返回;
- 魔法数字命名化:
if (amount > 100)→if (amount > FREE_SHIPPING_THRESHOLD); - 不吞异常:至少记录上下文,或转换为领域异常;
- 注释解释意图与约束,而非复述代码;
- 团队统一规范 + 工具强制:风格检查器(ESLint/PMD/Checkstyle)在 CI 中运行,不靠人盯。
注释的三分法
| 类型 | 例子 | 价值 |
|---|---|---|
| 意图/背景(该写) | “支付网关要求金额单位为分,此处乘 100” | 防止后人’优化’掉 |
| 约束/陷阱(该写) | “不可改顺序:释放锁必须在回调之后” | 防回归 |
| 复述代码(不该写) | // 加 1配x++ | 噪音,且会过期 |
规范落地机制:
- 规范文档 < 1 页(超出说明没提炼);
- 规则尽量编码进 Linter/格式化器(自动 > 人工);
- 例外走"豁免注释 + 理由"(如
// lint-override: 性能热点,见 PERF-12); - 规范本身进版本库,变更走评审。
5.3 重构(Refactoring)
重构:在不改变外部可观察行为的前提下,改善代码内部结构。
常见重构手法
| 手法 | 场景 | 例 |
|---|---|---|
| 提取函数 | 函数过长/职责混杂 | 把"计算运费"从下单函数中抽出 |
| 提取类 | 一个类职责过多(SRP) | Order拆出OrderPricing |
| 引入参数对象 | 参数过多 | (userId, addr, payMethod)→PlaceOrderCmd |
| 以多态替换条件 | if-else/switch 随类型膨胀(OCP) | 三种运费算法 → 三个PricingStrategy |
| 内联/替换算法 | 简化 | —— |
| 移除死代码 | 长期无用 | —— |
重构候选信号(代码坏味道):
- 长函数/长参数表/大类;
- 重复代码(同一逻辑三处以上);
- if-else 链随枚举值膨胀;
- 频繁修改某处导致反复回归;
- 名字与行为不符(名不副实是最隐蔽的坏味道)。
重构的安全网
- 先有充分的自动化测试(至少覆盖要改动的行为);
- 小步进行:每次只做一个重构,立即跑测试;
- 提交粒度小:每个重构一个 commit,便于回退与审查;
- 禁止"重构 + 行为修改"混在同一 commit。
重构窗口管理
重构 vs 重写:重写是从零开始,风险高、周期长;重构是持续小步改进,配合 TDD 可长期演进。OOS 在双 11 前冻结重构窗口,大促后集中 2 周偿还技术债——重构不是随时可做,它也是风险。
- 持续小重构:日常合入中允许"顺手小重构"(≤ 一个文件,测试保护);
- 窗口式大重构:结构性重构(跨模块)集中到稳定窗口,避免与功能开发并行造成双线风险;
- 冻结期:大促前 2 周、重大发布前 3 天,禁止非缺陷修复的重构;
- 判据:重构投入产出 = 未来修改成本下降 × 修改频率,低于阈值的重构不做(不是所有坏味道都值得现在修)。
5.4 单元测试(Unit Test)
定义与边界
单元测试:针对最小可测单元(函数/类/模块)的自动化测试,验证其在隔离环境下的行为。
边界判断:
- 测行为(输入 → 输出/状态),不测实现细节;
- 测边界与异常(空、极值、非法输入、并发),不只测"正常路径";
- 一个测试只验证一个行为点(失败时能立即定位)。
AAA 结构(Arrange-Act-Assert)
deftest_refund_not_allowed_after_shipped():# Arrange: 准备状态order=Order(status=SHIPPED,amount=100)service=RefundService(inventory=mock_inventory)# Act: 执行被测行为result=service.request_refund(order.id)# Assert: 断言结果assertresult.rejectedassertorder.status==SHIPPED# 状态未变单元隔离:Mock / Stub / Spy
| 替身 | 用途 | OOS 例 |
|---|---|---|
| Stub(桩) | 提供固定返回值 | 让PaymentGateway返回"支付成功" |
| Mock(模拟) | 断言"是否被以正确参数调用" | 断言下单后确实调用了inventory.reserve(sku, qty) |
| Spy(间谍) | 记录调用,可带真实逻辑 | 记录邮件发送次数与收件人 |
Mock 是隔离手段,不是设计目的。过度 mock 会测出"实现的镜像",重构时全崩。优先测公共契约,少测私有实现。
Mock 边界判断(三问):
- 这个依赖在单元测试中是否昂贵/不稳定(网络、时间、随机)?是 → 替身;
- 我断言的是"行为结果"还是"调用细节"?调用细节 → 警惕过度;
- 重构该依赖的内部实现时,测试是否需要改?需要 → 测太深了。
替代手段优先:能用构造注入的简单对象就不 mock(如用内存版库存仓储);能注入假时钟就不 mock 时间。
覆盖率(Coverage)
- 行覆盖:执行到的行比例;
- 分支覆盖:执行到的 if/else 分支比例(更有意义);
- 条件覆盖 / MC/DC:每个布尔条件及其组合(安全关键领域)。
定位:覆盖率是"测试是否充分"的下限指标,不是目标。100% 行覆盖不等于"测对了行为"。
覆盖率的正确使用:
- 门禁用增量覆盖率(新代码 ≥80%),存量逐步抬升;
- 看未覆盖清单做风险判断(未覆盖的是死代码还是高危分支?);
- 覆盖率下降必须在 PR 中说明理由;
- 永远与缺陷数据对照:高覆盖模块若仍频繁出缺陷,说明测的是行为盲区。
测试命名与组织
- 命名即文档:
test_<场景>_<条件>_<期望>(如test_满减_恰好100元_减20); - 同一行为族放同一文件/类,用分组注释标注边界;
- 测试数据用工厂/构造器集中管理,不散落在用例里;
- 禁止测试间共享可变状态(顺序依赖 = flaky 之源)。
5.5 测试驱动开发(TDD)
红-绿-重构循环:
1. 红(Red) : 先写一个会失败的测试(描述期望行为) 2. 绿(Green) : 写最少的代码让测试通过 3. 重构(Refactor): 在测试保护下改善结构 ───────────────────────────────────────── 回到 1,进入下一个行为每一步的停止条件:
- 红:测试因"功能未实现"失败,而非"写错了测试"(编译/导入错误不算红);
- 绿:通过,且没有引入超出当前行为的代码(YAGNI);
- 重构:测试仍绿,结构有可命名的改善;无改善则直接进入下一个红。
TDD 的收益
- 测试与实现同步产生,无"先写完再补测试"的滞后;
- 倒逼接口设计:写测试时被迫思考"这个 API 用起来舒服吗";
- 小步实现,天然符合增量;
- 心理安全:随时重构。
TDD 的边界
- 不是所有代码都适合 TDD:UI、探索性原型、一次性脚本;
- TDD 不解决"测什么"——测错行为同样红绿循环,仍产出错误系统;
- 与 BDD(行为驱动开发)互补:BDD 用 Gherkin 语法(给定/当/那么)写行为验收测试,弥补 TDD"测实现"的倾向。
BDD 示例(OOS 退款)
场景: 已发货订单不可直接退款,需走退货流程 给定 订单 ORD-123 状态为"已发货",实付 100 元 当 用户点击"申请退款" 那么 系统提示"已发货订单请先申请退货" 并且 订单状态保持"已发货"不变BDD 协作价值:Given/When/Then 是"产品-开发-测试三方签字"的行为契约,PO 能读懂、测试能执行、开发能实现,一份描述三种用途。
5.6 代码评审(Code Review)
| 要点 | 说明 |
|---|---|
| 规模 | 单次 ≤ 400 行 / 30 分钟内可完成,超则拆分 |
| 分工 | 评审者找问题,作者答疑问;作者不自审 |
| 关注 | 正确性 > 安全性 > 可维护性 > 风格(风格交给 Linter) |
| 语气 | 对事不对人;评论绑定代码行;区分"必须改/建议/疑问" |
| 时效 | 24 小时内响应,评审是流水线的一部分 |
| 度量 | 评审缺陷发现率(非零为健康)、平均响应时长 |
评审者检查清单:
- 行为与设计/需求一致?边界与异常都处理了?
- 有没有"顺手改了设计"而未同步工件?
- 命名与函数职责是否名实相符?
- 错误处理:吞异常、错误码混用、日志缺上下文?
- 安全:注入点、越权、敏感数据进日志?
- 测试:新增行为有测试吗?测的是行为还是实现?
5.7 静态分析与工具链
| 工具类别 | 作用 | 典型工具 |
|---|---|---|
| 静态分析(lint) | 风格、潜在缺陷、复杂度 | ESLint、PMD、SonarQube |
| 类型检查 | 编译期捕捉类型错误 | TypeScript、Mypy |
| 依赖审计 | 已知漏洞、许可证合规 | Snyk、Dependabot |
| 圈复杂度(Cyclomatic) | 量化分支复杂度,>15 预警 | 各静态分析器内置 |
CI 集成:提交 → 自动跑 lint + 单测 + 覆盖率门禁(如分支覆盖 ≥80%)→ 不通过则禁止合入。
门禁分级(建议):
- 阻断级:编译失败、单测失败、安全高危、增量覆盖率 <80%;
- 警告级:圈复杂度超标、技术债标记、风格问题(累计超阈值转阻断);
- 报告级:覆盖率趋势、重复率、依赖漏洞清单(进周报)。
5.8 编码阶段的反模式
| 反模式 | 症状 | 对策 |
|---|---|---|
| 复制粘贴编程 | 同一段逻辑三处以上 | 坏味道清单(5.3)触发提取 |
| 注释式废弃 | 大段注释代码舍不得删 | 版本库是垃圾桶:删掉,历史可查 |
| 魔法配置 | 硬编码散落各处 | 配置集中 + 命名常量 |
| 防御性编程泛滥 | 到处判空、到处 catch | 边界处校验,内部信任契约(快速失败) |
| 补测试表演 | 事后写"只测正常路径"的测试 | 评审检查异常流;增量覆盖率门禁 |
| 评审橡皮图章 | 1 分钟点 Approve | 评审规模控制 + 发现率度量 |
5.9 本章 FAQ
Q1:单测写多少算够?
没有绝对数;判据是"该单元的行为空间被有意义地覆盖":主路径 + 所有边界 + 所有声明的异常 + 并发(如适用)。用增量覆盖率兜底,用缺陷分布修正。
Q2:遗留代码没有测试,如何开始重构?
先做特征测试(characterization test):录制现有输入输出作为基线(先别判断对错),重构时以基线不变为准;同时与业务确认"哪些行为本来就是错的",把错的基线显式标记。
Q3:AI 生成的代码如何验证?
把 AI 产出当"外部贡献者代码":同样的评审 + 同样的测试要求,外加"理解责任"——你无法解释的生成代码不予合入。生成速度提升,验证与责任权重上升(第 1 章)。
Q4:TDD 对团队协作意味着什么?
意义不在个人效率,而在契约同步:测试是"可执行的需求",团队对同一行为的理解在写代码前就被迫对齐,评审时争议减半。
Q5:如何避免"测试写多了反而拖慢开发"?
先诊断慢在哪一环:构建慢(缓存/并行,第 11 章)、测试执行慢(分层:先跑受影响的)、还是写测试慢(往往是无测设计——接口耦合、依赖具体实现,根因在设计而非测试)。"测试慢"多数时候是设计问题的症状,TDD 与依赖注入就是解药。把"测试拖慢开发"当口号而不做归因,等于用症状掩盖根因。
Q6:单元测试与集成测试的边界到底在哪?
判据:被验证的行为是否跨模块边界。模块内部逻辑 → 单元;模块间协作(接口语义、数据流动、失败传递) → 集成。常见错误:单元测试里连了真实数据库或起真实容器(它其实是集成测试,却享受了单元的速度与隔离假设);另一个极端:把所有协作行为都推到 E2E,集成层空洞。用"这个行为换一个实现还能不能复用这个测试"来检验:能 → 测的是契约(好);不能 → 测到了实现(警惕)。
Q7:代码烂到"不敢动"怎么处理?
四步:① 特征测试兜底(FAQ Q2),先锁定现状行为;② 不整体重构,切"香肠"——挑一两个高频改动点先改善;③ 设退出判据(覆盖率 X% / 模块缺陷数降 Y%);④ 若性价比不成立,评估"弃用/替换"而非"拯救"(第 10 章 EOL)。最大风险是"永远在救火"的无限循环——没有退出判据的代码改善是慢性失血。
附录:编码阶段迭代检查单
每个迭代结束逐项检查(可作为回顾会的质量环节议程):
| # | 项 | 判定 |
|---|---|---|
| 1 | CI 全绿,主干健康(无挂起的红) | 是/否 |
| 2 | 增量覆盖率 ≥80%,下降有说明 | 数值 |
| 3 | 静态分析高危问题数 | 数值(目标 0) |
| 4 | 代码评审平均响应时长 | ≤24h |
| 5 | 评审缺陷发现率(条/PR) | 非零且稳定 |
| 6 | 重构 commit 与行为变更分离 | 是/否 |
| 7 | "以后再说"项全部进技术债登记册 | 是/否 |
| 8 | 有无"改了代码没更新设计工件" | 无 |
| 9 | 命名/异常/错误码符合规范抽查 | 是/否 |
| 10 | flaky 测试数量 | =0 |
任一"否"→ 产生改进项(Owner + 期限),不允许以"下次注意"关闭。检查单本身每半年审视一次:连续两次全绿的项可降级为抽查,防止检查单通胀(第 9 章度量纪律同理)。
5.10 小结
- 编码是设计的精确化;输入是契约,输出是"与契约一致 + 有测试保护";
- 可读性 > 聪明,命名是第一生产力;规范要工具化;
- 重构 = 不改行为改善结构,前提是自动化测试安全网 + 小步 + 单提交 + 窗口管理;
- 单元测试:测行为不测实现,AAA 结构,Mock 三问边界,覆盖率是下限非目标;
- TDD 红-绿-重构各有停止条件;BDD 用业务语言补行为契约;
- 代码评审是流水线环节:规模控制 + 清单 + 发现率度量;
- 静态分析 + 类型检查 + 依赖审计进 CI,门禁分级。
思考题
- "重构 + 修 bug"能否放在同一个 commit?为什么?
- Mock 过度使用的典型症状是什么?如何在团队中约束?
- 100% 行覆盖的系统仍可能在生产崩溃,给出 3 个具体例子。
- 为"库存预扣"功能写出 3 个单元测试用例(含边界与异常),并用 AAA 结构描述。
- 用 5.3 的"重构候选信号"扫描你的代码库,列出 3 个候选重构及其"未来修改成本下降 × 修改频率"的估算。
- 设计你团队的代码评审规范:规模上限、响应时效、评论分级、度量指标各定什么?为什么?
- 对一段没有测试的遗留模块,写出你的"特征测试 → 重构"分步计划(至少 5 步,含风险控制点)。
- 门禁分级(阻断/警告/报告)中,为什么"风格问题"不直接阻断?给出一个"警告转阻断"的触发规则。