做事不应当拘泥于既定的循例
常常反思:
我正在做什么事? 为什么要做这件事? 目前是怎么做这件事的?
这件事的实质是什么? 做好它需要怎样的技能素养? 怎样才能做得更高效自如?
做一件事有“道”“法”“术”三个层面。 遵循既定的步骤去完成它, 而不深思其中的缘由, 是“术”的层面; 深思这件事的本源、盲点和局限性, 并找到应对的方法, 是“法”的层面; 领悟这件事的运作规律以及与现实世界的关联, 并自觉去遵循它, 是“道”的层面。
做一件事, 常常思考: 为什么要做这件事? 是否有必要做这件事? 是变化的环境和业务所导致, 还是原有的设计不具可扩展性, 无法容纳新的事物, 还是原有的设计存在缺陷, 需要修复它在应对新事物出错导致的麻烦? 如果弄清楚这些问题, 从结果反推初始, 就能看到原有的做事方法还可能导致哪些尚未出现的问题, 也就是从已知推出未知, 而不是遇事解事, 步步踩坑。 当觉察不便时, 要在当下做一些努力, 并详细地记录, 避免以后类似的情况出现, 或者即使出现, 也能以更小的努力去解决。
比如说单元测试, 只是遵循既定循例去编写单元测试, 这只是“术”的层面; 明白单元测试的本源, 是为了有效地保证每一步每一个环节的正确性, 通过保证所有步骤所有环节的严谨与正确性去保证整体的正确性。这是其核心理念和优势, 同时从中也可以推出其盲点和局限性:
(1) 可能可以穷举, 但是耗时耗力。 比如一个功能包含十个步骤, 每个步骤都有成功或失败的几率, 甚至每个步骤可能出现多个错误, 那么单元测试至少要覆盖 1024 种用例, 而实际上这 1024 种用例可能只有少数用例出现比较频繁, 或者只有少数用例具有突出的重要性和重大的影响性,大部分用例在软件生命周期内几乎不会出现; 单元测试应该尽可能覆盖这些频繁使用的、具有突出重要性和重大影响性的用例, 而不是妄图全部覆盖; 或者说, 20% 的单元测试可以解决几乎 90% 的问题; 而 80% 的单元测试只是起到了安慰剂的作用罢了;
(2) 单元测试无法阻止烂代码的产生; 可以写出无比烂的代码去通过单元测试。 这只能通过“CodeReview” 去减轻其危害; 但这足以说明, 单元测试只能验证功能的正确性, 却不能判断功能实现的质量好坏, 不可过重倚赖, 更不可倚赖于几个数字; 数字从来不代表质量, 当质量出现问题时, 好的数字是一种讽刺。
(3) 单元测试无法阻止已有测试用例之外的情况的失败。 单元测试仅能保证已经设计的用例的成功, 但当现实发生的情况是在用例之外时, 就是单元测试力所不及之处。 因此, 要保证已有单元测试用例的有效性和充分性。
(4) 单元测试无法阻止全链路的失败。 当关联的外围系统发生变更时, 即使内部再如何正确, 也无法阻止失败。 这涉及到系统之间的接口协议。
因此, 当单元测试施展其威力到瓶颈之处时, 就必须考虑其他办法来加以完善。 比如一个功能包含十个步骤, ABCDEFGHIJ, 如果仅当 ABCD 全部正确才能推出 E', EFG 全部正确才能推出 G' , HIJ 全部正确才能推出 J' , 当 E'G'J' 全部正确才能推出功能正确时, 单元测试可以进行划分-分组。 验证 (E'G'J') 的组合共有 8 个; 验证 (ABCD) 组合共有 24 个, 验证 (EFG) 和 (HIJ) 分别有 8 个, 总共只需要 48 个用例即可。 这种方法不完全符合“单元测试”的定义, 但此时正确的做法, 不是质疑做法的正确性, 而是应该质疑定义的合理性, 不是修改作法, 而是修改定义, 使之更具有可行性; 它是基于已有理念和实践上的针对具体情况的一种优化。
其次, 单元测试难以高效地测试多个组件的正确性。 往往一个全链路的集成测试就可以测试很多组件的正确性, 通过单元测试需要使用大量测试用例才能做到。 就好比, 1000 可以等于 1 自加 1000 次, 也可以等于 500 自加 2 次, 尽管后者无法保证每个组件在不同情境下的正确性, 但是在验证正常情境下的正确性的时候无疑是最有效率的。
做事的方法, 从来是不拘泥于既定的循例, 而是想方设法去实现一些特别的方法, 更高效地验证程序的正确性, 发现程序里的 BUG, 甚至有时这种方式显得有点诡异, 但确实是高效的。 唯有这样, 才能突破一些陈规, 充分释放创造力, 去引导软件开发活动。