第一步:开始TDD,第二步:???,第三步:$利润$






4.90/5 (9投票s)
利用市场延迟成本和学习成本来衡量TDD或其替代方案是否适合你
学习测试驱动开发(TDD)的难度不容小觑。这很大程度上解释了TDD争论为何如此两极分化。真正经验丰富的TDD实践者早已掌握了这些技术,并认为它们显而易见。而经验不足的开发者则被需要学习的大量知识所压倒。许多不明智的勇敢尝试TDD风格技术的人,最终感到心灰意冷。他们喝着闷酒,将这种技术戏称为“乏味驱动开发”(Tedium Driven Development)。然而,一个坚固的测试线束可以强制代码清晰地分离关注点,持续反馈代码是否按预期工作,并生成更易于维护的代码。嗯?
一方面,自动化测试可能会使代码的修改变得非常困难。无论你是添加新功能还是修改现有功能,情况都是如此。例如,如果你更改了一个非常流行类或接口的方法签名,就会在整个代码库中产生连锁反应。另一方面,如果你的代码过于灵活,就会有大量的额外代码,只是为了提供你大部分都不会使用的“更大灵活性”。无论哪种情况,了解软件用户将如何与它交互都很有帮助。
此外,即使是才华横溢的开发人员也可能遇到严重问题。正确实施的TDD,即带有自动化验收测试的TDD,需要扎实的非技术领域知识。你需要知道哪些测试是明智的。通常,这些需求是非技术性的。如果你不理解“业务”,你就有可能给自己套上一个绞索,最终自食其果。所以,TDD隐式要求与领域专家密切合作,而这些专家可能不可用或不感兴趣。当你获得与他们接触的机会时,你肯定不希望在概念上苦苦挣扎。
从商业角度来看,TDD存在问题,因为它可能会花费时间。例如,对于一家初创公司来说,发布延迟会带来非常真实的机会成本。如果你的竞争对手抢先进入市场,你可能会减少销售额。即使他们有更多错误,他们也已经开始赚钱了。在大多数市场中,根据里斯和特劳特的《22条营销法则》一书,用户的头脑中只有三个参与者的空间。不查看商店,你能想出多少个牙膏品牌?更重要的是,不通过谷歌或维基百科,你知道第二位横渡大西洋的人是谁吗?(提示:不是查尔斯·林德伯格或阿梅利亚·埃尔哈特)。至少在你的利基市场内,你面临着人类感知和记忆的极限。
除了延迟成本之外,还有显而易见的与拥有和安置开发团队相关的成本:开发人员、测试人员、计算机、办公空间。即使一切都是虚拟的,仍然存在实际成本。一旦你清楚地了解时间的价值,你就可以评估你对TDD的方法。
从零开始
一方面,你需要学习相关的模式和技术,然后投入时间来应用它们。从零开始的角度来看,你需要对许多技术和技术有功能性的理解,才能有效地进行TDD。如果你的整个团队都经验丰富,这可能不是一个主要问题;但是,如果你完全从零开始……你不必做到极致,成为一个TDD纯粹主义者;你可能只需从以下子集中获得足够的实用价值
- 接口
- 依赖注入和控制反转(IoC)
- 存根和模拟对象
- 单元测试
- 自动化验收测试
老实说,我花了一些时间才充分理解上述所有概念,从而能够作为一名开发人员高效地使用它们。我很快意识到,在有机会尝试TDD之前,我需要理解编写单元测试、依赖注入和模拟的机制。一旦我理解了这些概念,我必须弄清楚它们在Visual Studio中的实际含义,这并不总是像我希望的那样清晰,特别是对于我正在使用的C#和C++中的特定代码。这意味着要找到正确的工具,学习它们的工作原理,然后实际编写一个合理的测试。
所有这些模式都是自动化测试的关键部分,因此,当您真正进行TDD时,它们不幸地成为有效TDD的先决条件。不幸的是,如果您“只是”想进行“规范优先开发”,您需要理解以上所有内容!
如果您能掌握所有这些知识,您将获得许多重要的回报。一旦您的代码库变得足够庞大和复杂,TDD可能会为您节省大量时间。XProgramming.com解释道:“在一个项目的生命周期中,自动化测试可以通过发现和防范错误,为您节省一百倍于创建成本。测试越难编写,您就越需要它,因为您的节省将越大。自动化单元测试的回报远远大于创建成本。”这种效果在Capers Jones的研究中得到了经验性的证明。尽管都是“敏捷”的,极限编程(XP)在多达1000个功能点的项目上明显优于Scrum。他的数据集包含数千个软件项目。琼斯认为TDD是关键的区别。
还有更多好消息。除了作为先决条件,上述技术本身也可以作为TDD的替代方案。没有必要用完美主义的测试套件愿景来束缚自己,那种阻止你提交回归测试的愿景,那种给你的程序“浇圣水”的愿景。单独学习和应用这些先决条件是有益的。每种方法都适用于解决特定类型的问题。一旦你理解了每种方法的工作原理,你就能够应用它来解决你实际面临的即时问题。
什么都不做,完全空白,零
好吧,我有点开玩笑了,但这是真的。有时候你只是在做一个原型,仅仅是为了学习一些东西。比如一门编程课。有时候,你只是为了自己的目的创建一个工具,比如一个一次性脚本来自动化一些繁琐的事情。有时候,你根本不需要关心bug;没有人会因为你有一个bug而叫你傻瓜,例如,因为没有人会看到你做了什么。
更现实地说,如果您正在为某个问题探索技术解决方案,您只是想知道一些技术和技术是否协同工作。那个“信息片段”可能对您具有巨大的价值。Ward Cunningham在C2.com wiki上描述了探索性解决方案(spiking):“我经常问肯特[贝克],‘我们能编写什么最简单的程序,能让我们相信我们走在正确的轨道上?’这种跳出眼前困境的做法常常引导我们找到更简单、更具说服力的解决方案。肯特称之为‘探索性解决方案’。我发现这种做法在维护大型框架时特别有用。”编写自动化测试只会让创建探索性解决方案花费更长时间,特别是如果您认为不会将探索性解决方案作为生产代码的一部分。
在考虑效率之前,先考虑您的有效性。借用玛丽·波彭迪克(Mary Poppendieck)的话,从纯粹的技术角度来看,“首先构建正确的东西,然后正确地构建东西”。基于TDD的技术可能会碍事。
接口
在不大幅改变方法的前提下,实现巨大收益的方法是使用更多接口。什么,你还没有使用它们吗?接口将你理解的概念与任何特定的实现分离开来。你已经自然地以这种方式思考;这有助于你的代码以相同的方式运行。
比如说,一张桌子的概念独立于任何特定的桌子实例。无论如何,桌子有固定数量的用途。你会期望每张桌子都支持某些功能。你可以在桌子上放东西,比如一个杯子、一个盘子。一张桌子可以聚集人们,例如,吃早餐,从而引发讨论。某些特殊的桌子可以用作书桌,但这不会使桌子的用途失效。在某种程度上,无论你用英语说“table”,西班牙语说“tabla”,还是中文说“桌子”,说这种语言的人都会对桌子的用途有相同的假设,并在较小程度上,对其固有的特性也有相同的假设。
简而言之,一个抽象概念是任何对象实例的基础,因此你应该将代码组织成合理的抽象。这将使你在构建功能时更容易更改类。
此外,即使在现有代码上回溯式地引入接口,也能带来额外的架构优势:你可以防止意外的副作用。在现有代码中积极引入接口,会迫使代码变得自包含。接口是一种概念性的范围界定工具。就像变量可以局部作用于方法、类或命名空间一样,接口就像一个领域特定的作用域。它强制特定的、概念相关的组件协同工作,而不与其他代码交互。这大大降低了代码的复杂性,因为代码将映射到你对问题的直观理解。
然而,使用过多的接口很容易导致类的数量爆炸。例如,在C#中,如果您为每个类创建一个接口,您的代码库将增加50%,而没有添加任何功能。在大型系统中,这会增加大量的额外代码行。
要从这项技术中获益,请在接口有实际功能或位于重要边界时添加它们。例如,为发送消息的类提供一个带有“发送”方法的接口类,可以完全消除对任何特定消息技术的依赖。将来,将新的消息总线添加到代码中也会更容易。在组件边界处设置接口意味着您的代码更加模块化、自包含且易于修改。
控制反转和依赖注入
编程是一种通过学习来解决问题的集中形式。在你实现解决方案之前,你对问题的了解程度必然会低于解决方案投入生产之后。使用接口将使尝试多种替代方案变得更容易。
一旦你理解了接口本身的工作原理,依赖注入和控制反转(IoC)就是使用它们的好方法。它们第一次不容易掌握,但值得深入研究。虽然IoC从契约角度描述了一种通用关系,但依赖注入专门使用接口作为一种基于代码的契约。
如果你遵循以下两个简单步骤,你就实现了控制反转:
1. 将“做什么”部分与“何时做”部分分开。
2. 确保“何时”部分对“做什么”部分了解得越少越好;反之亦然。
有了 IoC,不仅代码与实现之间存在概念上的分离,而且您还可以根据时间(即何时)和对调用代码的现有条件的假设来进一步解耦您的代码。
在一个小型的代码库中,这可能看起来会使代码更复杂。但在一个大型系统中,它是一个项目救星。系统中的每个组件,即“做什么”部分,都非常清晰地定义和自包含。它与“何时做”部分完全分离;例如,程序启动时加载资源的引导程序。这样,将各个组件像乐高积木一样组合起来就容易得多。考虑你的代码也更容易,因为它映射到你对随时间推移的交互的理解。
你从 IoC 中能获得什么?
- 运行时实现的解耦
- 避免导致耦合的“管道代码”
- 组件只关心契约,无需再做任何假设
- 对其他模块没有副作用
这种摆脱许多假设的能力非常强大。它消除了相互依赖,从而简化了你的代码。代码中通常存在隐藏的假设;IoC 保证所有假设都只存在于接口或契约层面。这是无价的。
依赖注入是一种常见的模式,旨在帮助您实现IoC,特别是使用接口。外部对象作为接口传递给类,这样类只使用接口,而实际执行的代码可以在运行时确定。
DI 有三种常见方法,包括在以下层面传递接口:
- 构造函数级别:在初始化时,您传入任何实现特定接口的对象。在类内部,它只使用接口方法。
- getter/setter 级别:类似于构造函数级别,尽管实现可以在对象生命周期中的任何时刻传入。虽然更灵活,但它也稍微不那么可预测。
- 一个专门的容器,用于解决依赖关系。给定一个接口,容器返回该接口的特定实现。
DI 是明确分离依赖关系的工具,以便在运行时按需添加。DI 还使得在测试类功能时注入测试存根或模拟对象变得容易。它强制您创建具有高内聚和低耦合的类。应该在一起的在一起。应该能够独立变化的独立。
用存根和模拟对象假装 (Nanny, nanny, boo, boo)
一旦你可以将接口注入到你的对象中,你突然就拥有了快速更改对象和交互的能力。问题立即出现,你要把它们改成什么?为了有效地理解和测试你的代码,你想要构建一个“科学”模型,以测试线束的形式。这样的模型可以帮助你保持一切恒定,例如
- 其他类
- 外部程序集
- 输入
以便你可以隔离每个单独的方法,或组件的某个部分。从假设检验的角度来看,唯一重要的是对象从其依赖项的角度来看的样子和行为。对象的私有部分由你决定。
测试替身通常是实现此目的最有效的方法。作为 GoF 代理模式的一种形式,测试替身可帮助您假装被测对象处于生产条件下,而实际上并非处于生产条件下。实际上,测试替身可帮助您进行思想实验。它们是一种工具,可帮助您的计算机“想象”,创建假设情况,以模拟您预期会发生的场景。
如果你不确定,你只使用测试替身来从你正在测试的“真实”类中提取特定的行为。这在我在网上找到的文档中竟然被认为是显而易见的。测试测试替身毫无意义,除非你正在编写一个测试框架,当然。
有许多测试替身类型可以达到目的,它们有各种各样奇怪而奇妙的名字:假对象(Dummy) vs. 存根(Stub) vs. 间谍(Spy) vs. 伪对象(Fake) vs. 模拟对象(Mock)。实际上,存根和模拟对象涵盖了您将遇到的大多数典型场景。
存根是自动化测试的主力。当你无法控制测试的间接输入时,存根会打破你正在测试的对象的实际依赖关系。它们更容易实例化。当你调用依赖关系时,存根会提供预设答案,而无需你实例化其依赖关系。结果呢?你可以在你的对象周围创建一个“气泡”,并使用存根非常精确地与之交互,以确认它在每种情况下都能按照你的意愿行事。
例如,如果你的对象判断你有一张价值超过1,000,000美元的彩票,它应该向标准输出打印“恭喜”。你将使用一个实现与彩票共享接口的存根。为了测试你的对象是否有礼貌并向你祝贺,这个存根将模拟给你一张价值1,000,001美元的彩票。在这种情况下,你的对象需要向你祝贺。存根使得创建这样一个模拟彩票变得容易。
模拟对象更复杂一些,它们可以帮助你明确你对对象行为的假设。模拟对象可以帮助你确保你的对象调用了其依赖项的方法。模拟对象不是查看输入数据,而是帮助你确定在运行时是否调用了特定依赖项的方法。
如果你的对象告诉一个依赖项叫唤,它就应该真的叫唤。
马丁·福勒关于存根和模拟对象的文章在我刚开始的时候很有帮助,尽管他的例子是Java的。人无完人。 " /> 马丁建议在一个待测方法中避免使用多个模拟对象。这样你就能在一个单元测试中测试多个交互。如有必要,你可以使用任意数量的存根来隔离你的类和特定方法。
一个好的模拟框架可以帮助您从定义的接口生成存根和模拟对象。这有助于您避免编写大量一次性类,仅仅为了隔离代码。您也无需担心依赖关系。您可以快速连接您的类,您所需要的只是接口。
单元测试
最后,我们到了单元测试有意义的阶段,因为你已经准备好了所有的部分。Roy Overshove的著作《单元测试的艺术》在这方面非常有帮助。在任何给定时刻,你都会编写一个单元测试,以检查特定方法是否按预期行为。因为你的类在边界处用接口实例化,你可以很容易地从这些接口创建存根和模拟对象。叮!
在单元测试中,最基本的原子元素是待测方法(MUT)。理想情况下,每个测试只确认一个类中一个函数的一个方面。测试命名得当,你立刻就知道哪些测试失败了。尝试通过代码测试一条逻辑路径,尽可能精细到实际有意义的程度。一旦你有了足够的测试,你就可以通过运行测试来证明每个方法都按预期工作。
编写单元测试时,理想情况下应该:
1. 从“正常情况”或预期功能的测试开始,
2. 然后是边界情况,接着是
3. 有问题的案例,即已报告的错误
通常,创建正常情况的单元测试就足够了,因为这意味着一旦你确信需要它们,并且你的架构足够灵活可以轻松地添加它们,其他情况也可以很容易地添加。
通过创建自动化单元测试,您可以相对确定:
* 您的方法功能不会意外改变
* 在重构后,如果通过您的测试,该类将继续执行您期望的所有功能
* 类之间的交互清晰
它们通常会帮助您及早发现代码中的任何问题,甚至在您将其交给他人查看之前。您不需要使用调试器。测试也是一个软件合同,因为它会立即告诉您代码是否停止按指定工作。在某种程度上,它有助于设计。它规定了解决方案的外观,而无需深入研究实现细节。您可以更容易地专注于解决规范的最简单方法。
自动化验收测试
然而,单元测试确实很繁琐。它深入细节,这意味着你可能会错过一些重要而明显的东西,但通常,重要而明显的东西才是你的客户最关心的。重要而明显的东西往往是他们真正能看到的。客户期望功能协同工作,而不知道每个部分是如何相互关联的。他们想开车,而无需调整发动机部件才能完成任务。
进入自动化验收测试。这是我对许多方法和技术的统称,例如行为驱动开发(BDD)、集成测试或面向客户的测试。它们可以帮助你确认你的客户仍然会满意,因为你没有用刚刚提交的更改破坏他最喜欢的功能。这些测试旨在捕捉客户需求的核心。因此,它们往往比单元测试操作的层面更高,将许多相关类整合在一起,以检查它们是否按预期工作。
大多数类型的自动化验收测试都接受既定的依赖关系。如果特定对象需要依赖关系,它就会实例化该对象。它不会费心去解耦事物。这既是它最大的优点,也是它最大的缺点。在这个层面工作通常更容易,因为测试会证明特定功能按照客户的预期工作。测试的存在是为了确认你的预期。这种确认具有很大的商业价值。同时,你通常会尽量避免解耦代码。解耦过程是痛苦且耗时的,但如果你推迟太久,你的代码就会一团糟。它将难以处理。它将难以更改。相比之下,正确实施的单元测试,即带有依赖注入的单元测试,将你的代码切割成完全隔离的方法和类。所有更改都保持极其局部化。无论你是在代码存在之前还是之后编写测试,这都是如此。如果你有足够的单元测试覆盖率,更改将非常容易实现。测试会确认你没有破坏任何其他现有功能。它们还会使你的估算更加准确和可靠。你甚至不需要使用调试器。自动化验收测试不会让你那么诚实。
自动化验收测试在重构时仍然非常有用。您避免了对客户重要的“意外回归”,这些回归在处理许多功能的较长项目中其实相当频繁。通常,潜在的回归会在您的初始 Beta 版本发布之前发生多次,当您开发软件时。如果您知道自己刚刚破坏了某些东西,您可以修复它,而无需打扰其他人。
当你发现新的需求或“预期的客户期望”时,你可以编写验收测试,以确认你的代码是否真的实现了这些。随着你添加功能,你必然会经历可能的路径的组合爆炸。拥有自动化验收测试对于确保你涵盖了基本内容非常有帮助。你可以专注于编写一个好的算法,因为大量的简单测试会让你知道你是否满足了它们。
它们在你试验时充当安全网。例如,由于良好的验收测试,你不需要不断地手动重新测试,你的覆盖重命名文件的方法是否会破坏仅仅写入不存在文件的场景。每个逻辑路径都会有一些原子测试,并在你尝试新事物时立即给你反馈。
我最喜欢的工具是Fitnesse。Wiki上的表格作为示例。然后将这些表格连接到测试工具,这些工具实例化一组类,并确认它们按预期运行。因为所有东西都在wiki上,所以团队中的每个人都可以为构建测试套件做出贡献:从业务分析师到开发人员,再到测试人员。这使得讨论你正在解决的问题变得容易,从而精确理解问题。开发人员也参与其中。因此,他们更有可能编写出解决正确问题的代码。
被误解的需求是许多软件项目中的一个主要浪费来源,特别是当需求频繁变动时,例如超过50%。此外,它们通常会对项目产生巨大的负面影响。Scott Ambler 总结道:“如果错误(很可能是由于对需求理解错误造成的)导致大量数据损坏,那么从问题中恢复的成本可能非常巨大。或者在商业软件,或者至少是贵组织客户使用的‘面向客户’软件的情况下,有缺陷的软件带来的公众羞辱可能非常巨大(例如客户不再信任您)。”因此,降低此类错误的发生概率,可以增加项目真正满足客户需求的机会。
那为什么要麻烦呢?
虽然这份清单是按顺序呈现的,但实际上你可以使用上述任何组合,并在此过程中仍然获得一些好处。在某些情况下,仅仅引入接口就能让你的代码更易于维护,所以你真的不需要走到尽头。分离你的关注点可能就足够了,因为你可以确定接口的任何一方都不会有副作用。在其他情况下,只有一套完整的单元测试和验收测试才足够,特别是对于关键任务系统的核心逻辑。例如,在空中交通管制软件中,事关人命,一个bug是绝对不可接受的,特别是这些技术可以帮助你防止大多数bug……当然包括那些因为疏忽而悄悄潜入的又大又丑的bug。在这种情况下,你甚至可能想使用形式化方法来证明在关键值的计算中不可能存在bug。
关于TDD是否是特定项目最佳方法的意识形态之争,许多有用的技术细节在其中丢失了。有时是,有时不是,有时更轻量级的方法更合适。值得仔细琢磨哪种方法最适合你,但最终,你的决定将取决于你认为它会为你节省时间还是花费时间。它还应该考虑你的产品何时开始需要这些测试。
在我看来,TDD之所以备受争议,是因为个体团队成员对时间有着截然不同的假设。产品顾问唐·赖纳特森(Don Reinertsen)将延迟成本作为衡量新(即:全新)产品发布的重要指标,无论是在大公司还是小公司。在他的内部培训课程开始时,在大多数产品团队中,初始延迟成本估算范围大约是两个数量级。每次都是同一个公司,同一个产品,同一个目标市场,同一个技术,同一个人。尽管如此,这种估算不一致的情况在各公司普遍存在。大概没有人甚至费心提起它,所以每个人都只是假设其他人都有相同的假设。
作为一名软件开发人员,你被期望对自己的工作提供极其准确的估算,有时如果你对“业务”的估算错误,甚至会面临牺牲“头生子”的风险。然而,我们通常甚至不向“业务”询问延迟成本估算。虽然可能需要一些基本的会计和营销技能才能得出这个数字,但你并不需要真正了解它是如何产生的,就能利用它。同样,你的产品负责人或项目发起人也无需知道你的开发工作估算为何或大或小,就能利用它们。即使它不完全准确,也可能比你的团队当前范围内的任何值(如果你还记得,可能夸大了100倍或低估了100倍)更准确。
在您的团队就市场时间价值达成一致,或者甚至获得外部提供的延迟成本估算之前,您无法确定是否能够承担TDD或其“小表弟”先决条件。一旦您对总延迟成本有了清晰的认识,您就可以弄清楚测试驱动开发带来的长期节省和错误预防是否真的能帮助您的产品赚钱。