TDD的定义、历史与核心困境
测试驱动开发(TDD: Test Driven Development,一种以测试驱动开发的软件开发模式)简单来说,是一种在拿到开发需求后,先凭空把测试写出来的方法。由于此时还没有实际代码,测试必然会失败。接下来的工作就是编写实际代码,让测试结果的红灯一个个变绿,直到全部通过。之后,在对代码进行重构或增加新功能时,也要确保已有测试继续保持绿灯状态。
作为一名程序员,你或多或少都听说过TDD,但你们中的大部分人应该都没有真正实践过TDD。毕竟这种做法还是有点极端,与大家平常的开发模式区别较大。愿意冒着风险尝试的,一般情况下都是因为项目负责人自己就是TDD的“教徒”。这类人相对而言不多,所以与前两期的UML(Unified Modeling Language: 统一建模语言,一种用于软件系统可视化、规范、构造和文档化的通用建模语言)和Agile(敏捷开发: 一种以迭代、循序渐进的方式进行软件开发的管理方法)相比,TDD在计算机行业的影响力没有那么大。
但TDD同样是90年代个人计算机热潮中诞生的软件开发理念。TDD之于代码测试,就像UML之于系统设计,Agile之于项目管理,都具有历史代表意义。所以TDD绝对值得一期“墓志铭”。而且从它的失败中,我们还能尝试解答一个困扰大家已久的问题:为什么我们都不想写测试,都不想更新它、修复它?即使我们在日常工作中,最讨厌的就是别人不写、不更新、不修复测试。测试本应该是我们的守护者,却活成了我们最讨厌的邻居。答案,就在TDD犯下的错误里。
NASA水星计划与XP模式的诞生
在60年代,美苏冷战进入到比拼载人航天的环节。NASA(美国国家航空航天局)的水星计划(Mercury Program: 美国在冷战时期旨在实现载人航天的早期太空计划)只有一个硬指标,就是要比苏联快。所以他们的软件开发部门被设计成开发和测试并行的组织结构。一旦产品的功能需求文档定稿,就会被同时发送给开发组和测试组,两边同时开工,最后“顶峰相见”。
作为美国历史上最成功的工程项目,水星计划中用到的诸多项目管理方式,也自然成为后人借鉴的模板。这其中就有一位叫做Kent Beck的青年。没错,他就是上期视频的男主角,eXtreme Programming(XP: 极限编程,一种强调极限实践的软件开发方法论)模式的创始人。他很喜欢借鉴古人的做法,然后再把它们推到“极限”的程度,这也是“extreme”这个词的来源。
当他在克莱斯勒的项目组里打造XP模式时,在测试这个工作上,他也借鉴了NASA的做法,只不过他更极端,直接把测试的位置提到了最前沿。在他的模式下,需求文档确认后,首先要做的就是用单元测试(Unit Test: 针对程序最小可测试单元进行验证的测试方法)把所有可能出现的状况都测一遍。这相当于用测试代码将需求文档翻译了一轮。那么接下来入场的开发,就不再是由需求文档来引导,他们唯一的任务就是写下能让这些测试通过的代码。这就是测试驱动的开发,TDD的来源。即使在XP全家桶里,TDD也算是比较极限、比较极端的那个。
因为大多数XP理念只是一种指导型方案,只包括对大方向的描述,具体怎么做还得看程序员自己的理解和开发团队的相互配合。但TDD是一个非常具体的方案,它基本上是在手把手教你怎么写代码。它会深入影响每一个人的编程风格,你每次敲击键盘都要遵循它的规则。这也是为什么喜欢它的人很喜欢,讨厌它的人很讨厌。那些喜欢TDD的,通常都会非常积极地向周围的人推荐TDD的做法。具体原因可以参照人类宗教史,某种程度上这也造成了TDD无处不在的假象,因为它的存在感确实是比它的实际存在要大很多。
TDD的局限性与失败
就像在上上期里出自于RUP(Rational Unified Process: 统一软件开发过程,一种迭代的、以用例为驱动的、以架构为中心的软件开发过程)管理模式的UML,TDD也因为它广泛的通用性脱离了XP,成为一个独立传播的开发模式。只不过,它的传播显然没有UML那么成功。作为一个诞生在90年代的开发模式,TDD的第一个国际级会议是在2021年才召开的。14位演讲者通过线上会议的形式,从各个角度分享了自己的TDD成功故事,以及为什么大家都应该用TDD。截止至本视频发布,据我所知,这也是TDD的最后一个会议。
在单元测试层面定下代码要求,在有经验的人听起来就像是一个坏主意。因为这相当于让具体代码的实现细节和测试过度耦合了,会锁死代码之后的调整空间。而作为首个应用TDD的软件项目,Kent Beck主导开发的克莱斯勒员工薪酬管理系统,在第一个版本交付后就因为员工过劳,无法继续。两年后就被关停了。作为XP发源地的克莱斯勒也宣布,集团内部禁止一切XP模式。
不知道是不是受到这次失败的教训影响,TDD的执行也开始从单元测试层面往上走。他们不再追求最小单位的测试,而是转向更大范围的,在需求层面的测试。这也衍生出了验收测试驱动开发(ATDD: Acceptance Test Driven Development,一种将单元测试替换为面向客户验收测试的开发模式),将单元测试替换成面向客户的验收测试。但不管测试的范围怎么调整,TDD有一个本质是不变的,那就是尝试在实际开发开始之前,在测试代码中想象和框定最终成果。
软件需求:一个时刻变化的变量
这种透支未来的做法,听起来是不是有点既视感?因为这也是前两期“墓志铭”主角UML和Agile在做的事情。不管是代码测试、系统设计还是项目管理,正常的顺序都应该是先有需求,然后有实现,最后有结论。但TDD、UML和Agile想的都是把后面两步反过来,先下结论,再去实现。这样是不是就能运筹帷幄,事半功倍了呢?
这种抄捷径的想法,通通忘记考虑了最重要的一个影响因素:那就是在软件项目中,“需求”不是一个常量,而是一个时刻在变化、在游走的变量。不管这个需求是来自业务方的功能需求、来自领导层的业绩压力,还是来自生产环境的安全隐患,它往往都是一个移动靶。当你的理论是基于一个假设的常量,然后在这个常量的基础上画坐标,在这个坐标上建公式,最后自证这个公式在理论上成立,那么在你发现这个常量不存在,固定的坐标也不存在的时候,你的公式就会出现越来越大的偏差,直至无法使用。牛顿他老人家就吃过这个亏。
重新审视测试的价值与定位
测试本应该是我们的好朋友,前提是摆正它的位置。在TDD眼里,测试是一种代码化的需求文档。比起普通的需求文档,它过于具体,没有解读空间,让写代码的程序员觉得自己就是个工具人。而且不管是下到单元测试,还是上到验收测试,TDD提前定好了各种函数和功能接口的做法,会让后续开发中任何的调整都十分吃力。而且很多项目的管理团队会把测试定义为一种KPI,将其包括在交付的指标中。所以,很多测试工作只是在纯粹地刷数据,比如经典的100%测试覆盖。它们的价值定位被扭曲,也不怪程序员嫌弃了。
在我看来,测试本质上就是一种模拟。测试的价值就是可以伪装成真正的客户,模拟使用代码和功能,模拟检验返回的结果。这个模拟的场景越真实,越接近现实的生产环境,效果自然越好。我们见过很多测试是通过了,但是在生产环境还是出bug的情况,这是因为测试的场景和实际场景不一样。所以我不会在项目的设计阶段,甚至是开发初期考虑测试问题。而是在软件初见雏形、设计基本定型、大致流程跑通、可以看到结果之后。因为在这个时候,我有了一个基本能用的产品,可以想象得到真实的使用场景是怎么样的,这个产品在哪里最薄弱,哪里交互最复杂,哪里出错最危险,都已经有一个大概的范围。就像著名程序员Atomic Energy说过的:“the moment you know what to fear is the moment you know what to test”。
这种理念听起来很凭感觉、很不客观,但如果你有看完前两期“墓志铭”,你应该能够感受得到,软件工程就是无法完全客观的东西。所以我觉得在大方向上制定规则,然后在具体执行上让有经验的人自主决定,才是最优解。
作者的测试实践分享
我简单分享一下我的做法,不是为了“安利”,只是拿来跟TDD做一下对比,孰好孰坏,你们自己决定。我这里有一个系统,它包括了PostgreSQL(一种开源的关系型数据库管理系统)、Redis(一个开源的、内存中的数据结构存储系统,可用作数据库、缓存和消息代理)、一个前端API、一个支付API、一个异步工作机(Async Worker)。这里只有前端API是面向客户的。
回到二三十年前,因为技术限制,我们没有能力在本地模拟全套的系统交互逻辑。所以要么只做一些简单的单元测试,要么就用各种mocking框架来做假惺惺的所谓模拟。但现在容器技术(Container Technology: 一种轻量级的虚拟化技术,允许应用程序及其依赖在隔离的环境中运行)已经很成熟了,所以在本地一比一还原生产环境只需要一个简单的Docker Compose(Docker Compose: 用于定义和运行多容器Docker应用程序的工具)配置文件。不熟悉容器技术的可以去看《让编程再次伟大#26》的介绍。
模拟环境搭建好后,我用的是一种叫做基于属性的测试(Property-based testing: 一种测试方法,通过生成大量随机输入来验证代码是否符合预设的属性或规则)的测试手法。具体做法就是模拟真实客户可能做的操作,随机生成各种输入参数,随机调用各个前端API,然后针对系统返回的结果当场判断它是否符合所测功能应有的特性。比如说,某个随机生成的输入是不合要求的,那么按照这个功能的特性,就应该返回400。又比如某个API的调用顺序不对,就应该返回403之类的。这样我们就不需要手搓test case,而是随机模拟,从而把所有乱七八糟的可能性都测一遍。
因为整个测试环境都在本地,所以响应速度也很快。普通笔记本也能在1秒内模拟100个test case。在稍微好一点的台式机上,我们还能够搞个100倍并发,顺便也把压力测试给做了,一石二鸟。这种测试我们写起来很积极,维护起来也很认真。因为我们知道,只要这些测试能通过,我们就很有信心,这个系统上线之后不会被客户玩坏,我们晚上就能安睡。而这,就是我们的测试之道。