本文要点
我们知道,任何软件都必须通过测试确保其功能符合要求;此外我们还要测试软件的非功能性指标,如安全性、可用性,尤其是可维护性。
测试驱动开发(TDD)是一种成熟的技术,可以帮助开发者交付更优秀的软件、缩短交付周期,并使交付周期更加稳定。
TDD 的理念很简单:在编写生产代码之前先编写测试代码。但要实践好这种“简单”的理念也需要技巧和判断力。
TDD 实际上是一种设计技能。TDD 的本质是利用一系列小型测试来从零开始一步步设计系统,并在系统逐渐成型的过程中快速获得价值。这种技能其实改名叫测试驱动设计更合适些。
一般来说,为某个问题开发解决方案时,第一步就是不管问题有多复杂,先分析问题的结构并把它拆分成许多小需求。之后这些需求就可以逐步实现,解决每一小步时都要同时考虑输入和输出场景。
我们需要测试软件以确保其满足要求;软件要正确响应输入(输入验证),在可接受的时间内完成任务(性能测试),可以被用户正常安装和运行(部署测试),还要达成利益相关方的目标。这些目标可能是业务成果或业务功能,诸如安全性、可用性、可维护性等。
测试类型包括:
烟雾测试和可用性测试,检查软件的基本功能运行情况。
持续测试,每次迭代时运行,比如运行 Maven 时做测试。
回归测试,每次添加新的编程代码或改动现有代码时使用。我们希望通过测试确认其它代码依旧正常工作。
性能测试,测量软件完成任务所需的时间。
验收测试,考察利益相关者是否满意软件的表现,是否愿意支付费用。
单元测试是一组测试中最小的模块。编程代码中的每个类都伴随有一个单元测试类。可通过模拟方法调用将测试与其它类隔离。
集成测试更容易实现,我们会测试一个包含所有依赖项的类。测试成功的话我们就知道软件内的路径是畅通的,但如果测试失败,我们也没法判断到底是哪个类出了问题。系统测试则会检查整个系统,包括硬件操作系统、Web 服务等等。
测试应该是可读的,以便非程序员阅读或改动测试内容。在敏捷团队中,程序员与测试人员和分析人员协同工作,测试和规范则属于协作内容,因此大家都应该能看懂测试,乃至在必要时改动测试内容。
TDD:重点在于设计和生产力
测试驱动开发(TDD)是一种可持续交付优秀软件的成熟技术。TDD 的理念很简单:在编写生产代码之前先编写测试代码。想要加一项功能?那就先写一项测试吧。但要实践好这种看似简单的理念也需要技巧和判断力。
TDD 实际上是一种设计技能。TDD 的本质是利用一系列小型测试来从零开始一步步设计系统,并在系统逐渐成型的过程中快速获得价值。这种技能其实改名叫测试驱动设计更合适些。
作为一种设计方法,TDD 的重点在于专注和简洁。它的目标是避免开发者编写多余的代码,有些代码是不产生交付价值的。TDD 的理念是用最少的代码来解决问题。
有很多文章称赞 TDD,列举出了它的众多优势;很多技术会议上也会宣传做测试的好处。这些宣传并没有夸大其辞,测试的确是非常必要的!TDD 的优势真实存在:
它能让你写出更好的软件。
让你避免多余的劳动。
当你引入新功能时,TDD 能防止你搞砸一切。
你的软件因此能实现自我记录。
虽说我一直很认可这些优势,我也曾有一段时间觉得就算不用 TDD 我也能写出优秀、可维护的软件。当然现在我知道自己错了,但为什么 TDD 好处这么多我还会有那种念头呢?是因为成本!
TDD 的成本太高了! 诚然,不做测试的话总成本的确会更高,但那种情况下成本会分摊在不同的阶段。如果我们开始使用 TDD 就要立刻增加投入,相反,不用 TDD 的话代价在未来才会体现出来。
顺其自然完成工作是最理想的状态。人的本性很懒惰(软件开发者大概是懒惰的极致了)还贪婪,所以我们希望降低成本的手段能立刻生效——说得容易,做起来就难了!
围绕 TDD 有许多理论、维度和视角,但我更想谈一谈 TDD 的实践应用。最后,随着我们一步步分析研究,我们会发现有些事情是只有用 TDD 才能做到的。
这里是我的演讲:“单元测试和TDD理念与最佳实践指南”,其中包含以下主题:
为什么我们需要测试,
测试类型,
每种类型应该如何及何时使用,
测试级别简介,
测试策略简介,
TDD 的实践应用。
演讲内容还包括指导和最佳实践,介绍测试时的注意事项。
“TDD 的实践应用”这部分和演讲中提到的理念是适用于所有编程语言的,而我演示时用的是 Java。我们要展示的是设计和创作优秀作品时的思维过程,不是教人写代码那么简单。
分析问题
不管解决什么问题,第一步就是不管问题有多复杂,先分析问题的结构并把它拆分成许多连续而完整的的解决步骤,同时考虑输入场景和输出的内容。接下来我们检查这些步骤,从业务角度确保它们的结果和原始需求是一致的,不多也不少。这时候先不管具体的实现细节。
这是关键的一步;其中最重要的是要能识别出手头上给定问题的所有需求,以减轻之后具体实现阶段的负担。将任务拆分成许多小步骤后,我们将获得干净、易于实现、可测试的代码。
TDD 是开发和维护这些步骤的关键,我们要通过 TDD 来覆盖手头问题的所有可能情况。
假设我们需要开发一个转换工具库,功能是将罗马数字转换为等效的阿拉伯数字。作为开发者,我要做的事情有:
创建一个库项目。
创建类。
应该会具体研究一下,开发出转换的方法。
想一想可能存在的问题场景和应对方式。
为这个任务编写一个测试用例,先把写测试的任务搞定(其实很可能写不出来什么东西),同时已经用老办法几乎做完测试工作了。
这种流程可太糟心了。
为了在编写代码时正确启动流程并将 TDD 付诸实践,请遵照下面这些实践步骤行事,这样就能成功做好一个项目了;同时你还会得到一套测试用例,减轻未来开发工作的时间和成本负担。
这个示例的代码可以从我的GitHub库克隆下来。启动终端,指向自己喜欢的位置,然后运行以下命令:
我已经做好了设置,项目的每次 TTD 红色/绿色/蓝色改动都会提交一次,因此在查看提交历史时我们就能发现哪些改动和重构是符合最终项目需求的方向了。
我用的是 Maven 构建工具、Java SE 12 和 JUnit 5。
TDD 的实践应用
为了开发上文所说的转换器,我们要做的第一步是写一个测试用例,将罗马数字 I 转换为阿拉伯数字 1。
这里需要创建转换器类和方法实现,以使测试用例满足我们的第一个需求。
等等,等等!稍等一下!这里有一条实践中总结的经验,最好记住这条规则再开始干活儿:首先不要创建源代码,而是先创建测试用例中的类和方法。这叫意图编程,因为我们在命名新类和将要使用新类的新方法时,必须要考虑我们正在编写的这段代码的用途和用法,这样当然就会设计出更出色、更干净的 API 了。
第 1 步
首先创建一个测试包,还有类、方法和测试实现:
包:rs.com.tm.siriusxi.tdd.roman
类:RomanConverterTest
方法:convertI()
实现:assertEquals(1, new RomanConverter().convertRomanToArabicNumber(“I”));
第 2 步
这里没有失败的测试用例:这是一个编译错误。所以先用 IDE 提示在 Java 源文件夹中创建包、类和方法。
包:rs.com.tm.siriusxi.tdd.roman
类:RomanConverter
方法:public int convertRomanToArabicNumber(String roman)
第 3 步(红色状态)
我们需要确认我们指定的类和方法正确无误,测试用例也能正常工作。
通过实现 convertRomanToArabicNumber 方法来抛出 IllegalArgumentException,我们应该就能达到红色状态了。
然后运行测试用例。我们应该能看到红色指示条。
第 4 步(绿色状态)
这一步我们需要再次运行测试用例,这次要看到绿色指示条。我们要用最少的代码量满足测试用例转绿的要求。所以方法应该返回 1 这个值。
运行测试用例。我们应该能看到绿条了。
第 5 步(重构阶段)
现在该做重构了(如果有能重构的代码的话)。需要强调的是不仅生产代码要重构,测试代码也得重构。
从测试类中删除未使用的导入。
再次运行测试用例,这次应该看到蓝条——哈哈哈,开个玩笑,哪来的蓝条。如果重构代码后一切正常,我们应该能再次看到绿条。
删除未使用的代码是常用的简单重构方法,可以提高代码的可读性,减少类的占用空间,从而优化项目的存储需求。
第 6 步
这一步开始,我们的流程就会统一成红到绿到蓝的顺序。我们先写一个问题的新需求或新步骤作为失败的测试用例来达成 TDD 的第一步,也就是红色状态,然后重复这一过程,直到我们完成整个功能。
注意,我们要从基本的需求或步骤起步,然后一步步走下去,直到我们完成所需的功能。这样我们的路线就很清楚了,是由简单到复杂的顺序。
这样的流程是最好的,不要花费大量时间从一开始就考虑整体实现,因为这可能会让我们想得太多,甚至搞出来一些不必要的功能。这样做会导致过度编程,效率自然低下。
下一步工作是将罗马数字 II 转换为阿拉伯数字 2。
在同一个测试类中,我们创建了一个新的测试方法及其实现,如下所示:
方法:convertII()
当我们运行测试用例时会看到红条,因为 convertII()方法测试失败了。convertI()方法还是绿色状态,这样就很好,说明其它代码没有受新代码影响。
第 7 步
现在我们需要让测试用例跑出绿色状态。我们来实现一种可以同时满足两种情况的方法。我们可以用简单的 if/else if/else 检查来处理这两种情况,在 else 情况下我们会抛出 IllegalArgumentException。
这里要避免的一个问题是某行代码(例如 roman.equals(“I”))导致的空指针异常。要修复这个问题只需将相等情况转换为”I”.equals(roman)。
再次运行测试用例,所有情况都应该是绿条了。
第 8 步
现在我们可以设法重构案例了,闻一闻哪些代码“味道不香”。重构时,我们通常会找出下面类型的代码做调整:
方法很长,
重复代码,
很多 if/else 代码,
switch-case 语句,
需要简化的逻辑
设计问题。
这个示例中的代码问题(你找到了吗)是 if/else 语句和返回太多。
也许我们应该引入一个 sum 变量,并使用 for 循环来遍历罗马字符的字符数组。如果一个字符是 I,它将对 sum 加 1,然后 return 变量 sum。
但我喜欢防御式编程,所以我会将 throw 子句移动到 if 语句的一个 else 语句中,以便覆盖所有无效字符。
在 Java 10 及更高版本中,我们可以使用 var 来定义变量,写 var sum = 0;代替 int sum = 0;。
然后再次运行测试以确保我们的重构不会影响任何程序功能。
糟糕——我们看到了一个红条。哦!原来所有测试用例都返回 0,我们的错误是没返回 sum 却返回了 0。
解决这个问题后就能看到美丽的绿条了。
这说明无论是多小的变动,都需要在添加改动后运行测试。重构时很容易引入错误,而测试用例就是我们的辅助工具;运行测试就能找出错误。这就是回归测试的力量。
再看一下代码,还有另一种问题(你看到了吗?)。这里的异常不是描述性的,因此我们必须提供有意义的错误
再次运行测试用例,所有情况都应该是绿条。
第 9 步
我们添加另一个测试用例,将罗马数字 III 转换为 3。
在同一个测试类中,我们创建一个新的测试方法及其实现:
方法:convertIII()
再次运行测试用例,全绿。我们的实现能覆盖这种情况。
第 10 步
现在我们需要将罗马数字 V 转换为 5。
在同一个测试类中,我们创建一个新的测试方法及其实现:
方法:convertV()
运行测试用例会看到红条,convertV()失败了,其它部分还是绿色。
将 else/if 添加到主 if 语句来实现这个方法,并加入检查,如果 char = 'v’则 sum+=5;。
这里我们可以重构,但不要在这一步做。在实现阶段,我们唯一的目标是让测试通过,看到一个绿条。现在我们不关心简化设计、重构或代码优化这些事情。当代码通过测试后,我们可以回来再做重构。
在重构状态下,我们只关心重构工作。一次只关注一件事以避免分心、提高效率。
测试用例应该是绿色状态。
我们有时需要一串 if/else 语句;为了优化它,可以按用例访问频率来对 if 语句测试用例排序。如果可以的话,改为 switch-case 语句来跳过测试就更好了。
第 11 步
现在我们处于绿色状态,轮到重构阶段了。再来看方法,我们可以改动一些让人讨厌的 if/else。
也许可以不用 if/else,我们可以引入查询表并将罗马字符存储为键,将对应的阿拉伯数字存储为值。
那就删除 if 语句并替换成 sum += symbols.get(chr);。右击气泡,然后点击引入实例变量。
我们需要像之前一样检查无效符号,因此我们让代码确定 romanSymbols 是否包含特定键,如果不包含就抛出异常。
运行测试用例,应该是绿色状态。
这是另一种代码问题,优化它是为了更好的设计和性能,让代码更干净。最好使用 HashMap 而不是 Hashtable,因为与后者相比前者的实现是不同步的。对这种方法大量调用会损害性能。
一个设计思路是始终使用通用接口作为目标类型,因为这能带来更容易维护,更干净的代码,且在不影响代码使用的情况下轻松调整实现细节。这个示例中我们将使用 Map。
如果你用的是 Java 9 或更高版本,则可以使用新的 HashMap<>()替换新的 HashMap <Character,Integer>(),因为菱形运算符可以与 Java 9 中的匿名内部类一起使用。
或者你可以使用更简单的 Map.of()。
java.util.Vector 和 java.util.Hashtable 已过时。虽然它们仍然受支持,但这些类已被 JDK 1.2 集合类淘汰,新的应用不应该继续用它们了。
重构之后我们需要检查一切是否正常,确认没有破坏任何东西。太棒了,是绿条!
第 12 步
这次找一些更有意思的数字做转换。我们回到我们的测试类,实现将罗马数字 VI 转换为 6。
方法:convertVI()
我们运行测试用例,正常通过。看来我们编写逻辑能自动覆盖这种情况。这样就不用单独写实现了。
第 13 步
现在我们需要将 IV 转换为 4,这次可能就没有 VI 转成 6 那么顺利了。
方法:convertIV()
运行测试用例,果然出来的是红条。
我们得设法让它跑通了。众所周知,在罗马数字中较小数字的字符(例如 I)如果附加到较大数字的字符(例如 V)前面,相当于用大数减去小数——所以 IV 等于 4,反过来 VI 等于 6。
我们现有的代码都是对数值求和运算的,但这一次我们需要做减法。我们应该做一个条件判定:如果前一个字符的值大于或等于后面字符的值,那么就求和,反之就做减法。
先专心编写满足问题需要的逻辑,同时不考虑变量的声明是很方便的做法。只要写好逻辑,然后创建实现所需的变量就行了。如前所述,这就是意图编程。这样一来,我们就能更快地写出最简洁的代码,不用事事都提前操心了——这才是最棒的理念。
我们目前的实现是:
为了检查罗马字符有效性,我们开始写新的逻辑。先编写一般性的逻辑,然后在 IDE 提示的帮助下创建一个局部变量。另外我们对变量的类型有一种感觉:它们要么是方法的本地变量,要么是实例/类变量。
现在我们需要将 for 循环更改为基于索引以访问当前和先前的变量,因此我们调整实现以满足新改动的要求,以便正常编译。
添加这个新功能后我们运行测试用例,绿条——很完美。
第 14 步
现在我们是绿色状态,该做重构了。我们将尝试做一个更有趣的重构。
我们的重构策略是尽量简化代码。观察发现 romanSymbols.get(roman.charAt(index))这行出现了两次。
那就把重复的代码提取到这里要使用的方法或类中,让将来的所有改动都集中在一起。
高亮代码,右键单击并选择 NetBeans 重构工具>introduce>方法。将其命名为 getSymbolValue 并保留为私有方法,然后点“确定”。
现在需要运行测试用例,看看这个小型重构有没有引入错误。结果代码没有出问题,我们发现它仍处于绿色状态。
第 15 步
我们将做更多重构。声明条件 romanSymbols.containsKey(roman.charAt(index))很难阅读,并且很难搞清它应该测试什么来传递 if 语句。那就简化代码,使其更具可读性。
虽然我们现在理解这行代码的作用,但我保证半年后我们就很难理解它要做什么了。
可读性是我们应该通过 TDD 不断提高的一项代码质量关键指标,因为在敏捷环境中我们经常要快速改动代码——为此代码必须有很好的可读性。另外任何改动都应该是可测试的。
现在把这行代码提取到一个方法中,该方法的名称为 doesSymbolsContainsRomanCharacter,名字就描述了它的作用。我们还是用 NetBeans 重构工具完成这项工作。
这样做可以改善代码的可读性。这里的条件是:如果符号包含罗马字符,则执行逻辑,否则抛出无效字符的非法参数异常。
我们再次重新运行所有测试,没出现新的错误。
请注意,不管引入多小的重构改动,都要跑一遍测试。我不会等做完所有的重构之后才运行所有测试用例。这在 TDD 中是非常重要的。我们需要即时反馈,运行测试用例就是我们的反馈循环。它让我们尽早找出每个小步骤的错误,而不是对着一大串步骤的出错信息发呆。
因为每个重构步骤都可能引入新的错误,所以代码改动和代码测试之间的间隔时间越短,我们就能越快地分析代码并修复新错误。
如果我们重构了一百行代码然后运行测试用例结果不成功,就必须花时间调试以准确检测出问题所在。在一百行代码中找错误要比五行或十行代码困难得多。
第 16 步
我们在引入的两个私有方法和异常消息中有重复的代码,重复的这行是 roman.charAt(index),因此我们使用 NetBeans 将其重构为一个新方法,名为 getCharValue(String roman, int index)。
重新运行所有测试,全部绿条。
第 17 步
现在做一些重构来优化代码并提高性能。我们可以简化转换方法的计算逻辑。目前的逻辑是:
改进下面这一行可以节省几个多余的 CPU 周期:
不必获取前一个字符,因为前一个字符只是 for 循环结束时的当前字符。可以删除这一行并将其替换为 previous = current;,放在 else 条件末尾的计算后面。
运行测试用例,结果应该是绿色。
现在我们简化计算以节省另外几个多余的计算周期。我会还原 if 语句测试用例的计算,并反转 for 循环。最终的代码应该是:
运行测试用例,应该还是绿色。
由于该方法不会更改任何对象状态,因此可以将其设置为静态方法。此外该类是一个工具类,因此应该关闭它以便继承。虽然所有方法都是静态的,但我们不应该允许实例化类。添加一个私有默认构造函数来修复此问题,并将该类标记为 final。
现在我们会在测试类中出现编译错误。一旦解决了这个问题,运行测试用例应该会再次全绿。
第 18 步
最后一步是添加更多测试用例,以确保我们的代码涵盖所有需求。
添加一个 convertX()测试用例,它应该返回 10,因为罗马数字 X=10。这时运行测试会失败并返回 IllegalArgumentException,所以将 X=10 添加到符号映射里。再次运行测试就通过了。这里没有重构内容。
添加 convertIX()测试用例,它应该返回 9,因为 IX=9。测试应该会通过。
在符号映射中加入这些值:L = 50,C = 100,D = 500,M = 1000。
添加一个 convertXXXVI()测试用例,它应该返回 36,因为 XXXVI=36。运行测试会正常通过。这里没有重构。
添加一个 convertMMXII()测试用例,它应该返回 2012。运行测试将通过。这里没有重构。
添加 convertMCMXCVI()测试用例,它应该返回 1996。运行测试将通过。这里没有重构。
添加一个 convertInvalidRomanValue()测试用例,它应该抛出 IllegalArgumentException。运行测试将通过。这里没有重构。
添加一个 convertVII()测试用例,它应该返回 7,因为 VII=7。但我们用小写的 vii 测试时,测试将失败并抛出 IllegalArgumentException,因为我们只处理了大写字母。为了解决这个问题,我们在方法开头添加一行 roman = roman.toUpperCase();。再次运行测试用例将通过。这里没有重构。
到这一步我们已经完成了任务(实现)。基于 TDD 的理念,我们用最少的代码改动通过了所有测试用例,并通过重构满足了所有需求,从而确保我们具有出色的代码质量(性能、可读性和设计)。
我希望大家像我一样享受这个过程,希望这能鼓励你在下一个项目甚至现在做的任务中开始使用 TDD。喜欢本文的话请点击分享、喜欢,并在 GitHub 中点星来帮我传播吧。
作者介绍
Mohamed Taman 是 Comtrade 数字服务公司的高级企业架构师、Java 冠军、甲骨文开拓大使。他是 JCP 成员,曾是 JCP 执行委员会成员,JSR 354、363、373 专家组成员、EGJUG 领导者、甲骨文埃及建筑师俱乐部董事会成员。他主讲 Java,热爱移动、大数据、云、区块链、DevOps。他是国际演讲者,书籍和视频“JavaFX essentials”“清洁代码入门——Java SE 9”“动手实践 Java 10 编程与 JShell”的作者,还出了一本新书“Java 冠军的秘密”。他赢得 2014、2015 年杜克选择奖项和 JCP 杰出参与者 2013 年奖项。
查看英文原文:Test-Driven Development: Really, It’s a Design Technique
评论 2 条评论