代码质量、测试与设计
代码质量不是“看起来整洁”。它体现在:别人能否理解,修改是否局部,错误能否被发现,测试是否稳定,系统变化时能否以可控成本演进。长代码不一定差,短代码也不一定好;关键是结构是否表达了真实问题。
1. 坏味道是设计信号
跳转到“1. 坏味道是设计信号”坏味道不是风格检查清单,而是维护成本的早期信号。
| 坏味道 | 表现 | 可能的根因 |
|---|---|---|
| 过长函数 | 一个函数做很多事 | 缺少职责边界,逻辑混层 |
| 过大类/模块 | 字段和方法都很多 | 聚合了多个概念 |
| 重复代码 | 相同逻辑复制多处 | 缺少可复用边界 |
| 参数过多 | 调用需要记很多位置参数 | 概念未建模 |
| 过深嵌套 | 多层 if/for | 条件可以提前返回或提取 |
| 神秘命名 | data、temp、handle |
含义没有被表达 |
| 数据泥团 | 几个字段总是一起出现 | 应抽出值对象 |
| 发散式变化 | 一个变化要改很多无关文件 | 职责分散 |
| 霰弹式修改 | 同一概念散落多处 | 缺少封装 |
| 特性依恋 | 函数大量访问别的对象数据 | 行为可能放错位置 |
| 过度抽象 | 很多接口只包一层 | 为假想变化设计 |
坏味道要和上下文一起判断。一次脚本可以写得很直接;长期维护的核心服务则需要更清楚的边界。
2. 复杂度与可读性
跳转到“2. 复杂度与可读性”圈复杂度大致统计独立分支路径数量。条件、循环和布尔组合会增加路径,复杂度高意味着测试用例更多、理解成本更高。
但复杂度指标不能替代判断。一个简单的 switch 可能比多层抽象更清楚;一个短函数可能隐藏了昂贵副作用。共同目标是降低认知负担:
- 名称说明意图,而不是实现细节。
- 一个函数在同一抽象层工作。
- 依赖显式传入,而不是藏在全局状态里。
- 副作用与纯计算分开。
- 错误路径清楚,不靠特殊返回值。
- 关键不变量用类型、断言或测试表达。
2.1 注释
跳转到“2.1 注释”注释应解释“为什么”和不明显的约束,而不是逐行复述代码。过时注释比没有注释更危险。复杂算法、业务规则、兼容性原因和临时权宜值得记录。
3. 重构
跳转到“3. 重构”重构是在不改变外部行为的前提下调整内部结构。它不等于重写,也不等于顺便修 bug。
安全重构流程:
确认当前行为 → 有测试或可复现验证 → 小步改变结构 → 每步运行检查 → 行为不变3.1 常用手法
跳转到“3.1 常用手法”- 提取函数:把一段有独立意图的逻辑命名并移出。
- 内联函数:删除只转发、没有增加语义的包装。
- 重命名:让名称表达领域含义。
- 引入参数对象:把总是一起出现的参数建模。
- 提取类/模块:把数据和相关行为聚合。
- 移动函数:把行为放到它最依赖的数据附近。
- 用多态替代条件:当分支按类型反复扩散时。
- 用组合替代继承:避免继承层级承载多种变化。
- 拆分阶段:把读取、计算、写入分开。
重构要小步提交。一次改结构、改命名、改行为、升级依赖,失败时无法判断是哪一项导致。
3.2 先加护栏
跳转到“3.2 先加护栏”如果代码没有测试,可以先:
- 补 characterization test,记录当前实际行为。
- 用输入输出快照比较重构前后。
- 增加日志或断言确认路径。
- 在受控环境跑真实请求。
记录的是“当前行为”,不一定代表“正确行为”。发现 bug 应单独修复,不要混在重构中。
4. 代码评审
跳转到“4. 代码评审”评审的目标是降低风险,不是显示谁更聪明。
优先看:
- 需求是否被正确实现,边界是否遗漏。
- 权限、数据安全、错误处理和回滚。
- 接口、数据库迁移和兼容性。
- 并发、资源释放、超时和重试。
- 测试是否覆盖关键风险。
- 命名和结构是否让后来者能理解。
格式问题交给自动工具。评审意见应说明问题、影响和建议,而不是只写“这样不好”。作者对修改负责,但不应把评审当成猜谜。
4.1 评审大小
跳转到“4.1 评审大小”超大变更集难以认真审查。按可独立验证的逻辑拆分提交,但不要把每个小修改都拆成无法运行的碎片。数据库迁移、代码和配置变更之间要有清晰顺序。
5. 测试的分层
跳转到“5. 测试的分层”测试不是为了追求数量,而是用最低成本捕获最重要风险。
| 层 | 验证什么 | 特点 |
|---|---|---|
| 单元测试 | 单个函数、类或模块规则 | 快、稳定、定位准 |
| 集成测试 | 模块与数据库、队列、外部适配器 | 接近真实,准备成本更高 |
| 契约测试 | 服务之间 schema 与行为 | 防止版本不兼容 |
| 端到端测试 | 用户关键路径 | 最有信心,也最慢最脆 |
| 性能/压力测试 | 延迟、吞吐、资源上限 | 需要真实数据与监控 |
| 安全测试 | 输入、权限、依赖和配置 | 需攻击者视角 |
测试数量和比例取决于系统。大量业务规则的纯函数适合单元测试;数据库查询和权限边界需要集成测试;支付和登录主路径值得端到端测试。
5.1 AAA 结构
跳转到“5.1 AAA 结构”Arrange:准备输入和依赖Act:执行被测行为Assert:验证输出和副作用测试名称应说明场景和预期,例如“超过库存上限时拒绝下单”,而不是 testCreateOrder2。
5.2 好的单元测试
跳转到“5.2 好的单元测试”- 只验证一个行为。
- 输入明确,不依赖执行顺序。
- 不依赖真实网络、当前时间和随机数。
- 失败信息能指出哪条规则出错。
- 不复制实现细节,否则重构时测试全坏。
时间、随机数和 UUID 应可注入或可控制。数据库测试要使用独立 schema、事务回滚或容器,避免共享状态。
6. 测试替身
跳转到“6. 测试替身”当依赖慢、不可控或有副作用时,可以使用:
- Stub:返回预设结果。
- Mock:验证某个交互发生。
- Fake:有简化但可工作的实现,如内存仓储。
- Spy:记录调用并允许真实执行。
替代依赖会降低真实度。不要 mock 所有东西后宣称“集成测试通过”。关键权限、序列化、数据库约束和网络错误应在更接近真实的环境验证。
7. TDD 与覆盖率
跳转到“7. TDD 与覆盖率”7.1 TDD
跳转到“7.1 TDD”红—绿—重构:
- 写一个失败的测试,描述期望行为。
- 用最小实现让它通过。
- 在测试保护下重构。
TDD 能帮助先想清接口和边界,但不适合所有探索性任务。关键不是形式,而是测试是否在实现之前表达了可验证要求。
7.2 覆盖率
跳转到“7.2 覆盖率”行覆盖率表示执行到了多少行,分支覆盖率观察条件分支。高覆盖率不能证明正确:
- 断言可能为空。
- 测试可能只走 happy path。
- 覆盖代码不代表覆盖业务风险。
- 为提升数字而写的测试会增加维护成本。
覆盖率可作为发现盲区的信号,不能作为质量目标本身。关键路径、权限、金额、并发和错误处理应有明确测试。
7.3 Flaky 测试
跳转到“7.3 Flaky 测试”偶发失败的测试会破坏信任。常见原因:
- 依赖真实时间、网络或执行顺序。
- 并发共享状态。
- 异步等待不充分或固定 sleep。
- 测试数据不隔离。
- 随机输入未固定种子。
应记录失败日志、隔离测试数据、使用可等待条件,而不是简单重跑。长期 flaky 的测试要么修复,要么删除,不能无限加重试掩盖。
8. 设计模式
跳转到“8. 设计模式”设计模式是常见问题的共同语言,不是必须使用的目录。
| 模式 | 解决的问题 | 何时不该用 |
|---|---|---|
| 策略 | 可替换的算法或规则 | 只有一两个稳定分支 |
| 适配器 | 统一不同外部接口 | 直接转换更简单时 |
| 装饰器 | 在不改原对象的情况下叠加行为 | 层级过多导致难追踪 |
| 工厂 | 封装创建逻辑和选择 | 只创建一个固定对象 |
| 观察者 | 状态变化通知多方 | 隐式事件链难调试 |
| 单例 | 全局唯一实例 | 隐藏依赖、测试隔离困难 |
8.1 组合优于继承
跳转到“8.1 组合优于继承”继承表达“是一种”,组合表达“拥有某种能力”。当子类需要组合多个变化维度时,继承容易爆炸。优先把行为抽成可组合的组件或策略。
8.2 避免过度设计
跳转到“8.2 避免过度设计”如果只有一个实现、一个变化方向和一个使用点,接口和工厂可能只是增加跳转。设计模式应在变化已经出现或可以明确预见时引入,并用测试保护。
9. 边界与错误设计
跳转到“9. 边界与错误设计”好的代码在边界处失败得清楚:
- 对不可信输入做校验。
- 用异常、结果类型或错误码表达不同失败。
- 不吞掉错误,不把内部异常直接暴露给用户。
- 资源在异常路径也能释放。
- 重试只用于可恢复错误,且操作幂等。
- 日志带上下文,但不泄露敏感信息。
错误处理不是 try/catch 包住一切。需要明确哪一层负责恢复、哪一层负责转换、哪一层负责告知用户。
10. 交付前检查
跳转到“10. 交付前检查”- 关键行为和边界是否有测试。
- 新代码是否引入共享可变状态或资源泄漏。
- 接口和数据库变更是否兼容。
- 错误路径是否有日志和稳定响应。
- 是否需要更新文档、配置和迁移。
- 是否运行格式、静态检查、单元和集成测试。
- 代码审查是否关注风险和可维护性,而不是只挑风格。
检查清单
跳转到“检查清单”- 代码结构和命名是否表达真实领域。
- 函数是否在同一抽象层,副作用是否清楚。
- 坏味道是否由实际变更成本驱动修复。
- 重构是否先有测试或可复现验证。
- 测试是否覆盖正常、边界、错误和权限。
- 是否区分单元、集成、契约和端到端边界。
- 测试是否确定、隔离、可读,失败能定位。
- 覆盖率是否作为信号,而不是目标。
- 设计模式是否解决真实变化,是否避免过度抽象。
- 错误和资源释放路径是否完整。