历史背景
测试驱动开发(Test-Driven Development,TDD)是在敏捷开发模型兴起的背景下出现的一种软件开发方法。这两者都是从 1950 年代开始使用的迭代、增量和进化模型衍生而来的。随着时间的推移,支持 TDD 的一些软件开发工具也得到了不断发展和完善,反过来成为推动该方法实践的重要组成部分。
关于测试的研究通常先假设存在一个要测试的程序,这决定了只能使用在最后进行测试的方法。然而,从 19 世纪 80 年代已经出现将测试从编码的结尾移到开头的实践。软件和测试团队通常在软件开发过程的早期按照程序逻辑开发测试。近四十年前,生命周期模型在软件开发过程的早期集成了测试。1980 年代推出的 净室软件工程方法就包括了在开发过程的早期对设计元素进行形式验证。
在引入 XP 之前,很少有人写过让小型增量自动化单元测试来驱动软件开发和设计过程的概念。虽然没有人正式发表相关的概念,但许多开发人员可能已经非正式地使用了测试优先的方法。1999 年 Kent Beck 发表了《解析极限编程》一书,其中将测试驱动开发作为了极限编程的核心实践之一,正式给出了相关的概念。许多开发人员可能一直以测试优先的方式思考和编码,但 TDD 强调以一种极端的方式做到这一点:总是在编写代码之前编写测试,使测试尽可能地小,并且从不让代码降级。TDD 适用于软件开发模型,增量、迭代和演化过程模型的开发对其出现至关重要。
迭代开发涉及到在一组不断扩展的需求上重复一组开发任务,在开发之初缺乏完整的规范,因此允许来自先前迭代和客户的反馈来指导未来的迭代,逐步得到一个完善的解决方案。增量式开发基于一个古老的原则:先构建部分,在构建整体。将产品分解为更小的特性,通过构建一部分再进行调整以后构建更多的特性。
测试驱动开发就是在迭代和增量开发模型的基础上发展而来的。TDD 之所以有效,是因为这些模型提供了先决条件。TDD 要求将设计决策延后并且能够灵活地改变软件设计,每个新的测试都可能需要重构和改变软件设计,迭代和增量开发模型正好符合这种开发模式。
2014 年出现了一场测试驱动开发之争,David Heinemeier Hansson,Ruby on Rails 的作者认为 TDD 已死,而 Kent Beck 持有反对的观点。从此以后业界关于测试驱动开发的观念也分成了两派,一方认为 TDD 给开发人员带来了开发压力,另一方认为 TDD 就像设计模式一样将来可能会变化但是永远不会死亡。
软件开发工具已成为现代软件系统发展的重要因素。从编译器、调试器和集成开发环境到建模和计算机辅助软件工程工具,各种工具都改进了开发人员的工作效率。软件开发工具在 TDD 的出现中也发挥了重要作用。TDD 对测试的覆盖率以及质量有很高的要求,对于大多数人来说,在编写功能相关代码之前先编写相应的单元测试代码具有一定难度。自动化单元测试框架的出现简化了软件单元测试的创建和执行,一定程度上促进了 TDD 的出现和发展。
1998 年 Kent Beck 开发了 JUnit,这是一个用于 Java 的自动化单元测试框架,JUnit 对于使用 Java 实现 TDD 至关重要,甚至可以说是 TDD 和 XP 广泛流行的主要原因。人们使用其它不同的语言实现了类似 Junit 的框架,创建了一个称为 xUnit 的框架系列。
测试驱动开发的过程
第一步是快速编写一个测试,第二步是运行测试,当测试失败时进行到第三步,更新功能代码,然后返回第二步重新运行测试,使其能完全通过之前的测试,如果失败需要继续修改代码,第四步,当测试全部通过时考虑重构整个代码结构,在不影响功能需求的情况下优化代码设计,提高代码的质量。重复该循环,不断的添加功能,直到整个项目的完成。
对于驱动测试开发的误解
开发者对于测试驱动开发往往存在着诸多误解并且在执行过程中容易出现错误:
- 一些人在最开始编写的测试就出现了错误,直接编写了整个项目所有的测试,或者为了减少工作量一次添加几个功能需求的测试代码,然后添加完成这些测试的功能代码。这样其实忽略了 TDD 的主要目的是设计而不是测试,测试驱动开发使用了迭代、增量的开发模型就是为了在迭代的过程中将每一个功能作为增量添加到系统上,因此一次添加多个功能可能反而导致出现更多不易修复的 bug,通过小步骤的增量,我们找到并修复其中缺陷的难度比在大段代码中容易得多。在 TDD 之前要合理的拆分任务,按照合适的粒度把一个大需求拆成多个小需求。当遇到 TDD 的问题,我们应该明白从来都不是 TDD 的问题,而是设计的问题,是测试目标太大的问题。
- 开发者在编写功能实现代码时无法专注于当前需求,往往把其它需求也一并实现了,破坏了迭代增量的过程。我们应该注意测试驱动开发中强调每次编写功能代码的标准是恰好实现当前需求,能够通过当前测试,这是 TDD 过程中需要遵守的三项原则之一。
- 忽略了重构的重要性甚至跳过重构,这是 TDD 失败的常见方式。Kent Beck 在他的著作《Test-Driven Development》一书中提到:“代码简洁可用这句言简意赅的话,正是 TDD 所追求的目标”。由此可见测试驱动开发有两个追求的目标,可用和代码简洁。而重构代码以保持其整洁是保证代码简洁的关键过程。
单元测试就是 TDD,这是大多数人对于 TDD 的理解。单元测试是 TDD 的基础,但单元测试并不等同于 TDD。单元测试是一种测试方法,而 TDD 是一种软件开发设计方法。
另一方面测试驱动开发中的测试并不只是指单元测试,而是指软件测试本身,可以是基于代码单元的单元测试,可以是基于业务需求的功能测试,也可以是基于特定验收条件的验收测试。通过测试帮助开发人员理解软件的结构、功能需求以及验收条件,帮助设计代码。尽管我们将其称作“测试驱动开发”,但不能将其视作一种测试技术,而应该将其看作测试驱动设计。TDD 包含两部分,ATDD 和 UTDD。- ATDD(Acceptance Test Driven Development):验收测试驱动开发,首先业务分析师或者测试工程师编写验收测试用例,然后开发工程师通过验收测试来理解需求和验收条件,并编写实现代码直到验收测试用例通过。
- UTDD(Unit Test Driven Development):单元测试驱动开发,首先开发工程师编写单元测试用例,然后编写实现代码直到单元测试通过。这是大部分人理解的 TDD,但是这只是 TDD 中的一部分。
- 盲目的追求 100% 代码覆盖率。理论上单元测试应该覆盖所有代码和所有的边界条件,但在实际中我们还需要考虑投入产出比。我们应该明白不能简单地将代码覆盖率和测试质量划等号,即使 100% 的覆盖率也不一定涵盖了实际场景中的所有情况。如果我们直接将一定程度的覆盖率作为目标,开发者就会为了满足覆盖率而忽略了其它方面的要求,例如功能的实现、代码结构等等。太高的覆盖率很容易通过低质量的测试代码达成。一个显而易见的例子是如果我们将测试代码中的所有断言都删除,代码覆盖率并不会有变化,因为它只统计了测试中执行了哪些代码。
- 一些开发者认为测试代码会成为后续维护的负担。这种观点主要原因在于没有将测试驱动开发用于软件过程的整个流程,不仅应该在软件开发的时候使用 TDD,在软件维护的时候也应该遵循 TDD 流程,先修改测试代码使其适配变更后的需求,再在测试失败的时候修改代码使测试能够通过,这样我们就不是单独的维护测试用例,而是利用测试用例来推动软件开发。
- 将测试驱动开发和自测试代码看作一个概念。测试驱动开发是一种软件开发设计或者实践,它的好处包括了一项是生成了自测试代码。自测试代码指的是代码能够针对自身运行一系列自动化测试,它是持续集成的支柱,同时也是持续交付的必要组成部分。自测试代码的一个好处是它可以大大减少软件中的错误数量,同时让开发者更加有信心对系统进行更改。对于旧的项目代码,开发者往往害怕对其修改,毕竟“代码能跑就不要动”,在代码中随意一个小的改动都有可能导致难以修复的 bug,在这种情况下,不仅对软件添加功能变得困难,对软件的重构也变得不可能,项目代码逐渐积累了巨大的技术债务。有了自测试代码,开发者能够更加轻松地处理代码中的小问题,每次代码出现问题都能及时通过测试找出位置并进行修复。自测试代码并没有强调编写测试代码在整个开发流程中的位置,开发者也可以在编写功能代码之后生成测试代码,重要的是进行了测试而不是如何进行测试,这是测试驱动开发与自测试代码最大的不同。
- 盲目地使用测试驱动开发。我们需要认识到不能认定测试能够保证质量。开发,代码审查,构建,部署,测试,发布,上线,每一个环节中都会进行质量反馈,如果能够通过更加经济有效的方法来提升质量,那么一些测试是可以忽略的。要重视测试但不能矫枉过正,过度依赖测试。正如前面代码覆盖率部分所说,一些测试通过并不代表着代码中就不存在问题。要对测试带来的效益和消耗的经济、时间等成本进行辩证地衡量,例如如果是需要快速交付不需要长期维护的项目,可以考虑手工测试。
测试驱动开发在开发工具中的应用
集成开发环境:大多数 IDE 都对测试功能提供了支持,允许开发者通过简易的方式创建、运行和调试测试用例。IDE中通过成熟的UI框架和测试运行器提供了测试结果的可视化反馈。
单元测试框架:在 Kent Beck 开发的 JUnit 框架基础上,开发者们针对不同语言开发了其它单元测试框架,形成了 xUnit 框架,如 JUnit、pytest、JUnit 等。这些框架提供了一种组织和运行测试用例的标准方式,同时能够生成详细的测试报告
持续集成工具:TDD 中的重要一点是自动化测试,在持续集成环境中,TDD 可以自动化地运行测试套件,并根据测试结果决定是否继续构建和部署。常见的 Jenkins、Travis CI、GitLab CI 等工具都能够与 TDD 集成,每次代码更改都会触发其中的测试代码保证功能能够正确运行。
版本控制系统:版本控制系统例如 Git 也支持 TDD 的实践。开发者在代码中编写测试用例,将代码提交到代码库以后通过与自动化测试框架集成能够确保新功能的引入或修改不会破坏现有功能。版本控制系统也可以帮助开发者追踪测试用例的变化历史。
代码覆盖工具:使用代码覆盖工具(如 JaCoCo、Coverage 等)用于评估测试用例对代码的覆盖程度。在 TDD 中这些工具可以反映测试用例是否覆盖了代码的不同路径和分支,提高测试的全面性
虚拟化和容器化工具:虚拟化和容器化工具例如 Docker 使得 TDD 在不同环境中的应用更为方便。容器保证了提供相同的测试环境,避免了环境差异导致的测试效果的不同,便利了随时随地的测试。
测试驱动开发的优点
- 更好的代码设计,降低开发者负担测试驱动开发通过将项目的功能需求分解,在迭代中将每个功能点作为增量添加到代码中,鼓励开发者编写可测试、模块化的代码,通过在编写代码之前定义接口和规范以及明确的流程,让开发者一次只关注项目中的一小部分,开发难度更低。
- 满足项目的扩展需求、提高代码的可维护性
TDD 的好处是测试代码覆盖率高,对代码提供了一个保证,当需求发生变更需要对代码更改时,开发者能够更加轻松地测试新代码不会对旧的功能造成影响。 - 提前明确项目需求
先编写测试代码能够帮助开发者清晰需求细节,而不是代码写到一半才发现不明确的需求。 - 支持重构
TDD 通过先编写测试代码保证了开发者在重构时代码功能不会发生变化。 - 更好的文档
测试用例本身能够作为代码的文档,描述了代码的预期行为,有利于开发者理解代码。
对测试驱动开发效果的评估
在现实中对 TDD 的影响进行实践具有一定的难度,因为代码的质量和开发时间等指标受到开发者能力、开发工具的实用性、经济等多种因素的影响,难以对这些变量进行定量分析。现有的调查 TDD 有效性的研究都未能产生结论性的结果,甚至一些研究中包含有相反的观点,但是从现有的研究还是能看出测试驱动开发的部分影响。
在对之前研究的汇总基础上,能够看出使用 TDD 在代码质量上有小幅的提高,但对生产力的影响尚不明确,在一些情况下生产力提高,一些情况下反而会降低。
在学术项目和工业项目中应用 TDD,在工业项目实践中发现了更大的代码质量提升,这可能归因于工业项目中的开发者开发经验更加丰富同时工业项目体量更大表现出来的质量提升也更明显。
在学术项目和工业项目中应用 TDD,在工业项目实践中生产率下降幅度较大,这可以通过部分原因合理解释:工业项目中经验丰富的开发者更多,而这些开发者更可能重视 TDD 并将更多的时间投入到与 TDD 相关的开发流程例如编写测试以及重构代码。通过研究也发现项目开发时间相比原来延长的程度与项目的规模成正比,越大的项目应用 TDD 以后越可能延长开发时间。
通过对之前研究的分析我们注意到之前研究的一些缺陷之处。首先应该对被调查的开发者进行充分的培训,保证他们能够正确熟练地掌握测试驱动开发技术。对于工业领域的研究应该尝试记录应用 TDD 后的长期影响,而不是只是调查应用以后的初步开发阶段。研究者更应该保证整个开发流程中高度的一致性,开发流程一旦确定开发者应该严格遵守。开发者应该尝试以标准化的方式量化研究中的指标例如开发任务复杂度,便于不同研究之间数据的比较分析。
测试驱动开发的未来趋势
增强自动测试和持续集成:随着自动测试工具和持续集成的不断发展,TDD 可以更加紧密地集成到整个软件开发生命周期中。自动测试和持续集成工具将变得更加智能、高效,更好地支持驱动测试开发。
更多层次的测试集成:除了单元测试、集成测试,端到端测试、验证等不同层次的测试将得到更广泛的应用,TDD 将在这些层次的测试中得到实践,从而更全面地确保系统质量。
更强大的测试工具和框架:随着技术的发展,测试工具和框架将变得更加强大和灵活,以适应不断变化的开发需求。
人工智能和机器学习的应用:人工智能和机器学习技术将会应用于测试领域,在自动化测试框架中协助自动生成测试用例、分析测试结果和检测潜在缺陷。这将加快测试过程,提高测试效率。
更加重视安全测试:随着对软件安全的关注不断增加,TDD 将更加重视安全测试。开发者可以考虑在早期先编写充分的安全测试代码,保证之后功能代码不会出现安全问题。
应用于更广泛的行业:除传统软件开发外,TDD 可能会更广泛地应用于更多行业和项目例如嵌入式系统、物联网和人工智能等领域。
敏捷和DevOps的推动:敏捷开发和DevOps实践的推广将进一步鼓励TDD的采用。