跳转到内容

代码质量、测试与设计

代码质量不是“看起来整洁”。它体现在:别人能否理解,修改是否局部,错误能否被发现,测试是否稳定,系统变化时能否以可控成本演进。长代码不一定差,短代码也不一定好;关键是结构是否表达了真实问题。

坏味道不是风格检查清单,而是维护成本的早期信号。

坏味道 表现 可能的根因
过长函数 一个函数做很多事 缺少职责边界,逻辑混层
过大类/模块 字段和方法都很多 聚合了多个概念
重复代码 相同逻辑复制多处 缺少可复用边界
参数过多 调用需要记很多位置参数 概念未建模
过深嵌套 多层 if/for 条件可以提前返回或提取
神秘命名 data、temp、handle 含义没有被表达
数据泥团 几个字段总是一起出现 应抽出值对象
发散式变化 一个变化要改很多无关文件 职责分散
霰弹式修改 同一概念散落多处 缺少封装
特性依恋 函数大量访问别的对象数据 行为可能放错位置
过度抽象 很多接口只包一层 为假想变化设计

坏味道要和上下文一起判断。一次脚本可以写得很直接;长期维护的核心服务则需要更清楚的边界。

圈复杂度大致统计独立分支路径数量。条件、循环和布尔组合会增加路径,复杂度高意味着测试用例更多、理解成本更高。

但复杂度指标不能替代判断。一个简单的 switch 可能比多层抽象更清楚;一个短函数可能隐藏了昂贵副作用。共同目标是降低认知负担:

  • 名称说明意图,而不是实现细节。
  • 一个函数在同一抽象层工作。
  • 依赖显式传入,而不是藏在全局状态里。
  • 副作用与纯计算分开。
  • 错误路径清楚,不靠特殊返回值。
  • 关键不变量用类型、断言或测试表达。

注释应解释“为什么”和不明显的约束,而不是逐行复述代码。过时注释比没有注释更危险。复杂算法、业务规则、兼容性原因和临时权宜值得记录。

重构是在不改变外部行为的前提下调整内部结构。它不等于重写,也不等于顺便修 bug。

安全重构流程:

确认当前行为
→ 有测试或可复现验证
→ 小步改变结构
→ 每步运行检查
→ 行为不变
  • 提取函数:把一段有独立意图的逻辑命名并移出。
  • 内联函数:删除只转发、没有增加语义的包装。
  • 重命名:让名称表达领域含义。
  • 引入参数对象:把总是一起出现的参数建模。
  • 提取类/模块:把数据和相关行为聚合。
  • 移动函数:把行为放到它最依赖的数据附近。
  • 用多态替代条件:当分支按类型反复扩散时。
  • 用组合替代继承:避免继承层级承载多种变化。
  • 拆分阶段:把读取、计算、写入分开。

重构要小步提交。一次改结构、改命名、改行为、升级依赖,失败时无法判断是哪一项导致。

如果代码没有测试,可以先:

  • 补 characterization test,记录当前实际行为。
  • 用输入输出快照比较重构前后。
  • 增加日志或断言确认路径。
  • 在受控环境跑真实请求。

记录的是“当前行为”,不一定代表“正确行为”。发现 bug 应单独修复,不要混在重构中。

评审的目标是降低风险,不是显示谁更聪明。

优先看:

  1. 需求是否被正确实现,边界是否遗漏。
  2. 权限、数据安全、错误处理和回滚。
  3. 接口、数据库迁移和兼容性。
  4. 并发、资源释放、超时和重试。
  5. 测试是否覆盖关键风险。
  6. 命名和结构是否让后来者能理解。

格式问题交给自动工具。评审意见应说明问题、影响和建议,而不是只写“这样不好”。作者对修改负责,但不应把评审当成猜谜。

超大变更集难以认真审查。按可独立验证的逻辑拆分提交,但不要把每个小修改都拆成无法运行的碎片。数据库迁移、代码和配置变更之间要有清晰顺序。

测试不是为了追求数量,而是用最低成本捕获最重要风险。

层 验证什么 特点
单元测试 单个函数、类或模块规则 快、稳定、定位准
集成测试 模块与数据库、队列、外部适配器 接近真实,准备成本更高
契约测试 服务之间 schema 与行为 防止版本不兼容
端到端测试 用户关键路径 最有信心,也最慢最脆
性能/压力测试 延迟、吞吐、资源上限 需要真实数据与监控
安全测试 输入、权限、依赖和配置 需攻击者视角

测试数量和比例取决于系统。大量业务规则的纯函数适合单元测试;数据库查询和权限边界需要集成测试;支付和登录主路径值得端到端测试。

Arrange:准备输入和依赖
Act:执行被测行为
Assert:验证输出和副作用

测试名称应说明场景和预期,例如“超过库存上限时拒绝下单”,而不是 testCreateOrder2。

  • 只验证一个行为。
  • 输入明确,不依赖执行顺序。
  • 不依赖真实网络、当前时间和随机数。
  • 失败信息能指出哪条规则出错。
  • 不复制实现细节,否则重构时测试全坏。

时间、随机数和 UUID 应可注入或可控制。数据库测试要使用独立 schema、事务回滚或容器,避免共享状态。

当依赖慢、不可控或有副作用时,可以使用:

  • Stub:返回预设结果。
  • Mock:验证某个交互发生。
  • Fake:有简化但可工作的实现,如内存仓储。
  • Spy:记录调用并允许真实执行。

替代依赖会降低真实度。不要 mock 所有东西后宣称“集成测试通过”。关键权限、序列化、数据库约束和网络错误应在更接近真实的环境验证。

红—绿—重构:

  1. 写一个失败的测试,描述期望行为。
  2. 用最小实现让它通过。
  3. 在测试保护下重构。

TDD 能帮助先想清接口和边界,但不适合所有探索性任务。关键不是形式,而是测试是否在实现之前表达了可验证要求。

行覆盖率表示执行到了多少行,分支覆盖率观察条件分支。高覆盖率不能证明正确:

  • 断言可能为空。
  • 测试可能只走 happy path。
  • 覆盖代码不代表覆盖业务风险。
  • 为提升数字而写的测试会增加维护成本。

覆盖率可作为发现盲区的信号,不能作为质量目标本身。关键路径、权限、金额、并发和错误处理应有明确测试。

偶发失败的测试会破坏信任。常见原因:

  • 依赖真实时间、网络或执行顺序。
  • 并发共享状态。
  • 异步等待不充分或固定 sleep。
  • 测试数据不隔离。
  • 随机输入未固定种子。

应记录失败日志、隔离测试数据、使用可等待条件,而不是简单重跑。长期 flaky 的测试要么修复,要么删除,不能无限加重试掩盖。

设计模式是常见问题的共同语言,不是必须使用的目录。

模式 解决的问题 何时不该用
策略 可替换的算法或规则 只有一两个稳定分支
适配器 统一不同外部接口 直接转换更简单时
装饰器 在不改原对象的情况下叠加行为 层级过多导致难追踪
工厂 封装创建逻辑和选择 只创建一个固定对象
观察者 状态变化通知多方 隐式事件链难调试
单例 全局唯一实例 隐藏依赖、测试隔离困难

继承表达“是一种”,组合表达“拥有某种能力”。当子类需要组合多个变化维度时,继承容易爆炸。优先把行为抽成可组合的组件或策略。

如果只有一个实现、一个变化方向和一个使用点,接口和工厂可能只是增加跳转。设计模式应在变化已经出现或可以明确预见时引入,并用测试保护。

好的代码在边界处失败得清楚:

  • 对不可信输入做校验。
  • 用异常、结果类型或错误码表达不同失败。
  • 不吞掉错误,不把内部异常直接暴露给用户。
  • 资源在异常路径也能释放。
  • 重试只用于可恢复错误,且操作幂等。
  • 日志带上下文,但不泄露敏感信息。

错误处理不是 try/catch 包住一切。需要明确哪一层负责恢复、哪一层负责转换、哪一层负责告知用户。

  1. 关键行为和边界是否有测试。
  2. 新代码是否引入共享可变状态或资源泄漏。
  3. 接口和数据库变更是否兼容。
  4. 错误路径是否有日志和稳定响应。
  5. 是否需要更新文档、配置和迁移。
  6. 是否运行格式、静态检查、单元和集成测试。
  7. 代码审查是否关注风险和可维护性,而不是只挑风格。
  • 代码结构和命名是否表达真实领域。
  • 函数是否在同一抽象层,副作用是否清楚。
  • 坏味道是否由实际变更成本驱动修复。
  • 重构是否先有测试或可复现验证。
  • 测试是否覆盖正常、边界、错误和权限。
  • 是否区分单元、集成、契约和端到端边界。
  • 测试是否确定、隔离、可读,失败能定位。
  • 覆盖率是否作为信号,而不是目标。
  • 设计模式是否解决真实变化,是否避免过度抽象。
  • 错误和资源释放路径是否完整。