TDD 实践经验分享与对 TDD 实践程度的讨论

TDD 是好的,但要求对测试库很熟悉才能实践;但这不妨碍我们按照 TDD 的思路来指导代码编写

#0.对读者的知识要求&前言

要求:简单掌握任一编程语言,明白“软件测试”的定义、类别与各测试类别的作用。

去年刚写这篇文章(20200717)时,我第一次在《代码整洁之道》系列书中读到 TDD 的概念与好处, 并试着在工作中实践了一个月,写这篇文章的目的是分享实践中摸索与网上搜索到的 TDD 实践技巧。 今年我读了更多的书,感觉对 TDD 的理论与实践多了解了一些,更新打的“补丁”字数也不少, 于是把标题改成更宽泛的“对 TDD 的实践的讨论”。

本文分成几大部分: 第一部分,对 TDD 的实践技巧分享,我建议跳过第4小部分的经验细节,先把后面更新的观点类的文字读一读。 第二部分,给出“严格遵守 TDD 在实践上很困难”、“TDD 部分流程不太‘务实’”的观点以及理由; 第三部分,摘录大量书籍《程序员修炼之道(第2版)》中的文字,给出可利用 TDD 优势而不会陷入教条的折中实践方案, 与对于“测试”概念的新颖理解方式。

希望本文能消除你对于 TDD 概念与实践的疑惑,让你在平时编码时能够从 TDD 的思维方式中获得好处,而不必苦苦承受遵守 TDD 流程实践中的重复与艰难。

#1.什么是 TDD

TDD 的全称是 “测试驱动开发(Test-driven development)”[1] ,“是一种软件开发过程中的应用方法,由极限编程中倡导,以其倡导先写测试程序,然后编码实现其功能得名”。

一个 TDD 循环的简单流程是:

  1. 编写一个需求对应的测试,该测试以一个功能方法的 一部分逻辑 为目标。
  2. 编写简洁的业务逻辑代码,以通过测试。
  3. 回归测试,运行和这部分逻辑相关的所有测试(或者简单的把整个测试类 run 一下),确认全通过。
  4. 重构代码,修正步骤2中因思考逻辑而忽略的设计原则|设计模式(比如 if-else 中重复的代码可以提取成一个方法)。
  5. 重新执行步骤3,确保重构没破坏功能。回到步骤1。

#2.TDD 的好处

在此引用 Uncle Bob 的《代码整洁之道-程序员的职业素养》(另,推荐阅读此系列书)一书中他第一次看到 TDD 的回忆:

…首先,他写了一个单元测试的一小部分,没几行代码。然后,他写了刚好能使那个测试通过的代码。 接着,他又写了些测试,然后再写一些代码…从编码到运行的周期如此之短…Kent 居然每30秒运行一次(测试)程序… 忽然,我发现这种周期似曾相识!许多年前,当我还是孩子的时候…解释型语言,无需编译构建,你要做的只是添加一行代码, 然后执行,再添加,再执行…使用这些语言的编程效率极高。

(我看到“30秒”那里,就对 TDD 感兴趣了。)Bob 大叔说得对,我们在刚开始编程时都有一个时期,小心翼翼,一次只加一行代码, 然后运行看看 print 出的变量值发生了什么变化。

符合预期的变化会给我们信心,而且直白的目标使下一行要加的代码“呼之欲出”。

这就是 TDD 的魔力所在。它鼓励你拿出勇气去重构(烂)代码,因为你不再害怕重构它有破坏功能的风险, 快速方便的回归测试集的 re-run 报告帮你撑腰。

TDD 还有如下优势:

  • 测试的代码覆盖率接近100%。
  • 这些测试不仅是单元测试的一部分,也是代码形式的用例和文档。
  • 便于执行自动化回归测试(单元测试层面)。
  • 测试间相互隔离,鼓励每次循环时编写的新逻辑间保持松耦合。
  • TDD 要求测试先行,这有助于帮助开发人员拆解需求。

#3.(我认为的)实施 TDD 的知识要求

  • 熟练使用当前项目所用语言的特性与类库,和单元测试工具。 前者减少因为不熟悉语言而卡壳愣住的概率,后者是使用 TDD 的硬性要求。
  • 会拆解需求,从理想情况到需求所定义的限制性条件,一步步增加条件。(第4部分中详细解释)

#4.实施 TDD 过程中的困惑和解决方案

TDD 理论说来容易,但如果自身技能不够扎实,只是从头疼怎么写业务代码转到头疼怎么写测试而已。 测试先行意味着,测试写得不够“好”,则直接影响开发人员进一步的思考方向,严重时会把开发人员带进思考泥潭。

#4.1.不需要在编写测试时编写非法输入测试

问:如何在使用 TDD 时保证代码的健壮性?是否需要考虑非法输入?
答:TDD 时不需要。TDD 之后,部分代码会需要,再加上就是。
资料:[2]的第二个回答,[3]1.

编写非法输入测试(比如输入 NULL,空对象指针 etc,这不是一个正规名词但涵盖范围正确)是 QA(测试人员)的任务, 当他们测试时,会在至少是单元测试的层面的封装(或者调用链)上测试非法输入,这意味着 不是每个方法都要考虑非法输入 , 而 TDD 的测试会覆盖到每个方法。 另外,在 TDD 之后而不是 TDD 时考虑这个部分,修改代码会更头脑清晰更方便(回归测试给的信心)。

同时应该注意的是,如果业务逻辑本身要求“分情况考虑”, 那么像是数组长度=0,以及由“情况”的排列组合得出的对应的“合法的”边界测试(edge cases),就要在 TDD 时覆盖到。 正确实例:[4] (这是一个优雅如禅宗公案的“kata”)最后,为了考虑逻辑的“周全性”而添加的两个测试。(一个输入情况对应一个测试)

#4.2.避免错误的步子大的测试

问:我写出了下一个测试,但在编业务代码时卡壳了
答:先排除自身编码不熟练的因素,然后检查这个测试,
是不是覆盖的逻辑太大了,重新写一个小一些的测试(4.3详解)
资料:33.,4文章中间

酒要一口一口地喝,路要一步一步走,步子迈大了,容易_ _ _。————《让子弹飞》

在 TDD 过程中,业务逻辑编码只需要恰好满足使测试通过, 如果坚持住这个原则,那么唯一使编程步骤卡壳的原因就在于: 开发人员不能快速想出通过测试的方案,又陷入了使用 TDD 前的窘境————没有短又直白的编码目标,头疼怎么一步到位。 这个测试不能驱动编码思路呼之欲出,说明这个测试有问题,需要重新换一个。 两个资料里都提到了这样含义的话:

避免一个测试覆盖太大的逻辑范围,这违反了 TDD 单个测试对应小目标的原则。

错误实例:就像文章4中“我”在第一次分析需求时,选择了不明朗的测试“演化”方向, 导致最后一步测试,实质上对应的是一步到位处理超过一种的新加入的情况,无法延续现有的代码继续修改以通过测试。

现在的问题转换为“如何写出下一个测试”,请接着读4.3。

#4.3.简化问题场景->逐步添加限制|条件

问:我无法写出下一个测试,卡壳了
答:接着上一步测试的目的,继续分析需求,我们走到哪了?或者我们应该换个思路重新分析需求?
资料:32.,4文章中间

“When faced with a problem you do not understand, do any part of it you do understand, then look at it again.”

这是文章4给出的引用,来自一部科幻小说。下面我要写的是我根据文章4和其他 kata 解决需求的思路悟出的门路, 总结来说:

不要按步骤1-2-3线性流程切分需求,而是按套娃, 从理想的简单状态机(简单输入简单处理输出|复杂输入简化处理输出)到有条件|限制的复杂状态机(复杂处理+复杂输入|输出), 一步步完善处理逻辑。

如果你手边有《计算机网络》这类书,看看每个层的第一个教学使用的理论协议的假设条件有多理想,以数据链路层的协议为例: 单工,发送方和接收方的网络层总是准备就绪,数据处理不计时间, 可用缓存无限大,信道不会在物理层损坏|丢失。 这种理想协议只会在教科书上存在,但它形象地描述了该层协议 可能的风险|考量 。 之后的篇幅逐渐引入各种机制来解决理想协议中忽略的考量,最终一个能在现实场景中使用的协议完成了。

咱们的测试也应该像设计协议一样,每次只测试一个考量|一种情况, 修改少量代码通过这个测试,接着向下走,直到考虑了所有情况。

正确实例:文章4后半段,“我”针对“字符串按位换行”需求,先考虑了没有空白符的“纯”字符串简单情况,然后因为引入空白符, 多增加了三种情况(分割点在空白符左、相同、右),多写三个测试并修改代码通过它们,最终完成了逻辑。

#2021-03-11更新

我对 TDD 有了新的理解:TDD 对新手来说比较难以实践,不建议新手去用。有以下几点理由:

  1. 写测试速度很难跟上思考速度。

    • TDD 本质上是把对方法|函数的功能的要求编进测试用例,要求是思考形成的, 如果对编程语言(&第三方测试框架)不是如母语一样熟悉, 写测试的速度跟不上思考的速度,就需要阻塞思考,慢慢写出一个个测试用例。
    • 就像阻塞快速的 CPU 去做慢速的I/O一样,更惨的是不是人人都是周伯通, 手脑协调同时只能做一件事, 所以不能引入一个终端机制去并行思考&写测试)。
    • 阻塞思考不仅痛苦还会打断思路,而且编程速度很慢,不利于完成工作。
    • 解决这个问题,或者提高写测试速度(I/O速度);或者科幻一些, 借助机器(引入数据通道辅助I/O),比如哪天出现一个自然语言的测试框架, 写英文就能生成对应测试用例,或者脑机接口之类。唉,暂时不可求。
  2. 测试用例是否能体现功能需求与测试是否通过无关,不可强求测试覆盖率。

    • 极端例子:…; assertThat(1,is(1)); 上述用例在计算覆盖率时照常计算,但实际上什么都没有测出来。
    • 类比网络安全上的常识“虚假的安全感比没有安全感更致命”, 虚假的单元测试覆盖率和绿色的测试通过标记比没有一丁点单元测试同样致命。
    • 我想说的是,如果强行遵守 TDD(意味着无论测试质量如何都会统计出高测试覆盖率) 或者不遵守 TDD 但有强制的单元测试覆盖率要求,有时对项目伤害更大。
  3. 如果有专业全面的测试环节,单元测试的价值会打个折扣。

    • 这不是我的见解,我认为他说得有些道理(推特 thread)
    • 但我认为对复杂逻辑的测试该写还是写,因为单元测试就是为开发时即时测试而存在的,和 QA 两码事。 即使 QA 发现 bug 提出工单,你改好了,但下次重构时又 不能&没权限&不方便 即时地把历史工单的测试用例全跑一遍,不踏实。
  4. 简单逻辑犯错概率很小,没有写测试的必要。

    • 举个例子,为了打 log 而写的 try-catch(-catch…)逻辑, 我会把 try 中的逻辑单独重构成方法, 那么专门对该单纯的 try-catch 包装方法写测试就比较鸡肋, 如果写测试速度不够快,认真读两遍代码看看异常类的名是否写对更快些。
    • (如果 throw 的方法在 try 中的逻辑内调用好几层深,或者 catch 内涉及修改对象状态的“异常时处理”操作, 写个测试用例比较好,保证确实这类异常会被这里的 catch 抓住而不是被内部的 catch 抓住,进而触发处理操作。 (比如 Java Spring @Transaction 注解,就隐性地要求必须把异常抛出该注解修饰的方法,才能启动回滚, 内部的打 log catch 要在最后把异常再 throw 出去。))

需要承认,如果真的能同时:1.测试质量高 2.测试覆盖率高,那么这种单元测试的价值非常大。 可惜现实情况往往是没经验满足1,没时间满足2,而且不满足1还反而有害。

我现在的实践是(我们团队对单元测试没要求),要写用例就用多种 assert 努力体现完备的功能要求,写不出|懒不想写就干脆不写。 编写完功能代码,针对其中我认为的“复杂(变成自动机它中间状态比较多)”方法 (相对的是“惰性”的一目了然的方法:简单的异常处理,类似构造器的接参数 builder 方法…) 写单元测试(简单的边界,null,正常),不在意覆盖率,单纯地“为了当前开发而测试”,如果能方便到以后的重构就更好啦。 (不要和“编程为了长远,只是运行刚好满足于现在的需求”准则弄混,那是描述功能设计而不是单元测试的准则。)

#2021-04-30更新

说实话,3月时我写下上面的补丁的内容,以提供反面的观点,但心中仍留有疑惑: 我到底该对 TDD 持有怎样的态度?我该如何使用其中好的思想并避开麻烦的实践过程? 《程序员修炼之道(第2版)》中谈到的关于测试的观点让我倍受启发, 我忍不住大段大段地把内容抄写在这里,以弥补与融合上面两部分中对 TDD 的支持与反对的观点,使之形成整体的观点。 因抄写字数太多,我不会使用格式标记抄写的段落,反过来,下面我说的话会使用斜体标记。 有时会抄写原文,有时会把原本不同位置的表达同概念的单句组合在一起,有时改写难以理解的表达, 省略描述书中举例,我尽量将书中的想表达的观点呈现出来。 当然,推荐你找到本书并从“第7章-41节-为编码测试”开始阅读原文,这书没阅读门槛,很值得一读。 嗯,版权问题……我想两位作者不会因为这个来找我的,我们都想让更多人了解到更务实的工作方式。


#41 为编码测试

我们认为“为了找 bug|确保代码工作正常而编写测试”是错误的观点。

#提示66 测试与找 bug 无关

我们相信,测试获得的主要好处发生在你考虑测试及编写测试的时候,而不是运行它的时候。 当你开始编写新功能,你不知道怎么去写业务逻辑,这时可以提前考虑一下测试: 假设你已经写完了业务逻辑,准备补单元测试了。 想想你要怎么测试业务逻辑?怎么划分每个测试方法的范围?你的代码需要注入什么依赖模块来模拟真实环境?能够控制哪些依赖模块以模拟不同情况?

从考虑测试开始,在不写一行代码的情况下,你已经有了对业务逻辑每个公开方法的方法签名(API)要求的发现,并设计了 API。 考虑测试使我们减少了代码中的耦合 (与其他代码紧耦合的函数或方法很难进行测试,因为你必须在运行方法之前设置(mock)好所有环境,让你的东西可测试也减少了它的耦合), 并增加了灵活性(我们想在测试时自由地控制依赖模块来模拟不同情况,这要求业务逻辑的 API 所接受的参数范围要广)。 为公开方法写测试的考虑过程, 使我们得以从外部看待这个方法,这让我们看起来是方法的客户(调用者)而不是作者。

#提示67 测试是代码的第一个用户

我们认为,“测试所提供的反馈至关重要,可以指导编码过程”,这是测试的最大好处。 在你对一个东西做测试前,必须先理解它在干嘛。 现实中我们刚开始编写代码时,只能基于对必须要做的事的模糊理解,边写边理解边解决新出现的要求(错误处理,边界,isNull etc), 最后代码总量比真正干事的逻辑大好几倍,里面充斥了条件逻辑与错误处理。这时再写测试就不好分辨整个代码真正在干什么了。 如果你在开始编码前,就考虑过测试边界条件及其工作方式(不同情况的分类处理),那么你可能会发现简化条件逻辑的方案。 如果考虑需要测试的引发错误的条件,那么你将会有意构造对应的错误处理逻辑

#测试驱动开发

我们看到 TDD 的好处,只要遵守流程,代码始终都有测试,你也将一直处于思考测试的状态。 我们也看到人们成为 TDD 的奴隶:

  1. 花费过多时间确保100%测试覆盖率
  2. 做了很多冗余(无用)的测试 (书中举例:刚开始编写方法时,对一个特定测试值(这是从特例到公式的必要一步 TDD 步骤), 编写之后显著会重复|重写的 AD-HOC 逻辑
  3. TDD 的设计从底层开始,然后逐步上升。
#TDD:你需要知道该去何方。

“小步前行”被吹捧为 TDD 的一个优点,但这可能会误导你,它鼓励人们专注于不断优化简单的问题,而忽略编码的真正动因(解决需求!)。 书中案例:罗恩·杰弗里斯的数独程序系列博客,进行多次重构后满意,最终放弃了项目。 按顺序阅读博客很有趣,可以看到一个聪明人是如何被通过测试的喜悦套牢,开始为琐事(不断想着重构)而分心。

#提示68 既非自上而下,也非自下而上,基于端对端构建程序

#自上而下与自下而上之争,以你该用的方式去做

计算机领域初期有两种程序设计学派:自上而下与自下而上。 但两个学派都没成功,因为它们都忽略了:刚开始(即设计阶段)没人知道要做什么。 自上而下要求提前知晓需求,这不可能。 自下而上假设从小到大构造抽象层最终实现需求,但不知需求就无法划分功能边界。

我们坚信,构建软件的唯一方法是增量式的。构建端到端功能的小块,一边工作一边了解问题。 应用学到的知识充实代码,让客户参与每一步并让他们指导这个过程。 测试对开发的驱动绝对有帮助,但是,就像每次开车一样,除非心里有一个目的地, 否则就可能到处兜圈子。

#提示70 要对软件做测试,否则只能留给用户去做

毫无疑问测试是编程的一部分,不该留给其他部门的人去做(测试部门算其他部门吗?)。 测试、设计、编码————都是在编程。


好了,抄完了,你应该能读到两位作者的心思:按照 TDD 的思路在编码前考虑测试有好处,但并不是必须要遵守 TDD 流程来实践才能获得这个好处。 那么我再抄一段作者在书中的调侃作为本次补丁的结尾吧:

忏悔

我,Dave,因告诉别人自己不再编写测试,而大出风头。这样做的部分原因是想动摇那些把测试变成宗教的人的信仰。 这样说的部分原因是,这(某种程度上)是真的。

我已经编写了45年的代码,30多年中我都写了自动化测试。构思写测试,已经成了我编码方式的一部分… 我决定停止编写测试几个月,看看代码会出什么事。令人惊讶的是,影响“不是很大”,我花了一些时间来找出原因。

我相信这个原因是,(对我来说)测试的好处更主要来自思考测试,以及思考测试对对代码造成的影响。 在长时间坚持这么做后,我写不写测试都会这样思考。代码仍是可测试的,只是无须真的写出来测试。

…测试也是与其他开发人员交流的一种方式,所以我开始会在与他人共享代码时为其编写测试,或给有外部依赖的事情写测试。

Andy 说我不应该加上这个知识栏,他担心这会诱使缺乏经验的开发人员不写测试。下面是我的折衷方案:

应该编写测试吗?要,但等你写了30年后,不妨从容地做些实验,看看它究竟给你带来的什么好处。

updatedupdated2023-08-082023-08-08