敏捷技术实践之TDD
by Teobler on 21 / 02 / 2021
views
至此,生命之环的外圈和中间的一圈已经介绍完了,现在开始的就是内圈的技术实践,也是敏捷最为关键的实践,技术实践能否有效执行关乎着外围实践能否成功,可以说是敏捷最为重要的支撑。
技术实践要求开发人员进行大量的分钟级甚至秒级的,**深刻的、充满仪式感的行为。**以至于大部分团队尝试将这些实践去掉,但是去掉之后你就会发现什么叫做伪敏捷。这些技术实践才是敏捷的核心。没有测试驱动开发、重构、简单设计以及结对编程的敏捷注定没法成功。
需要明确的是,这些技术实践的每一项单独拎出来都能写一本书。所以文章中不会介绍详细内容,只会粗略讲解,欲知后事如何,请直接去看经典书籍。我们先从测试驱动开发开始讲起。
测试驱动开发
程序员的职责是什么?显而易见 - 写程序。程序由什么构成?是的,代码。一行行代码组成了代码文件,一个个代码文件组成了程序,这些代码不能出错,哪怕一个字符的错误,也可能导致完全相反的结果,给用户造成不可估量的损失。那么程序员应该怎么保证自己写的代码是没错的呢?
我们先来看看存在了很久的另一个职业 - 会计,是怎么做的。会计的职责跟程序员很像,他们也要通过一行行记账信息组成账本,同时信息也不能发生任何错误。
早在 1000 年前,他们就发明了复式记账。简单来说,每一笔交易会写入账本两次 - 在一组账户中记一笔贷项,在对应的另一组账户中记为借项。两个账户最终汇总到收支平衡表中,差额为零,如果不为零,那大概是出错了。
会计同学别打我,这只是简化
从一开始,会计师被要求一笔笔地记录交易并在每一笔交易记录后立即平衡余额。这样做的好处是可以立即发现错误,如果同时记录多次交易再去平衡余额会导致定位错误变得复杂。
测试驱动开发这是实践就是程序员界的同一实践。它要求程序员每次只添加一个行为,先写一个失败的测试,然后写出恰好能使这个测试通过的生产代码。这可以立即发现错误。同样地,如果先写一大堆生产代码,再来补测试,你很难发现自己的代码有什么问题。
这两种实践的目的只有一个,在一个重要的文本中避免出现错误。
TDD 三原则
TDD 的规则很简单,可以归纳为下面三条:
- 先编写一个因为缺乏生产代码而运行失败的测试,然后编写生产代码。
- 只允许编写一个刚好失败的测试 - 编译失败也算失败。
- 只允许编写刚好能使当前失败测试通过的生产代码。
看起来蠢吗?蠢,是的,我第一次接触 TDD 也觉得这个规则蠢透了。为了写一个一加一等于二,我得先断言一加一的确等于二,再开始写真正的一加一。
如果一个程序员严格遵守三原则,他的工作状态是什么样的?
先为不存在的生产代码编写测试,因为测试调用了不存在的元素,编译失败。在生产代码中补上这个元素后,测试通过。回到测试文件接着写测试...这意味着程序员的工作周期只有几分钟甚至几秒钟,他们需要在极短的时间内在测试代码和生产代码之间反复切换。
这意味着你不可能一次写完一个完整的函数,甚至不能写完一个完整的 if 语句。因为测试需要刚好失败,代码需要刚好通过测试。大多数程序员认为这三条规则彻底打乱了他们写代码的思路,以至于无法容忍这看起来荒谬的规则。
所以,这样做真的能带来好处么?
调试
严格遵守 TDD 三原则,意味着任何程序员写出的代码,在一分钟前都是总是可工作的。这意味着什么?意味着你现在遇到的错误都发生在一分钟以内,调试一个一分钟以内发生的错误,对于程序员来说还不是信手拈来?你甚至不需要动用调试器,靠脑子就能想出来哪里出了问题。
测试驱动开发的程序员都不擅长使用调试器,因为他们不经常使用调试器,他们经常打交道的是自己刚刚写好的测试。那么是不是这样写出来的代码就一定没有 bug,完全不需要调试呢?
当然不是,TDD 的目的是为了让你的代码质量更好,但谁也没法保证写出来的代码没有 bug,但是 TDD 大大降低了 bug 的发生率和严重性。
文档
有多少程序员在写文档的工作中挣扎浮沉?程序员什么时候最喜欢文档?当然是集成/接手别人代码库的时候。但是就算是集成,你会仔细看那个又长又臭的 pdf 文件吗?还不是直接跳到代码示例看代码,毕竟文档会骗人,代码可不会。
如果你遵循三原则,你写出的每一个测试都是一份代码示例,如何调用 API,如何创建某个对象。测试已经有了超过 90% 使用场景覆盖。
**你写出的测试已经成为了被测系统的文档。**它以熟悉的语言编写,可运行,永远与生产代码保持同步。这份文档是完美的,因为它本身就是代码。
而且,测试本身并不能互相组合成一个系统。这些测试彼此之间并不了解,也并不互相依赖。每个测试都是一小段独立的代码单元,用于描述系统一小部分行为的方式。
完备性
看到这,可能有人会说,那我能不能不 TDD,先把生产代码写完,最后再来补测试?那么我们来看看这样做会发生什么。
首先你已经写完了所有生产代码,你现在开始补测试,不出意外的话,你编写的所有测试都会通过。一切都很开心,直到你遇到了一个不太好测试的函数,因为你没有先写测试,你也没有考虑可测试性。
现在你需要先去改变生产代码,然后再来补上这个不太好写的测试。那么问题来了,你怎么能保证你已经写好并且正确运行的生产代码在经过你的二次修改后行为不被改变呢?同时为了让其易于测试,你可能要打破耦合、添加抽象、增加函数...太烦了,明明它现在可以工作啊!
好,不测了,时间紧任务重,改起来太烦了,反正它现在可以工作,挂了再说,接着补其他测试。
测试补完了,运行一遍测试,全是过的,开心,交差!
你的测试不完整!这里通过的所有测试只给出了一个信息:被测到的功能没有被破坏。那么,那些没有被测试的功能呢?你有没有信心说我的测试全过了,我的代码可以部署了?
如果遵循 TDD 三原则,意味着你的每一行生产代码都是有测试保证的 - 先有的测试,才有的你那一行恰好可以通过的生产代码。你的测试是完备的,你有信心部署你测试全过的代码,这些测试告诉我们,我们的系统是可靠、可部署的。
但是是不是这样的代码就是 100% 覆盖了呢?显然不是,甚至三原则在某些情况下是不适用的(要了解更多的情况建议看书),这意味着就算你严格 TDD,也不太可能写出 100% 覆盖的代码。但是它依旧能给你带来 90% 以上的覆盖率,这已经足以支撑我们接下来的部署了。
乐趣
你又说了,既然你提到了测试完备性,那我可以使用测试覆盖率工具来追踪我的测试覆盖率,然后慢慢往上补呀。
如果你真的这么做过,你应该不会这么乐观地说出这句话。先不说难易程度,就这个过程来说实在是太枯燥了。代码已经可以工作了,你还在考虑各种场景,补上各种场景的测试。如果不信,你可以去试试这个过程有多无聊。还不说这路上会遇到的那些你没法写测试的代码。
设计
TDD 的全称是什么?是的,Test-Driven Development。但是在公司里,我听到了他的另一个名字 - Test-Driven Design。这里的设计如果展开来讲又可以讲一个长篇了,这里只聊聊代码的可测试性。
我们回头想想为什么之前提到后补测试会出现没法测的代码?因为它与别的行为耦合在一起,你没有将它们设计成易于测试的代码。如果事后补测试,可测性应该是在你脑海中最不可能出现的词语。
而如果你先写测试,那么你的代码不可能出现不可测试的代码。这迫使你将生产代码设计成易于测试的样子,怎么样方便测试?是的,解耦,你的代码将是耦合度很低的代码,TDD 强迫你写出高度解耦的代码。
勇气
上面提到了一系列 TDD 所带来的的好处,而我们最终需要的,其实是这些好处给我们的勇气,修改旧代码的勇气。
假如你在代码库里看到烂代码,你的第一个念头是“清理”一下,但转念一想,现在它是工作的,万一我改动以后不工作了怎么办,还是随它去吧。说白了这是一种恐惧心理,恐惧来源于没有安全感,没有安全感来源于未知。
如果团队中每一个人都抱有同样的心理,那么这个代码库一定会腐烂。在每次添加新的功能时,为了不改坏已有的功能,大量引入耦合和重复。于是大量的 if else 出现,代码越来越不可读,开发效率越来越慢,管理者越来越绝望。
可是假如所有代码都是 TDD 出来的,意味着它拥有一个完备的测试套件,这个保护网能给我们足够的勇气去修改旧代码 - 只要测试不挂,我的修改就是没有问题的!你不再恐惧修改代码,也不再堆积屎山, TDD 使我们表现得像一个专业的程序员 - 我们对我们的代码有完全的掌控。
总结
总有人说,我单纯写测试,不采用 TDD 的方式也能带来 TDD 的那些好处。但其实 TDD 所带来的的好处远远不是测试那么简单。
更少的调试,高质量的完备文档,极高程度的代码保护测试,高解耦程度的代码。以及这些好处带给你修改旧代码的勇气!