你好,我是徐昊,欢迎和我一起学习测试驱动开发(Test-Driven Development)。
对于测试驱动开发,稍有了解而全无实践的人,会认为是天方夜谭,甚至无法想象为什么需要这样的方式来开发:
为什么要开发人员来写测试?这难道不是测试人员的工作吗?难道开发写了测试,测试人员就不用再测了嘛?
又要写测试,又要写生产代码,效率是不是太低了?只写生产代码效率应该更高吧?
不写测试我也能写出可以工作的软件,那么写测试能给我带来什么额外的好处呢?
的确,从直觉上来看,测试驱动开发相当令人困惑:它将我们通常认为的辅助性工作——测试,作为程序员编码的主要驱动力;它主张通过构造一系列自动化测试(由程序员编写),为编写生产代码(Production Code)做指引;它甚至建议,如果不存在失败的测试,就不要编写生产代码。似乎测试驱动开发有些过分强调测试对于程序员的重要性。
那么为了理解测试驱动开发的核心逻辑,我们需要仔细思考一下“测试”在所谓的“正常软件开发模式”中发挥了怎样的作用,才能讨论测试驱动开发是不是过分地强调了它。
然而直觉和经验同样告诉我们,在所谓的“正常软件开发模式”中,貌似测试只是最后的验收步骤,程序员很少直接参与。但事实却不是这样,就算是所谓的“正常软件开发模式”,也蕴含着非常多“程序员测试”的步骤。只不过这些“程序员测试”并不表现为自动化测试,而是由“测试应用”、“跑一下”和“调试”等手段体现的。
“测试应用”
所谓“测试应用(Testing Application)”并不是某个技巧正式的名字,但是所有人都熟知这一技巧:
构造一个简单的控制台应用(Console Application),并提供 main 入口函数(Entry point);
在 main 函数中,调用所编写的代码,并通过与控制台的交互(各种 printli、writeline 之类的函数),将结果输出在控制台上;
再通过观察控制台的输出,判断结果正确与否。
让我们看一个具体的例子,假设我们需要将某个对象存储到数据库中,以 Java 中的 JPA(Jakarta Persistence API)为例,那么我们大概可以构造出这样的“测试应用”:
如视频中展示的测试应用,符合我们对于验证测试的一切认知:有需要被测试的行为,有明确的执行结果,以及针对结果的验证。那么我们实际上可以很容易地将它改写为自动化测试:
对比这两种做法,从意图上,我们可以粗略地认为,它们是对于同一种意图的两种不同实现:无计划的手动验证与有计划的自动化验证。所以如果你曾经使用过“测试应用”,那么你就曾经在项目中做过“程序员测试”。
“跑一下”
同样,“跑一下(Run it in a local testing environment)”也不是某个技巧的正式名字,从严谨的角度出发,“跑一下”甚至不能算是它真正的名字。它真正的名字应该叫“在我本地的测试环境中跑一下”。同样,所有人也都熟知这一技巧,就真的是“在我本地的测试环境中跑一下”。当代应用通常都在受控环境中运行(Managed Environment),因而当验证某功能时,需要连通其所在的受控环境一起执行。
让我们再看一个具体的例子,假设我们需要实现 REST API,以 Java 中的 JAX-RS(Jakarta Restful WebService)为例,那么我们大概会这样来跑一下:
如视频中展示,无论是通过浏览器直接观测结果,还是通过 Postman 验证,都符合我们对于验证测试的一切认知:有需要被测试的行为,有明确的执行结果,以及针对结果的验证。那么我们实际上可以很容易地将它改写为自动化测试:
对比这两种做法,从意图上,我们可以粗略地认为,它们是对于同一种意图的两种不同实现:无计划的手动验证与有计划的自动化验证。所以如果你曾经也类似这样“跑过一下”你的应用,那么你就曾经在项目中做过“程序员测试”。
“调试”
我想你已经发现了模式,你肯定要猜测“调试(Debug)”也是一种验证测试,但并不是这样!“测试应用”和“跑一下”这两种技巧更多地关注在发现问题上,可以看作是“验证测试”。而“调试”通常发生在已经明显知道有错误的代码中,是一个定位错误的过程。让我们来看个例子:
如视频中展示,“调试”,而是一种启发式过程(Heuristic Procedure)。更像是探索测试(Exploratory Testing),根据出现的错误寻找可能出现错误的位置,然后设置断点,判断该断点处状态是否正确。
除了调试之外,我们还可以将代码划分成更小的单元,逐一排查以定位错误。那么我们就可以将对于某段代码的调试过程,转化成对一组更小粒度单元的验证测试:
在软件开发中,一直都存在验证性测试和定位性测试两种测试。这也很好理解,我们既要知道代码有没有错误,也要知道当错误发生时,错误发生在哪里。
从定位性测试的角度出发,对比这两种做法,从意图上,我们可以粗略地认为,它们是对于同一种意图的两种不同实现:手动的启发式定位与有计划的逐模块自动化排查。所以如果你曾经也类似这样“调试”过你的应用,那么你就曾经在项目中做过“程序员定位测试”。
测试驱动开发的核心逻辑
除去我们讨论的三种,在所谓的“正常软件开发模式”中,还存在很多其他常用的手段,也都可以看作是自发性的“程序员测试”。任何有过严肃编程经验的从业者,都能根据自己过往的经历,回想起这些年所做过的“程序员测试”。
我们构造软件的过程,就是通过一系列验证测试(跑一下**、测试应用等),证明我们在朝着正确的方向前进;如果验证的过程中,发现出了错误,再通过一系列定位测试(调试等);然后找到问题的根源,加以改进。如此往复,直到完成全部功能(如下图所示)。
现在让我们回到最初的问题:测试驱动开发过分强调测试对于程序员的重要性了吗?答案是:并没有!
验证测试与定位测试,本身就贯穿了整个软件构造的过程。测试构成了整个开发流程的骨架,功能开发可以看作填充在测试与测试之间的血肉。这就是测试驱动开发的核心逻辑:以测试作为切入点,可以提纲挈领地帮助我们把握整个研发的过程。
一个个测试就像一个个里程碑点(Milestone),规划着研发活动的进度。围绕这些里程碑点,我们就可以持续对成本和效率进行管理和改进。也就是说,测试驱动开发将个体的开发活动,变成了工程化的研发过程。这也是为什么,三十年以来,测试驱动开发在敏捷方法族中,都扮演着工程实践基石的角色。
因为测试是如此重要,我们需要非常高效地实现它们。那么“无计划的手动验”与“手动的启发式定位”都是无法容忍的低效手段。必须将它们替换为“有计划的自动化验证测试”和“有计划的逐模块自动化排查”。从而才有了我们熟知的测试驱动开发循环(红 - 绿 - 重构),以及令没有做过测试驱动开发的人费解的对于自动化的偏执。
到这里,我想你应该就会明白了,测试驱动开发并不是关于“怎么写测试”、“怎么消除测试人员”、“怎么让开发人员多干一份活”的编码技巧。它是以测试为里程碑点的工程化研发过程;同时,测试驱动开发将软件流程中无时无处不在的低效测试手段,以可重复的、高效的自动化测试代替,以获得更高的工程效能。
这就是隐藏在测试驱动开发反直觉的工程实践背后的核心逻辑。
学习测试驱动开发的难点在哪里?
学习测试驱动开发是困难的,很多信服于测试驱动开发理念自发实践的人也会被各种问题困扰:
测试从哪里来?为什么我写了很多测试,功能却没有进展?
写什么样的测试既能驱动功能进展,又不会在重构中被破坏?
社区里很多人都非常推崇单元测试,但我就是要测一段 SQL,单元测试怎么测?
测试驱动开发从来都不是一种即插即用的技能,它是一种工作习惯和思维方式,背后还对深层的胜任力(Competency)—— 分析性思考有极高的要求。某种程度上讲,测试驱动开发有点像物理,定理写出来很简单,但却需要我们在不同的场景下练习,才能应用得得心应手。
正是因为这样的特点,我们的课程是这样设计的…
开篇寄语
作为中国最早一批测试驱动开发的实践者,从 2003 年开始,我就采纳测试驱动开发作为主要工作方式了。
在加入 Thoughtworks 之后,对内对外我讲述了大量测试驱动开发的课程。曾经有一段时间,每一位新入职的 Thoughtworker 我都会通过 6 周的时间,教会他们进行测试驱动开发。
当我主持 Thoughtworks 委培生计划——小巨人项目时(Small Giant Program)时。测试驱动开发与学习管理,是最早也是最重要的工作习惯。近年我研发的高效能工程方法 SEELE(Scalable Engineering Experience for Large Enterprise)也是以测试驱动开发作为核心流程,再简化知识传递成本并提高杠杆率。
测试驱动开发伴随了我职业生涯的每一个阶段。我相信,我掌握了测试驱动开发那天,我才成为了可靠、高效的职业程序员。如果你对程序员这个职业抱有严肃的态度,那么测试驱动开发是必须要掌握的。