【问题标题】:How to avoid the creation of poor designs with TDD如何避免使用 TDD 创建糟糕的设计
【发布时间】:2011-09-07 23:19:00
【问题描述】:

我最近(上周)开始了一项实验,我尝试在我正在使用 TDD 原则从事的项目中编写新功能。过去,我们的方法是一种适度敏捷的方法,但没有非常严格。方便时,单元测试会在这里和那里进行。全面测试覆盖的主要障碍是我们的应用程序具有复杂的依赖关系网络。我选择了一个方便隔离的功能来尝试我的实验;细节并不重要,可能商业敏感,足以说这是一个简单的优化问题。

到目前为止,我发现:

  • 对我来说,TDD 似乎鼓励漫无目的、不明显的设计成形。未经测试不得编写代码的限制往往会阻碍将功能分解为独立单元的机会。在实践中,同时为这么多功能进行思考和编写测试太难了
  • TDD 倾向于鼓励创建可以做所有事情的“上帝对象”——因为您已经为 x 类编写了很多模拟类,但为 y 类编写的模拟类很少,所以在当时看来,x 类也应该是合乎逻辑的实现特征 z,而不是将其留给 y 类。
  • 在编写代码之前编写测试要求您在解决问题之前对问题的每一个复杂性有一个完整的了解。这似乎是一个矛盾。
  • 我无法让团队支持开始使用模拟框架。这意味着专门为测试特定功能而创建的杂乱无章的扩散。对于每个测试的方法,您往往需要一个假的,它唯一的工作就是报告被测类调用了它应该调用的任何东西。我开始发现自己编写类似于 DSL 的东西纯粹是为了实例化测试数据。
  • 尽管存在上述问题,但与我习惯的开发模式不同,TDD 产生了一个几乎没有神秘错误的工作设计。然而,重构导致的混乱局面需要我暂时放弃 TDD 并完成它。我相信测试将继续强制执行该方法的正确性。尝试对 TDD 进行重构,我觉得这只会让事情变得更加繁琐。

那么问题是“是否有人有任何建议可以减少上述问题的影响?”。我毫不怀疑模拟框架将是有利的。但是目前我已经在尝试一些似乎只会产生杂乱无章的代码的东西。

编辑#1:

感谢大家深思熟虑的回答。我承认我在星期五晚上喝了几杯啤酒后写了我的问题,所以在某些地方它是模糊的,并没有真正表达我真正想要的情绪。我想强调一下,我确实喜欢 TDD 的哲学,并且发现它相当成功,但由于我列出的原因也令人惊讶。下周我有机会睡在上面,用新的眼光再次审视这个问题,所以也许我可以通过蒙混过关来解决我的问题。但是,它们都不是初学者。

更让我担心的是,一些团队成员不愿意尝试任何你可以称之为“技术”的东西,而倾向于“完成它”。我担心 cruft 的出现会被视为对该过程的一个黑标,而不是证明它需要完全完成(即使用模拟框架和强大的 DI)以获得最佳结果。

RE “TDD 不一定意味着测试优先”:(womp, btreat)

我在该问题上找到的每篇文章中的“黄金法则”都是“红色、绿色、重构”。那就是:

  • 编写一个必须失败的测试
  • 编写通过测试的代码
  • 重构代码,使其以最实用的方式通过测试

我很好奇人们如何想象在不遵循最初编写的 TDD 核心原则的情况下进行测试驱动开发。我的同事称中途之家(或不同且同样有效的方法,取决于您的观点)“测试-验证开发”。在这种情况下,我认为创造一个新术语 - 或者可能从其他人那里窃取并以此为荣 - 很有用。

用于测试数据的 RE DSL:(Michael Venable)

我很高兴你这么说。我确实看到通用形式在整个项目范围内变得越来越有用,因为所讨论的应用程序维护着一个非常复杂的对象图,并且通常,测试它意味着运行应用程序并在 GUI 中进行尝试。 (出于上述商业敏感性的原因,不会放弃游戏,但它从根本上与优化有向图上的各种指标有关。但是,其中涉及许多警告和用户可配置的小部件。)

能够以编程方式设置有意义的测试用例将有助于各种情况,可能不仅限于单元测试。

RE 神物:

我有这种感觉是因为一个班级似乎占据了大部分功能集。也许这很好,它确实那么重要,但它引起了一些人的注意,因为它看起来就像不是按照这些路线开发的旧代码,并且似乎违反了 SRP。我想某些类将不可避免地主要作为许多不同封装的功能位之间的接缝而其他类只会接缝少数。如果是这样的话,我想我需要做的是从这个明显的上帝对象中清除尽可能多的逻辑,并将其行为重新塑造为所有分解部分之间的连接点。

(对版主:我已在此处添加对帖子的回复,因为评论字段不够长,无法包含我想要的详细信息。)

编辑 #2(大约五个月后):

嗯,我觉得在考虑了一段时间后,更新一些想法可能会很好。

很遗憾,我最终还是放弃了 TDD 方法。不过,我觉得这其中有一些具体且合理的理由,并且我已经准备好下次有机会时继续这样做。

TDD 毫无歉意的重构心态的一个结果是,当我简要查看我的代码时,我并没有感到非常沮丧,主要开发人员宣称其中的绝大多数都是毫无意义的,需要删除。虽然不得不放弃大量的辛勤工作感到有些遗憾,但我明白他的意思。

出现这种情况是因为我从字面上理解了“代码到接口”的规则,但继续编写试图代表现实的类。很久以前我第一次发表声明:

类不应试图代表现实。对象模型应该只尝试解决手头的问题。

...从那以后我尽可能多地重复;对我自己和任何愿意倾听的人。

这种行为的结果是一个执行功能的类的对象模型,以及一组重复类功能的镜像接口。向我指出了这一点,经过短暂但强烈的抵抗后,看到了曙光,删除大部分内容没有问题。

这并不意味着我相信“接口代码”是无稽之谈。它的意思是,当接口代表真实的业务功能时,对接口的编码主要是有价值的,而不是一些想象中的完美对象模型的属性,它看起来像现实生活的缩影,但不考虑它的唯一含义。生活要回答你最初提出的问题。 TDD 的优势在于它无法生成这样的模型,除非是偶然。由于它从提出问题开始并且只关心获得答案,因此不会涉及您的自我和对系统的先验知识。

我现在正在胡言乱语,所以我最好完成这件事,并声明我非常渴望再次尝试 TDD,但对可用的工具和策略有更好的了解,并将尽我所能在开始之前决定我想怎么做。也许我应该把这个华夫饼移植到它所属的博客上,一旦我有更多话要说。

【问题讨论】:

  • 我目前正在阅读:Growing Object-Oriented Software Guided by Tests。关于这个主题的优秀读物。
  • 我有 Professional Test-Driven Development with C#,一些 cmets 认为这是衍生作品。我选择它是因为它特定于 C# 并且使用了一些我以前看过的工具(NUnit、Ninject)。
  • Osherove 的 The Art Of Unit Testing 相当不错,虽然它并不特定于 TDD-only。
  • 关于您的编辑:您应该遵循的一个总体原则是“YAGNI”——“您不需要它”。您编写的每一行代码都应该有助于促进现有系统的发展。永远不要构建您以后“可能”需要的东西,因为您可能不需要,即使您这样做了,您可能仍然需要更改您认为需要使其以您现在确实需要的方式工作的东西。对接口进行编码是一种很好的“象牙塔”实践,但使用 ReSharper 之类的工具提取接口是一分钟的操作,因此实际上,在需要时进行。

标签: tdd mocking


【解决方案1】:

TDD 不是在模拟。有时,模拟有助于开发测试,但如果您在 TDD 的第一次通过时是从模拟开始的,那么您可能没有得到最好的实践介绍。

根据我的经验,TDD 不会导致上帝对象;恰恰相反。 TDD 让我的类做的事情更少,与其他类交互更少,依赖更少。

不得有的限制 不经测试编写代码往往 阻止排除因素的机会 功能成独立的单元。 思考并为此编写测试 很多功能同时也是 在实践中很难。

这对我来说听起来确实不对。您不是要同时为许多功能编写测试;您正在尝试为 one 功能一次编写 一个 测试。编写该测试时,您使其通过。当它过去时,你把它弄干净。然后您编写下一个测试,可能会推动相同功能的进一步开发,直到该功能完成并干净。然后你为你的下一个特性编写下一个测试。

在编写代码之前编写测试 要求你有一个完整的 理解每一个错综复杂的 在你解决它之前的问题。这 看起来很矛盾。

再次:编写一个测试。这需要完全理解 one 特性的 one 方面。它需要它,并以可执行的形式具体地表达它。

【讨论】:

    【解决方案2】:

    就像对您遇到的问题的全面回应一样,听起来您使用 TDD 的时间不长,您可能没有使用任何可能有助于 TDD 流程的工具,而且您正在投入更多生产代码行的值比测试代码行的值。

    更具体到每一点:

    1:TDD 鼓励设计不超过或少于它必须做的,也就是“YAGNI”(你不需要它)方法。那就是“轻装上阵”。您必须在“正确行事”之间取得平衡,即将正确的 SOLID 设计概念和模式整合到系统中。我采用以下经验法则:在第一次使用一行代码时,让它工作。在第二次引用该行时,使其可读。第三,使它成为 SOLID。如果一行代码只被另一行代码使用,那么在那个时候放入一个完全 SOLID 的设计没有多大意义,将代码分解成一个可以插入和交换的接口抽象类出去。但是,一旦代码开始获得其他用途,您必须有纪律地返回并重构代码。 TDD 和敏捷设计都是关于重构的。这就是问题所在;瀑布也是如此,只是成本更高,因为您必须一直回到设计阶段才能进行更改。

    2:同样,这是纪律。单一职责原则:一个对象应该做一件特定的事情,并且是系统中唯一做那件事的对象。 TDD 不允许你偷懒;它只是帮助你找出你可以在哪里偷懒。此外,如果您需要创建一个类的许多部分模拟,或者许多功能强大的完整模拟,那么您可能会错误地构建对象和测试;您的对象太大,您的 SUT 有太多依赖项,和/或您的测试范围太广。

    3:不,它没有。它要求您在编写测试套件时考虑需要什么。这就是像 ReSharper(用于 MSVS)这样的重构助手真正大放异彩的地方; Alt+Enter 是您的“执行”快捷方式。假设您正在开发一个新类,它将写出一个报告文件。您要做的第一件事是新建该类的实例。 “等等”,ReSharper 抱怨道,“我找不到那个班级!”。 “所以创建它”,你说,按 Alt+Enter。它这样做了;你现在有一个空的类定义。现在您在测试中编写一个方法调用。 “等等,”ReSharper 喊道,“那个方法不存在!”,然后你又按 Alt+Enter 说“然后创建它”。您刚刚通过测试进行了编程;你有你的新逻辑的骨架结构。

    现在,您需要一个要写入的文件。您首先输入一个文件名作为字符串文字,知道当 RS 抱怨时,您可以简单地告诉它将参数添加到方法定义中。等等,这不是单元测试。这需要您创建的方法来接触文件系统,然后您必须取回文件并通过它以确保它是正确的。因此,您决定改为传递 Stream;这允许您传入一个完全与单元测试兼容的 MemoryStream。 TDD 影响设计决策;在这种情况下,决定是预先使类更加 SOLID,以便可以对其进行测试。相同的决定使您可以灵活地在未来将数据传输到您想要的任何地方;进入内存、文件、通过网络、命名管道等等。

    4:敏捷团队根据协议进行编程。如果没有达成协议,那就是一个障碍;如果团队被阻止,则不应编写任何代码。为了解决障碍,团队负责人或项目经理做出指挥决策。该决定是正确的,直到被证明是错误的;如果它最终出错,它应该尽快完成,这样团队就可以朝着新的方向前进,而不必回溯。在您的具体情况下,让您的经理做出决定 - Rhino、Moq 等等 - 并执行它。任何一个都比手写测试模拟好一千%。

    5:这应该是TDD真正的强项。你有一堂课;它的逻辑是一团糟,但它是正确的,你可以通过运行测试来证明它。现在,您开始重构该类以使其更加可靠。如果重构不改变对象的外部接口,那么测试甚至不必改变;您只是在清理一些测试不关心的方法逻辑,除非它有效。如果您确实更改了接口,那么您将更改测试以进行不同的调用。这需要纪律;由于被测试的方法不存在,因此很容易对不再起作用的测试进行消化。但是,您必须确保对象中的所有代码仍然得到充分执行。代码覆盖率工具可以提供帮助,它可以集成到 CI 构建过程中,如果覆盖率达不到要求,它可以“中断构建”。然而,覆盖的另一面实际上是双重的:首先,为了覆盖而增加覆盖的测试是无用的。每个测试都必须证明代码在某些新情况下按预期工作。此外,“覆盖”不是“锻炼”;您的测试套件可能会执行 SUT 中的每一行代码,但它们可能无法证明一行逻辑适用于所有情况。

    话虽如此,当我第一次学习 TDD 时,我得到了一个非常有力的教训,即 TDD 会做什么和不会做什么。这是一个编码道场;任务是编写一个罗马数字解析器,它接受一个罗马数字字符串并返回一个整数。如果您了解罗马数字的规则,这很容易预先设计并且可以通过任何给定的测试。但是,TDD 规程可以非常轻松地创建一个类,该类包含一个字典,其中包含测试中指定的所有值及其整数。它发生在我们的道场。这就是问题所在;如果解析器的实际要求是它只处理我们测试的数字,我们没有做错;该系统“工作”,我们没有浪费任何时间设计更精细的东西,在一般情况下工作。然而,我们这些新敏捷的人看着这片泥沼,说这种做法很愚蠢;我们“知道”它必须更智能、更强大。但是我们做到了吗?这是 TDD 的力量和弱点;您只能设计一个满足用户规定要求的系统,因为您不应该(而且通常不能)编写不满足或证明付款人给您的系统的某些要求的代码账单。

    虽然我写了不少后期开发测试,但这样做有一个大问题;您已经编写了生产代码,并希望以其他方式对其进行测试。如果它现在没有通过你的测试,谁错了?如果是测试,那么您更改测试以断言程序当前输出的内容是正确的。好吧,这没什么用;你刚刚证明了系统输出它一直有的东西。如果是 SUT,那么问题就更大了;你有一个你已经完全开发的对象没有通过你的新测试,现在你必须把它撕开并改变一些东西才能让它通过。如果这是您迄今为止对该对象的唯一自动化测试,谁知道您会打破什么才能通过这一测试?相反,TDD 强制您在合并任何将通过该测试的新逻辑之前编写测试,因此您有回归证明代码;在开始添加新代码之前,您有一套测试可以证明代码符合当前要求。因此,如果在添加代码时现有测试失败,那么您破坏了某些东西,并且您不应该提交该代码以进行发布,直到它通过所有已经存在的测试和所有新测试。

    如果您的测试中有冲突,那就是一个障碍。假设您有一个测试,证明给定方法在给定 A、B 和 C 的情况下返回 X。现在,您有一个新要求,在开发测试时您发现现在相同的方法必须在给定 A、B 和 C 时输出 Y C. 好吧,先前的测试对于证明系统以旧方式工作是不可或缺的,因此更改该测试以证明它现在返回 Y 可能会破坏基于该行为的其他测试。要解决此问题,您必须澄清新要求是对旧要求的行为更改,或者其中一种行为是从接受要求中错误推断出来的。

    【讨论】:

    • 一段时间后我重新审视了这个话题并重新阅读了回复,这个最符合我对这个话题的想法。我想这使它成为“最喜欢”的答案,尽管在 CW 类型的问题中适用多少还有待商榷。如果您有兴趣,我已经更详细地介绍了这个作为主要问题的编辑。
    【解决方案3】:

    我强烈建议您继续使用您的方法一段时间,然后阅读 Gerard Mezaros 的书 xUnit 测试模式,并尝试应用他的指南。 TDD 是一条漫长而曲折的道路,需要很长时间才能开始看到好处。我对您的一些担忧的简短回应如下:

    • TDD 确实鼓励非显而易见的设计。通常这些比显而易见的要好。您可以在网上找到的一些 katas 显示了此功能 - 您可能已经想象过一整套类 TDD 会用很少的代码产生一个或两个。我不同意它阻止了分解位的机会——TDD 的口号是红色、绿色、重构。我认为这里的秘诀是不要同时考虑所有功能 - 在您开始考虑如何通过类完成之前,从高层次上坚持一次。
    • 可能是这样,但是当您戴上重构帽子时,您总是可以将您的大类重构为多个类,并且知道您的测试会发现重构中的任何错误是安全的。 TDD 鼓励在每一个机会(只要你有绿灯)进行重构,因此结果不应该是上帝的对象。
    • 我不同意这个。所有 TDD 要求是您知道您需要的一件事并编写该测试。然后你让它通过。然后你想到你需要的另一件事。各种katas 很好地说明了这一点。 Kent Beck 关于 TDD 的原著也说明了这一过程。
    • 模拟很困难,这是真的。此外,用于设置测试数据的 DSL 也是一个好主意 :)
    • 据我所知,根据定义,重构不需要更多测试。您在绿条下进行重构——这意味着您永远不会为重构编写测试。重构可能会导致创建一个新类,此时您可能希望创建一个新的测试类来测试您的新类并一次将一个测试移动到新的测试类,但通常重构是在没有添加新的测试。

    【讨论】:

    • 大,大+1,特别是对于“TDD是一条漫长而曲折的道路”。 @Tom - 在您的第一周 OOP 之后,您在课堂设计方面的表现如何?这需要时间和练习,而且总是有更多的东西要学。
    • @TrueWill:我不敢看我为大学论文写的代码,如果我能找到的话。一周我拿起一本关于 C# 的书,然后开始工作。这几乎是一场彻底的灾难。
    【解决方案4】:

    我认为您陷入了一种常见的误解,即 TDD 始终意味着“测试优先”。测试优先开发不一定与 TDD 相同。 TDD 是一种专注于编写可测试代码的软件工程方法。没有严格的要求总是先编写测试才能练习 TDD。

    让我分解你的论点,希望我能帮助你清除一些障碍!

    TDD for me seems to encourage rambling, non-obvious designs to take
    

    形状。必须的限制 未经测试不写代码往往 阻止排除因素的机会 功能成独立的单元。 思考并为此编写测试 很多功能同时也是 实际操作困难

    TDD 是关于尝试编写可测试的代码。首先编写测试的目的是让您可以勾勒出您的类在做什么,并确保您设计的这些类是可测试的。

    你的测试不应该做的是锁定你或强迫你的设计。测试是可变的——如果你写了一个测试,然后它阻止你重构某些东西,只需删除测试。您不能编写工作代码来考虑已经编写的测试。测试是为了支持您的代码,而不是相反。

    TDD tends to encourage the creation of 'God Objects' that do
    

    一切——因为你已经写了 x 班有很多模拟课程 已经,但是对于 y 类来说很少,所以它 在那个时候似乎合乎逻辑 还应该实现特征 z 而不是把它留给 y 类。

    I haven't been able to get the team on-side to start using a mocking
    

    框架。这意味着有一个 单独创造的杂物泛滥 测试特定功能。为了 每种测试方法,你都会倾向于 需要一个假货,他唯一的工作就是 报告被测类 调用它应该的任何东西。我是 开始发现自己在写作 类似于 DSL 的东西纯粹是为了 实例化测试数据。

    真正应该使用一个框架来完成模拟,以消除编写所有这些模拟类的负担。同样,听起来您将测试视为静态的,并编写真实代码以适应现有测试。这不是您面临的 TDD 问题 - 这是一个管理问题。创建杂乱无章的是您的团队,而不是 TDD 流程。仅仅将一个特性移动到一个不属于它的类是出于方便的有意识的选择,而不是适当的工程。如果嘲讽是负担,那就是导致懒惰选择的问题。

    Writing tests before you write code requires that you have a complete
    

    了解每一个错综复杂的 在你解决它之前的问题。这 看起来很矛盾。

    测试应该随着您的代码流畅地发展。在开始为它编写测试之前,不需要了解所有内容 - 测试应该帮助您创建设计,以便您创建良好的、可测试的类。如果你不得不离开并构建真正的代码几天,然后回来更新你的测试,TDD 警察不会闯入你的大门。关键是,当您构建类时,您正在考虑如何测试它们。同时编写测试很棒,但是有时您还没有足够的知识来编写它们。测试需要与您合作,而不是针对您。

    请记住 - “先测试”只是进行 TDD 的一种方式。这不是唯一的方法,事实上,我从来没有遇到过对他们编写的每一段代码都实行“测试优先”的人。

    【讨论】:

      【解决方案5】:

      由于您提到的原因,我个人不喜欢在代码之前编写测试。在实践中,我更喜欢遵循编写一些代码的方法,为代码编写测试,确保测试运行,然后提交代码和测试。这避免了您提到的许多问题,同时还保留了 TDD 旨在促进的一些好处。代码的编写方式是为了促进最终将要进行的测试,但代码驱动测试而不是相反。

      【讨论】:

      • 根据我的经验,后测代码编写的耦合度更高,使得编写测试变得更加困难。由于时间紧迫,还有可能永远无法编写测试。
      • 无论您是在代码之前还是之后编写测试,都必须具备编写测试的规则。但总的来说,必须采用最适合他们的方法,无论是先测试还是后测试。
      【解决方案6】:

      我认为您在开始使用 TDD 时的感觉是正常的。您的代码必须按照某些约定编写以支持 TDD(如依赖注入),并随着时间的推移通过习惯改变您的代码编写风格以支持它。

      对于您发现自己为测试编写 DSL 并不感到惊讶,我认为这不是一件坏事。它使测试更容易阅读,这将大大有助于维护测试。我希望它能让你的同事也更容易添加他们自己的测试。最近,我发现自己使用fluent interface 来使测试代码可读并将通用逻辑分解到一个位置。

      例子:

        LoadModelSetupFromTestFileCollection("VariableSetToScript.xml");
        AssertVariable("variable").HasValue(3);
      

      测试代码的每个功能需要做很多工作,但我不知道有其他方法可以自信地说您知道所有代码都能正常工作。从长远来看,拥有一组自动化测试确实会带来回报,因为未来的代码更改会破坏现有代码。

      我能给出的最佳建议:

      • 选择单位是什么。它不必是一个类。一个小模块(有几个类)可以作为一个被测单元。这样做可以减少模拟的需要,因为您一次测试了一组类。唯一的缺点是调试比较困难,但您的测试同样有效。

      • 我喜欢在编写代码之前编写测试,但有时,您确实需要先探索想法。可以在需要时进行更多的“探索性”代码编写,然后再编写测试。但是,我怀疑大多数时候,在开始编写代码之前,您对所需的函数有一个很好的了解。

      • 将您的测试代码分解为函数。我所做的代码更改几乎不会影响生产代码,但会通过测试代码引起涟漪效应。精心设计的测试代码解决了这个问题并有助于维护。

      • 类似于 DSL 语言的测试代码在维护方面大有帮助,并使创建新测试更容易,IMO。

      我认为你在正确的轨道上。如果您很难说服人们嘲笑,那么就不需要它。如果您觉得测试每个功能太多,那么测试最有可能失败的功能。从小处着手。随着团队对此感到满意,他们有望开始创建更多测试。无论如何,有一些测试总比没有测试好。

      【讨论】:

        【解决方案7】:

        首先,请尝试对照参考书(例如 Beck 的书)检查您的方法。当你在学习一些东西时——不要质疑地遵守规则。在不了解更改的含义的情况下过早调整方法的常见错误。
        例如(正如 Carl 发布的那样)我读过的书提倡一次编写一个单元测试,并在填写实现之前看着它失败。

        一旦通过,您需要“重构”。小字,但大含义 - 这是成败的一步。您可以通过一系列小步骤改进您的设计。然而,TDD 不能替代经验……这源于实践。所以一个有经验的程序员有/没有 TDD 可能仍然会比一个有 TDD 的新手产生更好的代码——因为他/她知道要注意什么。那你怎么去那里?你可以向那些已经这样做了一段时间的人学习。

        • 我首先推荐 Beck 的 TDD By Example 书。 (Freeman and Pryce 的 GOOS book 很好,但是一旦你做了一段时间的 TDD,你就会从中获得更好的价值。)
        • 要了解 Guru 的思想,请查看 Bob Martin 的 Clean Code。它为您提供了简单的启发式方法来评估您的选择。我在第 3 章;我什至设置了一个小组阅读练习@work。正如书中所说,干净的代码等同于纪律、技术 + “代码意识”。

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2020-02-03
          • 2021-10-09
          • 1970-01-01
          • 1970-01-01
          • 2020-09-10
          • 1970-01-01
          • 2012-01-18
          相关资源
          最近更新 更多