作者 | Buttondown 译者 | Sambodhi 策划 | Tina 测试驱动开发 在 1999 年左右是最前沿的技术,也是现代开发的基础,但为什么直到现在还没有被广泛使用?
“我认为,在我作为一名专业极客的四十二年生涯中,软件行业在历史上始终不能或不愿意掌握和采用测试驱动开发(TDD),这是最令人沮丧和丧气的事件之一。”对于 TDD 没有广泛被应用的问题,GeePaw Hill 发了系列 推文 进行了探讨。他认为问题在于其支持者在组织方面的失败,他们推动得太猛,想将“TDD”转化为“测试很好”。
对此,我认为:对于那些最坚定的支持者来说,其实 TDD 并不像他们认为的那么有价值。
他们中的大多数人将 TDD 的价值 基于自己的经验,因此,我也想基于我的经验来谈谈这个问题。先从我的背景开始讲起吧。我将自己视为“TDD 人”。早在 2012 年我就学会了 TDD,它帮助我获得了第一份软件工作,而我之前的两份工作,都是在 Ruby 中严格执行 TDD。有那么一段时间,我所有的个人项目都遵循严格的 TDD,如果有一天,我头脑一热,创办了一家科技创业公司,我也会使用 TDD 来开发软件。我在 2018 年的时候就会为 TDD 辩护,现在也仍然会为 TDD 辩护。
我和他们的区别在于,我将 TDD 视为一项“有些用处”的技术,是众多技术中的一项;而那些最强烈的倡导者则认为 TDD 是一种“变革”。有些人声称,TDD 对编程的重要性,就像洗手对医学的重要性一样。
为什么会有区别?因为我们指的是两件不同的事情。我实行的是“弱 TDD”,这只是意味着“在代码之前编写测试,在短的反馈周期内”。这有时被贬低为“测试优先”。而强 TDD 遵循的是一个更严格的“红 - 绿 - 重构”周期。
编写一个最小的失败测试。
编写尽可能少的代码来通过测试。
在不引入新行为的情况下重构一切。
重点是极简(minimality)。在其最纯粹的形式中,我们有 Kent Beck 的 test && commit || reset (TCR):如果最小的代码没有通过,那么就把所有的修改都删除,然后重新开始。
另外,对于为什么要进行 TDD,我们也有不同的看法。强 TDD 的支持者们常常声称,这并非一项测试技术,而是一种偶然使用测试的“设计技术”。但我对这一说法感到困惑,原因有二。首先,他们使用“设计”的方式,和我有很大的区别:本地代码组织与系统规范。其次,很多人说它一直就是这样的,而原书中明确地声称,它是一种测试技术。不管怎么说,这是现代强 TDD 的一个核心原则:TDD 让你的设计变得更好。换句话说,弱 TDD 是一种技术,而强 TDD 则是一种范式。
没有人愿意听别人说他们做错了,尤其是他们做错的时候。
如果你尝试了 TDD,但它没有“起效”,而实际上你所尝试的东西根本不是 TDD,那又会如何?——反对 TDD(Against TDD)
为了避免每一个细微的差别,我将集中讨论 TDD 的“极繁主义(Maximalism)”模型:
除最特别的情况外,在任何情况下都必须使用 TDD。
应该尽可能严格遵循 TDD 周期(尽管 TCR 是不必要的)。
测试优先并非 TDD。
TDD 总是能带来更好的设计。
TDD 可避免其他形式的设计。
TDD 可避免其他形式的验证。
TDD 不会失败。如果它引起问题,那是因为你做错了。
TDD 和生产力之间的权衡关系到学习曲线。一旦你到达山顶,那就没有什么权衡的事了。如果你还在谈论权衡,那就表明你可能在山上的什么位置。
我认为,真正的极致主义者并不多,尽管我至少遇到过一个。大多数倡导者在某些方面是温和的,但在另一些方面却是偏激的——我当然也不例外!但是对于更广泛的 TDD 对话是什么样子的,极致主义者是一个很好的模型。尽管人们只是在口头上谈论诸如“使用合适的工具”“没有银弹”之类的东西,但是他们经常发表他们的极致主义的观点,而不分享他们的注意事项。极致主义思想,在整个学科中得到了广泛的传播。
TDD 的极致主义案例来自两个方面:它对你的测试和设计都有好处。
TDD 的开发是复式簿记,同样的原则,同样的推理,同样的结果。
这条推文的论点很简单:在极致的 TDD 下,所写的每一行代码都会被测试所覆盖,这样就会发现更多的 bug。我对此深信不疑。测试覆盖率越高,意味着 bug 越少。
问题在于,TDD 测试非常受限制。为了使 TDD 周期保持快速,你的测试需要快速编写和运行,而且要能在“一秒之内完成数百次的测试”。唯一符合这三个标准的测试是手工制作的单元测试。这就将其他形式的测试排除在外:集成测试、端到端测试、突变测试、模糊测试、性能测试、基于模型的测试。
要想让单元测试足够充分,就必须替代所有其他形式的测试。还必须替代基于非测试的验证技术:手动测试、代码检查、类型系统、静态分析、合同、把断言语句推得到处都是。
“可是,从来没有人说过,你只需要做一个单元测试!”好吧,我们认为自己很幸运,因为我曾经多次经历过这种极繁的情形:如果你使用 TDD,你将不存在任何 bug,因此,如果你存在 bug,那就是你的 TDD 使用不当。
测试驱动开发(TDD)并非一种测试方法。它是一种设计方法。通过使用自动测试,它可以帮助你构建干净、经过测试和无错误的代码。测试不是 TDD 的输出。测试是输入,干净的设计和代码是输出。
就像我以前说过的,TDD 的倡导者使用“设计”的方式与我截然不同,所以让我们先解释一下其中的区别。
对于我而言,设计就是软件的规范。我们需要处理一个问题,以及我们希望保留的一些属性,我们的系统能够满足这些要求吗?比如,设想一个工作器,可以从三条数据流中提取数据,把这些数据合并在一起,然后把他们上载到数据库。我要保证不会出现重度的数据,流的停顿能够得到优雅地处理,所有的数据最终都会合并,诸如此类。我不在乎代码为“API 请求”调用了哪些方法,也不在乎 JSON 响应是怎样转化为域对象的。我只在乎它对数据做了什么。
与此相反,“设计”在 TDD 中是怎样组织代码的。munge
是一个公共的还是私有的方法?我们是否应该把 http 响应处理程序分割成独立的对象?check_available
方法的参数是什么?TDD 的倡导者们谈到了“倾听你的测试”:如果编写测试很困难,那就说明你的代码有问题。你应该重构代码,使其更容易测试。换句话说,难以通过 TDD 进行测试的代码组织得很糟糕。
TDD 是一种设计技术。如果你不需要设计,那么你就不需要 TDD。(测试只是设计过程的一个很好的副作用。)我简直无法想象这样的系统是如此地小,以至于可以不需要任何设计。
但是 TDD 是否能确保良好的组织?我并不这么认为。我们知道,TDD 的代码看上去是不同的。在其他方面:
依赖注入。这使得代码更容易配置,但代价是使其更加复杂。
大量的小函数而不是几个大函数。
广泛采用公共方法,而非深入使用私有方法。
这些一定是坏事吗?不是的,它们会把事情搞砸吗?是的。有时候,大的函数会带来 更好的抽象,而小的函数会导致混乱的行为图。有时候,依赖注入会使代码变得更加复杂,难以理解。有时候,大型公共 API 会让模块之间的耦合变得更紧密,这就是为了鼓励重用“实现对象”。如果 TDD 与你的组织相抵触,那么有时 TDD 是错误的。
现在,这是一个相当弱的论点,因为它同样适用于任何种类的设计压力。极繁主义更具体的问题是,代码组织必须以极少的步骤开发。这导致了路径依赖:代码的最终结果会受到你所采取的路径的强烈影响。按照极繁的 TDD,下面是我写的前七个测试:
quicksort([]) # prove it exists
assert quicksort([]) == []
assert quicksort([1]) == [1]
assert quicksort([2, 1]) == [1, 2]
assert quicksort([1, 2]) == [1, 2]
assert quicksort([1, 2, 1]) == [1, 1, 2]
assert quicksort([2, 3, 1]) == [1, 2, 3]
下面是传递它的最小代码:
def quicksort(l):
if not l:
return []
out = [l[0]]
for i in l[1:]:
if i <= out[0]:
out = [i] + out
else:
out.append(i)
return out
需要澄清的是,我并不是想在这里表现得反常,当我严格遵守 TDD 时,我就是这样做的。有了更多的测试,它就会趋于正确,但由于我们将代码封装在一组小型的测试中,因此设计将会变得很不可靠。
既然我说我正在做的是“弱 TDD”,所以我还是会在快速排序(QuickSort)之前写一个测试。但与最大的 TDD 不同,我不会去编写一个单元测试。而是像下面这样编写:
from hypothesis import given
from hypothesis.core import example
import hypothesis.strategies as st
@given(st.lists(st.integers()))
def test_it_sorts(l):
out = quicksort(l)
for i in range(1, len(l)):
assert out[i] >= out[i-1]
这是一个属性测试的示例。我不是对一堆具体的示例进行编码,而是按照排序的定义进行编码,测试将在随机列表上运行我的代码并检查属性是否成立。概念上的统一进一步深化,这也推动了更好的组织。
这导致了我对极繁主义的 TDD 最大的不满:它强调局部组织而不是全局组织。如果它能让你不对一个函数进行整体思考,那么它也能让你不对整个组件或组件之间的交互进行整体思考。它能带来更好的设计。
James Shore 发了 推文:
架构对于前期设计来说太重要了。
(事实上,我最痛恨的就是这会让人混淆代码组织和软件设计,而非 TDD 的人也会将这两者混淆,因此,或许我只会选择一个非常差劲的话题来进行宣传。)
我已经讲了很多关于 TDD 的废话。就像我以前说过的,我常常实践 TDD 的“弱”形式:在编写代码之前先编写一些验证,但又不坚持极致,甚至不坚持基于测试的验证。TDD 的极繁主义者也许会说它并非“真正的 TDD”,让他们见鬼去吧。
弱 TDD 有四个好处:
你可以编写更多的测试。如果编写一个测试“Gates”来编写代码,你就必须这么做。如果你可以以后再编写测试,你就可以一直拖着,而且永远不会去编写。在我看来,这是向早期程序员教授 TDD 的主要好处。
重构更容易,因为你更容易抓住回归的问题。
现在,在开发代码时,所有代码都至少有一个客户端。这会告诉你界面是否太过笨拙。
它会让你养成一种习惯,就是在你实际没有使用单元测试的情况下,也要考虑你的代码如何被验证。
等等,这些不就是和极繁的 TDD 一样的好处吗?“它检查你是否有笨拙的界面”听起来非常像“倾听你的测试”。嗯,是的。你应该倾听你的测试!TDD 经常使你的设计变得更加完美!
我的观点是,它也可能使你的设计变得更糟。有 TDD 比没有 TDD 好,但没有 TDD 比过度的 TDD 好。TDD 是一种你与其他方法结合使用的方法。有时你会听从这些方法,他们会给出相互矛盾的建议。有时,TDD 的建议会是正确的,有时会是错误的。有时它会错得离谱,以至于你在那种情况下不应该使用 TDD。
今天真是大开眼界。测试驱动开发在 1999 年左右是最前沿的。它是现代开发的基础。我无法想象不使用它。听到公司不使用它,就像听到公司说“你听说过这个叫 Linux 的新东西吗?”卧槽。
所以,在所有这些之后,我有了我的假设,即为什么 TDD 没有传播开来。老实说,这是一种相当反常的假设。极繁的 TDD 并不像极繁主义者所认为的那么重要。TDD 在方法组合中使用得更好。因为有用的方法远远多于一个人所能掌握的,因此,你要选择你想擅长的。通常情况下,TDD 不会被选中。
我将其与 Shell 脚本相提并论。今年这个春季,我花费了大量的时间来学习 Shell 脚本。我想每位开发者都应该懂得怎样编写自定义函数。这是否比 TDD 更重要呢?如果人们没有时间去同时学习,他们会选择哪个呢?如果使用合适的 TDD 所花的时间太长了,那么你能在 Shell 脚本和调试实践中学到一些东西吗?人们什么时候才能停下来?
我甚至不知道我的结局是什么。写这篇花了我三天时间,我不知道它是否让我或你们中的任何一个人有了更清晰的认识。我甚至不知道我的理解是否正确,因为我并没有做很多研究,也没有处理过一些细节上的问题。
原文链接:
https://buttondown.email/hillelwayne/archive/i-have-complicated-feelings-about-tdd-8403
点击底部阅读原文访问 InfoQ 官网,获取更多精彩内容!
英伟达回应“对中国断供部分高端 GPU”;月薪 3.6 万工程师日均写 7 行代码被开;12 年黑进 40 多家金融机构老板赚百万获刑 |Q 资讯
在阿里达摩院搞了四年数据库,我来聊聊实际情况 | 卓越技术团队访谈录