我是一名喜欢追求高质量代码和高效率工作的软件开发工程师,因此我学习 SOLID 和 Simple Design 等原则、阅读优秀的开源代码、阅读相关的书籍、学习软件过程方法和真实项目实践,但是在追求高质量代码的道路上,总感觉目前的知识还不能帮我塑造成一种思维框架。在 2018 年年初机缘巧合阅读了TDD(测试驱动开发)培训录这篇文章,瞬间欣喜若狂!
到现在接触 TDD 将近一年,期间因为沉不下心只阅读了很少的资料就在项目中实践了一段时间,得到的效果还不错,自以为已经很理解 TDD 实践和背后的思想,结果在不断阅读相关书籍和关于一些 TDD 的讨论中不断暴露自己的无知,发现自己就像站在“达克效应”曲线的愚昧之巅,原来大部分自认为正确的知识都是不准确甚至是错误的。不过我很享受这种过程,在学习的过程中不断验证自己的知识是非常有趣的,这使我变得更有自知之明的同时也在不断突破自身的认知上限。
接下来我会通过图文的方式总结这段时间来对 TDD 的实践和思考,以便于沉淀自身对 TDD 的理解,希望对读者有所帮助,也希望读者可以指点一二,集思广益才能离真相更进一步。
范围
TDD (Test Driven Development) 在不同的圈子、不同的角色的认知中可能会有不同的理解,有人可能会理解成 ATDD(Acceptance Test Driven Development),也有人可能会理解成 UTDD(Unit Test Driven Development),为了避免产生歧义,文章涉及到 TDD 专指 UTDD(Unit Test Driven Development),即 「单元测试驱动开发」。
什么是 TDD
以前很片面的认为 TDD = XP 的测试优先原则 + 重构,认为 TDD 只是通过单元测试来推动代码的编写,然后通过重构来优化程序的内部结构。这很容易被理解成只需要先写单元测试就可以驱动出高质量的代码,直到我精读 Kent Beck 的著作《测试驱动开发》和不断实践思考之后才总算窥探到 TDD 藏在冰山下的面貌:
Kent Beck:“测试驱动开发不是一种测试技术。它是一种分析技术、设计技术,更是一种组织所有开发活动的技术”。
分析技术: 体现在对问题域的分析,当问题还没有被分解成一个个可操作的任务时,分析技术就派上用场,例如需求分析、任务拆分和任务规划等,《实例化需求》这本书可以给予一定的帮助作用。
设计技术: 测试驱动代码的设计和功能的实现,然后驱动代码的再设计和重构,在持续细微的反馈中改善代码。
组织所有开发活动的技术: TDD 很好地组织了测试、开发和重构活动,但又不仅限于此,比如实施 TDD 的前置活动包括需求分析、任务拆分和规划活动,这使得 TDD 具有非常好的扩展性。
TDD 的目标
Kent Beck 在他的著作《Test-Driven Development》一书中提到:“代码简洁可用这句言简意赅的话,正是 TDD 所追求的目标”。
对于如何保证“代码简洁可用”可以使用分而治之的方法,先达到“可用”目标,再追求“简洁”目标。
可用: 保证代码通过自动化测试。
代码简洁: 在不同阶段人们对简洁的理解程度也不一样,不过遵循的原则差不多,例如 OOD 的 SOLID 原则,Kent Beck 的 Simple Design 原则等。
虽然有很多因素妨碍我们得到整洁的代码,甚至可用的代码,无需征求太多意见,只需要采用 TDD 的开发方式来驱动出简洁可用的代码。
TDD 的规则
在 TDD 的过程中,需要遵循两条简单的规则:
- 仅在自动测试失败时才编写新代码。
- 消除重复设计(去除不必要的依赖关系),优化设计结构(逐渐使代码一般化)。
第一条规则的言下之意是每次只编写刚刚好使测试通过的代码,并且只在测试运行失败的时候才编写新的代码,因为每次增加的代码少,即使有问题定位起来也非常快,确保我们可以遵循小步快跑的节奏;第二条规则就是让小步快跑更加踏实,在自动化测试的支撑下,通过重构环节消除代码的坏味道来避免代码日渐腐烂,为接下来编码打造一个舒适的环境。
关注点分离是这两条规则隐含的另一个非常重要的原则。其表达的含义指在编码阶段先达到代码“可用”的目标,在重构阶段再追求“简洁”目标,每次只关注一件事!!!
TDD 的口号
简单来说,不可运行/可运行/重构——这正是测试驱动开发的口号,也是 TDD 的核心。在这个闭环中,每一个阶段的输出都会成为下一阶段的输入。
- 不可运行——写一个功能最小完备的单元测试,并使得该单元测试编译失败。
- 可运行——快速编写刚刚好使测试通过的代码,不需要考虑太多,甚至可以使用一些不合理的方法。
- 重构——消除刚刚编码过程引入的重复设计,优化设计结构。
假设这样的开发方式是可能的,那我采用 TDD 真正的动机是什么?
采用 TDD 的动机
- 控制编程过程中的忧虑感。
有一个有趣的想象,当我感觉压力越大,自身就越不想去做足够多的测试。当知道自己做的测试不够时,就会增加自身的压力,因为我担心自己写的代码有 BUG,对自己编写的代码不够自信,这是一种心态上的变化。此时测试是开发人员的试金石,可以将对压力的恐惧变为平日的琐事,采用自动化测试,就有机会选择恐惧的程度。
- 把控编程过程中的反馈与决策之间的差距。
如果我做了一周的规划,并且量化成一个个可操作的任务写到 to-do list,然后使用测试驱动编码,把完成的任务像这样划掉,那么我的工作目标将变得非常清晰,因为我明确工期,明确待办事项,明确难点,可以在持续细微的反馈中有意识地做一些适当的调整,比如添加新的任务,删除冗余的测试;还有一点更加让人振奋,我可以知道我大概什么时候可以完工。项目经理对软件开发进度可以更精确的把握。
TDD 的整体流程
- 想一下我要做什么,想想如何测试它,然后写一个小测试。思考所需的类、接口、输入和输出。
- 编写足够的代码使测试失败(明确失败总比模模糊糊的感觉要好)。
- 编写刚刚好使测试通过的代码(保证之前编写的测试也需要通过)。
- 运行并观察所有测试。如果没有通过,则现在解决它,错误只会落在新加入的代码中。
- 如果有任何重复的逻辑或无法解释的代码,重构可以消除重复并提高表达能力(减少耦合,增加内聚力)。
- 再次运行测试验证重构是否引入新的错误。如果没有通过,很可能是在重构时犯了一些错误,需要立即修复并重新运行,直到所有测试通过。
- 重复上述步骤,直到找不到更多驱动编写新代码的测试。
使测试程序可运行的三条策略:
- 伪实现——可以返回一个常量或变量,然后调整伪实现,直至伪实现变成可接受的实现代码。
- 明显实现——直接将实现代码键入,因为已经明确如何编写实现代码。
- 三角法——当我明确输入和输出但却不知道它背后的设计和实现是什么时,可以使用三角法,原理是先用简单的可运行的例子作为参考的信息源,然后推出测试的明显实现。详细信息在参考资源中给出。
这三条规则的目的是达到代码的“可用”目标,只需要键入我们认为正确的代码使测试程序尽快通过即可。
TDD 的难点
- 缺乏软件质量意识
- 缺乏一定程度的程序设计能力,很难设计出高内聚低耦合、意图清晰的结构和代码。
- 缺乏分析需求并进行任务分解和规划的能力,很容易在还没开始 TDD 的时候就被打乱了节奏。
- 缺乏合适的测试环境和测试规范。
- 测试优先的习惯难以养成。
- 重构手法不熟练。
TDD 疑问
- 都说小步快跑,具体步伐是多小?
无论是测试程序覆盖的范围还是重构时的中间步骤,TDD 建议是采用尽量小的步伐(测试无法再拆分,微小的重构),但是也没有强制一定按照这种步伐,不同人的步伐可以不同,可以在实践中不断寻找适合自己的步伐,但是前提必须尽量小。
- 什么需要测试?什么不需要测试?
除了那些不写测试还能对自己的代码感到非常自信的人之外,这取决于自己的经验和对代码的信心程度。如果某些代码自己认为即使不需要测试,运行和重构时也非常有信心,就可以不需要测试,比如大部分 set get ;相反,如果去掉会让自己感到不安,就需要考虑加入测试。
- 为什么需要遵循不可运行/可运行/重构这个顺序,不可以采用其它顺序吗?
这个问题《测试驱动开发》作者 Kent Beck 也很难去证明,因为没有专门的人真正去做过这个统计,所以他表示不否认可能存在一些更好的顺序设计。
- 为什么每次在可运行阶段只编写“可用”代码?
因为要尽快使测试运行起来,这样可以降低来自系统的反馈周期,如果能够快速持续得到来自系统的反馈,那么就可以持续保持小步快跑的节奏。如果可以短时间实现一个好的设计,写出优雅简洁的代码,那么在一开始 TDD 的时候,就应该采用最好的设计,因为这样的效率会比较高。
- TDD 是银弹吗?
TDD 不是银弹,遇到问题需要寻找核心痛点是什么,然后再对症下药。
单元测试
与 ATDD 不同, UTDD 主要面向的是开发人员,所以 UTDD 在这里主要关注的是软件内部的质量属性,如果说软件的外部质量体现在“缺陷数”和“缺陷率”等指标,那软件的内部质量属性体现在代码的“可测试性”、“可读性”和“可扩展性”等,这些几乎是每一位软件开发工程师的追求。“单元测试”作为 TDD 的产物之一,为了把控软件内部的质量属性,通常会使用到自动化“单元测试”作为软件质量保证的“根基”。
在计算机编程中,单元测试(英语:Unit Testing),通常由软件开发人员编写,用于确保他们所写的代码匹配软件需求和遵循开发目标,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。
每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用 stubs、mock 或 fake 等测试马甲程序。
通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标。
从维基百科的描述中可以看出单元测试拥有如下特点
- 开发人员编写。
- 测试函数/方法(TDD 的粒度更小)。
- 用于对代码进行正确性检验。
- 编码前后和修改代码时都会运行单元测试。
- 经常使用 stubs、mock 或 fake 等测试马甲程序确保每一个单元测试之间互相独立(正交)
这里从我个人的角度对单元测试进行简单的分析,但对“单元测试”的理解,或者是它所处的位置还是不够清晰,所以接下来我使用了“测试金字塔”模型来帮助我站在一个更高的视角理解“单元测试”。
测试金字塔
上图的“测试金字塔”模型按照运行速度和投入成本两个维度对不同阶段的测试工作进行非常直观的可视化,可以看到单元测试是位于“测试金字塔”的最底部,很明显“单元测试”相对于其它不同阶段的测试工作,拥有速度快(运行效率),成本低(维护成本)的优势,同时也是作为上层测试工作的支撑,体现了“单元测试”的重要程度。
总结
文章纯理论总结了 TDD 的全貌和一些 TDD 实践过程中的策略,包括 TDD 的难点和疑问,文章多次提到“反馈”一词是因为 TDD 是一种引入大量底层反馈的技术(得益于自动化测试),这些反馈使得很快就能看到行动的结果,使用 TDD,它将会在实践的过程中学会如何雕琢我们的代码,从而得到稳定的面向对象设计、可维护和高质量的系统。
后续
纸上得来终觉浅,唯有知行合一,通过理论指导实践,在实践中不断总结经验,不断验证自己的知识,才能不断对 TDD 有更深入更正确的理解。接下来将计划出几篇文章演示使用 TDD 如何解决一些真实的案例的总结,以便于提高自己的 TDD 技艺。
文献参考
欢迎关注我的微信订阅号,我将会持续输出更多技术文章,希望我们可以互相学习。