软件工程/测试/测试驱动开发简介
测试驱动开发 (TDD) 是一种软件开发流程,它依赖于一个非常短的开发周期的重复:首先开发人员编写一个失败的自动化测试用例,该用例定义了所需的改进或新功能,然后生成代码以通过该测试,最后将新代码重构到可接受的标准。肯特·贝克 (Kent Beck) 因开发或“重新发现”该技术而受到赞誉,他在 2003 年表示,TDD 鼓励简单的设计并增强信心。[1]
测试驱动开发与 1999 年开始的极限编程的测试优先编程概念相关。[2] 但最近它本身也引起了更广泛的兴趣。[3]
程序员还将此概念应用于改进和调试使用旧技术开发的遗留代码。[4]
测试驱动开发要求开发人员在编写代码本身之前(立即)创建定义代码需求的自动化单元测试。测试包含真或假的断言。当开发人员改进和重构代码时,通过测试确认正确的行为。开发人员通常使用测试框架(例如 xUnit)来创建和自动运行一组测试用例。
以下序列基于书籍《测试驱动开发的实践》。[1]
在测试驱动开发中,每个新功能都从编写测试开始。此测试必然会失败,因为它是在实现该功能之前编写的。(如果它没有失败,那么要么提议的“新”功能已经存在,要么测试有缺陷。)为了编写测试,开发人员必须清楚地了解该功能的规范和需求。开发人员可以通过使用用例和用户故事来完成此操作,这些用例和用户故事涵盖了需求和异常情况。这也可能意味着现有测试的变体或修改。这是测试驱动开发与在编写代码之后编写单元测试的区别特征:它使开发人员在编写代码之前专注于需求,这是一个细微但重要的区别。
这验证了测试工具是否正常工作,并且新测试不会在不需要任何新代码的情况下错误地通过。此步骤还会对测试本身进行负面测试:它排除了新测试始终通过的可能性,因此毫无价值。新测试还应因预期原因而失败。这增加了信心(尽管它并不能完全保证)它正在测试正确的事物,并且仅在预期的情况下才会通过。
下一步是编写一些代码,使测试通过。在此阶段编写的代码不会是完美的,例如,它可能以一种不优雅的方式通过测试。这是可以接受的,因为后续步骤将对其进行改进和优化。
重要的是,编写的代码仅用于通过测试;在任何阶段都不应预测和“允许”任何进一步的(因此未经测试的)功能。
如果所有测试用例现在都通过,则程序员可以确信代码满足所有经过测试的需求。这是一个开始周期最后一步的好起点。
现在可以根据需要清理代码。通过重新运行测试用例,开发人员可以确信代码重构不会损坏任何现有功能。消除重复的概念是任何软件设计的重点。但是,在这种情况下,它也适用于消除测试代码和生产代码之间的任何重复 - 例如在两者中重复的幻数或字符串,以便在步骤 3 中使测试通过。
从另一个新的测试开始,然后重复该循环以推动功能向前发展。步骤的大小应始终很小,每次测试运行之间只有 1 到 10 次编辑。如果新代码没有快速满足新测试,或者其他测试意外失败,则程序员应优先选择撤消或回退,而不是进行过度调试。持续集成通过提供可回退的检查点来提供帮助。在使用外部库时,重要的是不要进行过小的增量,以至于实际上仅仅是在测试库本身,[3] 除非有理由相信库存在错误或功能不完整,无法满足正在编写的程序的所有需求。
测试驱动开发包含多个方面,例如“保持简单,傻瓜”(KISS)和“你不需要它”(YAGNI)等原则。通过专注于编写仅通过测试所需的代码,设计可以比其他方法更简洁明了。[1] 在肯特·贝克的《测试驱动开发实践》一书中,他还提出了“先假装,后实现”的原则。
为了实现某些高级设计理念(例如设计模式),会编写生成该设计的测试。代码可能仍然比目标模式简单,但仍然可以通过所有必需的测试。这最初可能会让人感到不安,但它允许开发人员只关注重要的内容。
**先写测试**。测试应该在被测试的功能之前编写。据说这样做有两个好处。它有助于确保应用程序的可测试性,因为开发人员必须从一开始就考虑如何测试应用程序,而不是以后再担心。它还确保为每个功能编写测试。当编写功能优先的代码时,开发人员和开发组织往往会将开发人员推向下一个功能,完全忽略测试。
**先让测试用例失败**。这样做的目的是确保测试确实有效,并且可以捕获错误。一旦证明这一点,就可以实现底层功能。这被称为“测试驱动开发箴言”,也称为红/绿/重构,其中红色表示*失败*,绿色表示*通过*。
测试驱动开发不断重复添加失败的测试用例、使其通过以及重构的步骤。在每个阶段获得预期的测试结果会强化程序员对代码的心理模型,增强信心并提高生产力。
测试驱动开发的高级实践可以导致验收测试驱动开发 (ATDD),其中客户指定的标准被自动化为验收测试,然后驱动传统的单元测试驱动开发 (UTDD) 过程。[5] 这个过程确保客户拥有一个自动化的机制来决定软件是否满足他们的需求。通过 ATDD,开发团队现在有一个特定的目标需要满足,即验收测试,这使他们持续专注于客户真正想要从用户故事中获得什么。
2005 年的一项研究发现,使用 TDD 意味着编写更多测试,而反过来,编写更多测试的程序员往往生产力更高。[6] 与代码质量和 TDD 与生产力之间更直接的相关性相关的假设尚无定论。[7]
在新的(“绿地”)项目上使用纯 TDD 的程序员报告说,他们很少需要调用调试器。与版本控制系统结合使用时,当测试意外失败时,将代码恢复到通过所有测试的最后一个版本通常比调试更有成效。[8]
测试驱动开发提供的不仅仅是简单的正确性验证,还可以驱动程序的设计。通过首先关注测试用例,必须想象客户端将如何使用该功能(在第一种情况下,是测试用例)。因此,程序员在实现之前关注的是接口。这种优势与契约式设计相辅相成,因为它通过测试用例而不是数学断言或先入为主的概念来处理代码。
测试驱动开发提供了在需要时采取小步骤的能力。它允许程序员专注于手头的任务,因为第一个目标是使测试通过。异常情况和错误处理最初不会被考虑,并且创建这些额外情况的测试会单独实现。测试驱动开发以这种方式确保所有编写的代码都至少由一个测试覆盖。这使编程团队和后续用户对代码更有信心。
虽然 TDD 比不使用 TDD 需要更多代码(因为有单元测试代码),但总的代码实现时间通常更短。[9] 大量测试有助于限制代码中的缺陷数量。测试的早期和频繁性质有助于在开发周期的早期捕获缺陷,防止它们成为流行且代价高昂的问题。在流程早期消除缺陷通常可以避免在项目后期进行冗长而乏味的调试。
TDD 可以导致更模块化、更灵活和更可扩展的代码。这种效果通常是因为该方法要求开发人员以可以独立编写和测试并稍后集成在一起的小单元的形式来考虑软件。这导致更小、更集中的类、更松散的耦合和更简洁的接口。模拟对象设计模式的使用也有助于代码的整体模块化,因为这种模式要求编写代码,以便可以在单元测试的模拟版本和部署的“真实”版本之间轻松切换模块。
因为不会编写超过通过失败测试用例所需的代码,所以自动化测试倾向于覆盖每个代码路径。例如,为了让 TDD 开发人员向现有的 if
语句添加 else
分支,开发人员首先必须编写一个导致该分支失败的测试用例。因此,来自 TDD 的自动化测试往往非常彻底:它们会检测代码行为中的任何意外更改。这可以检测到在开发周期的后期更改意外更改其他功能时可能出现的问题。
- 在需要完整的功能测试来确定成功或失败的情况下,测试驱动开发很难使用。这些示例包括用户界面、与数据库一起工作的程序以及某些依赖于特定网络配置的程序。TDD 鼓励开发人员将最少的代码放入此类模块中,并最大化可测试库代码中的逻辑,使用模拟和模拟来表示外部世界。
- 管理支持至关重要。如果没有整个组织相信测试驱动开发将改进产品,管理层可能会认为花费在编写测试上的时间是被浪费的。[10]
- 在测试驱动开发环境中创建的单元测试通常由也将编写被测试代码的开发人员创建。因此,测试可能与代码共享相同的盲点:例如,如果开发人员没有意识到必须检查某些输入参数,那么测试和代码很可能都不会验证这些输入参数。如果开发人员误解了正在开发的模块的需求规范,那么测试和代码都将是错误的。
- 大量的通过单元测试可能会带来一种虚假的安全感,导致更少的其他软件测试活动,例如集成测试和合规性测试。
- 测试本身成为项目维护开销的一部分。编写不当的测试,例如包含硬编码错误字符串或本身容易出错的测试,维护成本很高。存在定期产生错误失败的测试会被忽略的风险,因此当出现真实失败时,它可能不会被检测到。可以编写易于维护的测试,例如通过重用错误字符串,这应该是在上面描述的代码重构阶段的目标。
- 在重复的 TDD 周期中实现的覆盖率和测试细节水平无法轻松地在以后重新创建。因此,随着时间的推移,这些原始测试变得越来越宝贵。如果糟糕的架构、糟糕的设计或糟糕的测试策略导致后期更改导致数十个现有测试失败,则必须单独修复它们。仅仅删除、禁用或草率地更改它们会导致测试覆盖率中无法检测到的漏洞。
测试套件代码显然必须能够访问它正在测试的代码。另一方面,不应损害信息隐藏、封装和关注点分离等正常的设计标准。因此,TDD 的单元测试代码通常与被测试的代码写在同一个项目或模块中。
在面向对象的设计中,这仍然无法访问 private
数据和方法。因此,单元测试可能需要额外的工作。在 Java 和其他语言中,开发人员可以使用反射来访问标记为 private
的字段。[11] 或者,可以使用内部类来保存单元测试,以便它们可以查看封闭类的成员和属性。在 .NET Framework 和一些其他编程语言中,可以使用部分类来公开供测试访问的私有方法和数据。
重要的是,此类测试技巧不应该保留在生产代码中。在 C 和其他语言中,编译器指令(例如`#if DEBUG ... #endif`)可以放置在这些附加类以及所有其他与测试相关的代码周围,以防止它们被编译到发布的代码中。这意味着发布的代码与单元测试的代码不完全相同。然后,定期运行数量更少但更全面、端到端的集成测试,可以确保(除其他事项外)不存在任何以测试框架的某些方面为基础的生产代码。
关于是否明智地测试私有和受保护的方法和数据,TDD 实践者在其博客和其他著作中存在一些争论。一些人认为,通过其公共接口测试任何类就足够了,因为私有成员仅仅是实现细节,可能会发生变化,并且应该允许这样做,而不会破坏大量的测试。另一些人则认为,关键的功能方面可能在私有方法中实现,并且仅通过公共接口间接地开发和测试它会模糊问题:单元测试是关于测试尽可能小的功能单元。[12][13]
单元测试之所以这样命名,是因为它们每个都测试代码的一个单元。一个复杂的模块可能有 1000 个单元测试,而一个简单的模块可能只有 10 个。用于 TDD 的测试永远不应该跨越程序中的进程边界,更不用说网络连接了。这样做会引入延迟,导致测试运行缓慢,并阻止开发人员运行整个套件。引入对外部模块或数据的依赖也会将单元测试变成集成测试。如果一个模块在一系列相互关联的模块中行为异常,则不清楚在哪里查找故障原因。
当正在开发的代码依赖于数据库、Web 服务或任何其他外部进程或服务时,强制执行可单元测试的分离也是一个机会,也是设计更模块化、更可测试和更可重用代码的驱动力。[14] 需要两个步骤
- 无论何时最终设计中需要外部访问,都应该定义一个接口来描述可用的访问权限。有关执行此操作的好处(无论是否使用 TDD),请参阅依赖倒置原则。
- 该接口应以两种方式实现,一种真正访问外部进程,另一种是模拟对象或桩对象。模拟对象只需要将消息(例如“Person 对象已保存”)添加到跟踪日志中,测试断言可以针对该日志运行以验证正确行为。桩对象的不同之处在于,它们本身包含测试断言,这些断言可以使测试失败,例如,如果人员的姓名和其他数据与预期不符。返回数据的模拟对象和桩对象方法(表面上来自数据存储或用户)可以通过始终返回测试可以依赖的相同、真实的数据来帮助测试过程。它们还可以设置为预定义的故障模式,以便可以开发和可靠地测试错误处理例程。除数据存储之外的其他模拟服务在 TDD 中也可能很有用:模拟加密服务实际上可能不会加密传递的数据;模拟随机数服务可能始终返回 1。模拟或桩实现是依赖注入的示例。
这种依赖注入的推论是,TDD 过程本身永远不会测试实际的数据库或其他外部访问代码。为了避免由此产生的错误,需要其他测试,这些测试使用上述接口的“真实”实现实例化测试驱动的代码。这些测试与 TDD 单元测试完全分开,实际上是集成测试。它们的數量会更少,并且需要比单元测试运行得更少。尽管如此,它们可以使用相同的测试框架(例如 xUnit)来实现。
更改任何持久性存储或数据库的集成测试应始终谨慎设计,并考虑文件或数据库的初始状态和最终状态,即使任何测试失败也是如此。这通常通过以下技术的某种组合来实现
- `TearDown` 方法,它是许多测试框架不可或缺的一部分。
- 在可用时使用`try...catch...finally` 异常处理结构。
- 数据库事务,其中事务以原子方式包含写入、读取和匹配删除操作。
- 在运行任何测试之前获取数据库的“快照”,并在每次测试运行后回滚到该快照。这可以使用 Ant 或 NAnt 等框架或 CruiseControl 等持续集成系统来自动化。
- 在测试之前将数据库初始化为干净状态,而不是在测试之后清理。这可能与清理可能会使诊断测试失败变得困难相关,因为在执行详细诊断之前删除了数据库的最终状态。
Moq、jMock、NMock、EasyMock、Typemock、jMockit、Unitils、Mockito、Mockachino、PowerMock 或 Rhino Mocks 等框架的存在使得创建和使用复杂的模拟对象的过程变得更容易。
- ↑ a b c Beck, K. 示例驱动的测试开发,Addison Wesley,2003
- ↑ Lee Copeland(2001 年 12 月)。"极限编程"。Computerworld. 检索于 2011 年 1 月 11 日.
- ↑ a b Newkirk,JW 和 Vorontsov,AA。Microsoft .NET 中的测试驱动开发,Microsoft Press,2004 年。
- ↑ Feathers,M。有效使用遗留代码,Prentice Hall,2004 年
- ↑ Koskela,L。“测试驱动:Java 开发人员的 TDD 和验收 TDD”,Manning 出版物,2007 年
- ↑ Erdogmus,Hakan。“关于测试优先编程方法的有效性”。IEEE 软件工程学报论文集,31(1)。2005 年 1 月。(NRC 47445). 检索于 2008 年 1 月 14 日。
我们发现,测试优先的学生平均编写了更多测试,而编写更多测试的学生往往生产力更高。
{{cite web}}
: 未知参数|coauthors=
被忽略(建议使用|author=
)(帮助) - ↑ Proffitt,Jacob。“TDD 证明有效!还是?”. 检索于 2008 年 2 月 21 日。
因此,TDD 与质量的关系充其量是有问题的。它与生产力的关系更有趣。我希望有后续研究,因为生产力数据对我来说根本加不起来。生产力和测试数量之间存在不可否认的相关性,但这种相关性在非 TDD 组中实际上更强(该组有一个异常值,而 TDD 组大约有一半在 95% 范围内之外)。
- ↑ Llopis, Noel (2005年2月20日). "透过镜子的世界:测试驱动游戏开发(第一部分)". Games from Within. 检索日期 2007-11-01.
将[TDD]与非测试驱动开发方法进行比较,您将用代码替换所有心理检查和调试器步骤,以验证您的程序是否完全按照您的预期执行。
- ↑ Müller, Matthias M. "关于测试驱动开发的投资回报率" (PDF). 卡尔斯鲁厄大学,德国. 第 6页. 检索日期 2007-11-01.
{{cite web}}
: 未知参数|coauthors=
被忽略(建议使用|author=
)(帮助) - ↑ Loughran, Steve (2006年11月6日). "测试" (PDF). HP实验室. 检索日期 2009-08-12.
- ↑ Burton, Ross (2003年11月12日). "颠覆Java访问保护以进行单元测试". O'Reilly Media, Inc. 检索日期 2009-08-12.
{{cite web}}
: 检查日期值:|date=
(帮助) - ↑ Newkirk, James (2004年6月7日). "测试私有方法/成员变量 - 您应该还是不应该". 微软公司. 检索日期 2009-08-12.
- ↑ Stall, Tim (2005年3月1日). "如何在.NET中测试私有和受保护的方法". CodeProject. 检索日期 2009-08-12.
- ↑ Fowler, Martin (1999). 重构 - 改善既有代码的设计. 波士顿:Addison Wesley Longman, Inc. ISBN 0-201-48567-2.
- [1] 在WikiWikiWeb上
- 测试还是规范?测试和规范?从规范中测试!,作者:Bertrand Meyer(2004年9月)
- 从TDD方法进行Microsoft Visual Studio Team Test
- 编写可维护的单元测试,为您节省时间和泪水
- 使用测试驱动开发(TDD)提高应用程序质量