TDD:为什么让应用程序代码知道它正在测试,而不是运行,这可能是错误的?

在这个post中 ,Brian(唯一的回答者)说“你的代码应该以一种与测试无关的方式编写”

单一的评论说“你的代码绝对不应该在全球范围内分支”我正在测试标志“。”。

但是没有给出理由,我真的希望听到一些关于此事的理性思考。 这将是非常容易的(特别是考虑到许多测试具有对app类的包私有访问这一事实)以进入给定的app类并设置一个布尔值来表示“这是一个测试,而不是一个运行”。

我发现自己跳过篮球(注入模拟的私人领域等)以实现的各种各样的事情可能变得更容易实现。

同样显而易见的是,如果你把它拿得太远就可能是灾难性的……但作为软件测试军械库中许多人的一个工具,为什么这个概念会遇到这样的耻辱?

回答Mick Mnemonic:

如果您实际在方法中间创建一个新的类实例并将其分配给私有字段,那么这可能有用的一个简单示例:私有字段模拟在这种情况下将无济于事,因为您正在替换私有领域。 但实际上创建一个真实对象可能会非常昂贵:您可能希望在测试时将其替换为轻量级版本。

我昨天遇到过这种情况,实际上……我的解决方案是创建一个名为createXXX()的新的包私有方法…所以我可以嘲笑它。 但这反过来反对“你不能创造适合你的测试的方法”的格言!

我将把这个答案分成两部分。 首先,我将分享我对Brian的答案的看法,然后我将分享一些有关如何有效测试的技巧。

对Brian的回答的解释

Brian似乎暗示了两个关键的想法。 我将逐一解决每一个问题。

想法1:生产代码不应该依赖于测试

您的代码应该以与测试无关的方式编写。

生产代码不应该依赖于测试。 应该是相反的。

这有多种原因:

  1. 更改测试不会改变代码的行为
  2. 您的生产代码可以独立于测试代码进行编译和部署
  3. 更新测试时, 不需要重新编译代码。
  4. 由于未运行测试代码而产生意外的副作用,您的生产代码可能无法失败

注意:任何体面的编译器都会删除测试代码。 虽然我不认为这是设计/测试系统设计不佳的借口。

想法2:您应该测试抽象而不是实现

无论您在哪种环境中测试,都应尽可能接近现实世界。

听起来 Brian 似乎在他的回答中暗示了这个想法。 与最后一个想法不同,这个想法并不是普遍认同的,所以要把它当作一粒盐。

通过测试抽象,您可以对被测单元产生一定程度的尊重。 你同意你不会对其内部情况嗤之以鼻,并监视其内部状态。

为什么我不应该在测试期间监视对象的状态?

通过监视对象的内部,你会导致这些问题:

  1. 您的测试将使您与单元的特定实现相关联。

    例如…
    想要改变你的class级以使用不同的排序算法? 太糟糕了,你的测试会失败,因为你断言必须调用quicksort函数。

  2. 你将打破封装

    通过测试对象的内部状态,您将试图放松该对象的一些隐私。 这意味着您的更多生产代码也将提高对对象的可见性。

    通过放松对象的封装,您会诱使其他生产代码也依赖于它。 这不仅可以将测试与特定实现相结合,还可以与整个系统本身相关联。 你希望这种情况发生。

然后我怎么知道class级是否有效?

测试被调用方法的前置条件和后置条件/结果。 如果您需要更复杂的测试,请查看我在模拟和dependency injection上撰写的最后一节。

迷你笔记

只要您的生产代码保持独立于您的测试,我认为在您的main方法中使用if (TEST_MODE)并不一定是坏事。

例如:

 public class Startup { private static final boolean TEST_MODE = false; public static void main(String[] args) { if (TEST_MODE) { TestSuite testSuite = new TestSuite(); testSuite.execute(); } else { Main main = new Main(); main.execute(); } } } 

但是,如果您的其他类知道它们正在测试模式下运行,则会出现问题。 如果您在所有生产代码中都有if (TEST_MODE) ,那么您将面对我上面提到的问题。

显然在Java中你会使用像JUnit或TestNG这样的东西而不是这个,但我只想分享我对if (TEST_MODE)想法的想法。

如何有效地测试

这是一个非常大的主题,所以我将保持这部分答案简短。

  • 而不是监视内部状态, 使用模拟和dependency injection

    使用模拟,您可以声明已经调用了已注入的模拟方法。 更好的是,dependency injection将反转您的类依赖于您注入的任何内容的实现。 这意味着您可以更换不同的实现,而无需担心。

    这完全消除了在课堂内徘徊的需要。


如果有一本书我强烈推荐阅读,那就是Jeff Langr的 现代C ++编程与测试驱动开发 。 它可能是我用过的最好的TDD资源。

尽管标题中有C ++,但其主要焦点仍然是TDD。 本书的介绍讨论了这些示例如何适用于所有(类似)语言。 鲍勃叔叔甚至在前言中说明了这一点:

你需要成为一名C ++程序员才能理解它吗? 当然你没有。 C ++代码非常干净,编写得非常好,概念非常清晰,任何Java,C#,C甚至Ruby程序员都不会遇到任何麻烦。

想想大众汽车的大丑闻。 在测试中表现出与在生产负载下不同的系统并未真正测试。 那就是:它实际上是两个系统,即生产系统和测试系统 – 其中唯一一个被测试的是测试系统。 不同的生产系统未经过测试。 您在两个系统之间引入的行为的每个差异都是一个测试漏洞。

很多测试都有对包类的私有访问权限

我建议不要这样做,在生产代码中打破封装的想法感觉就像尾巴摇尾巴给我。 它表明这些课程太大和/或缺乏凝聚力。 TDD,dependency injection/控制反转,嘲弄和编写单一责任类应该不再需要放松可见性。

单一的评论说“你的代码绝对不应该在全球范围内分支”我正在测试标志“。”。

生产代码是生产代码,无需了解您的测试。 在那里应该没有关于测试的逻辑,它的分离很差。 同样,dependency injection/控制反转将允许您在运行时交换测试特定逻辑,这将不包含在生产工件中。

TDD:为什么让应用程序代码知道它正在测试,而不是运行,这可能是错误的?

1)Carl Manaster带来了一个优秀而简短的答案。 如果您的实现根据测试的具有不同的行为,则您的测试没有价值,因为它不反映应用程序在生产中的实际行为,因此它不validation要求。

2)测试驱动开发与让应用程序代码知道它正在测试的事实无关。 无论您使用何种开发方法,都可能会引入此类错误。

凭借我的TDD经验,我认为TDD阻止让应用程序代码知道它正在测试中,因为当您在第一个意图中编写unit testing并且适当地执行它时,您可以保证有一个自然可测试的应用程序代码来validation应用程序要求并且不了解经过测试的代码。

我想相反,当你在编写应用程序代码后创建测试代码时,更可能发生这种错误,因为你可能不想重构应用程序代码以使代码可测试,因此在实现中添加一些技巧来绕过重构任务。

3)测试驱动开发是可以工作的代码,但是当你使用它时,你不能忘记app类和测试类的设计方面。

如果您实际在方法中间创建一个新的类实例并将其分配给私有字段,那么这可能有用的一个简单示例:私有字段模拟在这种情况下将无济于事,因为您正在替换私有领域。 但实际上创建一个真实对象可能会非常昂贵:您可能希望在测试时将其替换为轻量级版本。

我昨天遇到过这种情况,实际上……我的解决方案是创建一个名为createXXX()的新的包私有方法…所以我可以嘲笑它。 但这反过来反对“你不能创造适合你的测试的方法”的格言!

在某些情况下,使用package-private修饰符是可以接受的,但只有在设计代码的所有自然方式都不允许具有可接受的解决方案时才应使用它。

“你不能创造适合你的测试的方法”可能会产生误导。

事实上,我会说: “你不应该创建适合你的测试的方法,并以不合需要的方式打开应用程序的API”

在您的示例中,当您想要修改您希望在测试期间模拟或替换依赖项的代码的依赖项时,如果您练习TDD,则不应直接修改实现,而应通过测试代码开始修改。
如果您测试代码似乎被阻止,因为您错过了构造函数,方法,对象等…为您的测试类设置依赖项,您将被迫添加到您的测试类中。
这是TDD方式。

上面,我提到不要超过需要打开API。 我将提供两个示例,它们提供了一种设置依赖关系但不以相同方式打开API的方法。

这样做是可取的,因为客户端无法在生产中更改MyClass的行为:

 @Service public class MyClass{ ... MyDependency myDependency; ... @Autowired public MyClass(MyDependency myDependency){ this.myDependency = myDependency; } ... } 

这种做法不太理想,因为MyClass API增长而应用程序代码不需要它。 除了这个新方法,客户端还可以使用myDependency字段的setter来改变生产中MyClass的行为:

 @Service public class MyClass{ ... MyDependency myDependency; ... @Autowired public void setMyDependency(MyDependency myDependency){ this.myDependency = myDependency; } ... } 

只是一句话:如果你的构造函数中有超过4或5个参数,使用它可能会变得很麻烦。
如果它发生,使用setter仍然可能不是最好的解决方案,因为问题的根源可能是该类有太多的责任。 所以如果是这样的话应该重构。

我非常仔细地阅读了所有这些答案,它们都很有帮助。 但也许我应该重新归类为自己:我似乎正在成为一个低中级TDD从业者,而不是一个新手。 我已经吸收了很多这些观点和经验法则,无论是通过阅读还是有时令人费解,在过去的6个月左右,偶尔会有苦涩但总是有启发性的经验。

卡尔·曼纳斯特与大众汽车丑闻的比喻很诱人,但稍微不适用,或许:我并不是说应用程序代码应该“检测”测试正在发生并改变其行为。

建议的是,有一两个棘手的,令人讨厌的低级问题,你可能想要以不影响TDD的铸铁规则和“哲学”的方式使用这个工具。

两个例子:

我的代码中有一些情况会抛出exception,并且测试我要检查它们的位置。 好的:我去doThrow( ... )@Test( expected = ... ) ,一切正常。 但是在生产运行期间,我希望使用堆栈跟踪打印出错误消息。 在测试运行期间,我只想要错误消息。 我不希望logback-test.xml完全禁止错误级别的日志记录。 但显然没有办法配置记录器以防止打印出堆栈跟踪。

所以我能做的就是在应用程序代码中使用这样的方法,仅用于测试:

 boolean suppressStacktrace(){ return false; }; 

…然后我用它作为给定LOGGER.error( ...的测试LOGGER.error( ...情况,然后当我想在测试期间激发该exception时,模拟该方法返回true

其次,控制台输入的相当具体的情况: BufferedReader.readLine() 。 将另一个InputStream替换为System.in并为其提供一个不同Strings List ,每个readLine将提供一次,这是一个正确的问题。 我所做的是在app类中有一个private字段:

 Deque inputLinesDeque; 

…以及一个package-private方法,用输入行的List设置它,然后可以pop直到Deque为空。 在应用程序运行期间,此Dequenull ,因此if分支到br.readline()

这只是两个例子。 毫无疑问,在其他情况下,超纯粹主义的方法价格过高,并且可以说没有真正的好处。

但是,我很欣赏davidxxx对TDD 10诫命之一的高级定义:“你不应该创建适合你的测试的方法,并以不合需要的方式打开应用程序的API”。 非常有帮助:值得深思。

后来

自从一个月前写这篇文章以来,我已经意识到扩展和修改logback类是非常不可能的……我认为创建自己的logback类并不太难以在logback-test.xml接受配置标志logback-test.xml来“抑制堆栈跟踪”。 当然,当你制作一个应用程序的可执行jar时,不必导出这个定制的logback类……但是再一次,对我来说这是“跳过篮球”的类别。 应用程序代码真的需要“纯粹”吗?