【问题标题】:Test: stub vs real implementation测试:存根与实际实现
【发布时间】:2012-03-14 19:22:41
【问题描述】:

我一直想知道在单元测试中一般使用存根与使用真实(生产)实现,特别是在使用存根时我们是否不会遇到一个相当讨厌的问题,如下所示:

假设我们有这个(伪)代码:

public class A {
  public int getInt() {
    if (..) {
      return 2;
    }
    else {
      throw new AException();
    }
  }
}

public class B {
  public void doSomething() {
    A a = new A();
    try {
      a.getInt();
    }
    catch(AException e) {
      throw new BException(e);
    }
  }
}


public class UnitTestB {
  @Test
  public void throwsBExceptionWhenFailsToReadInt() {
     // Stub A to throw AException() when getInt is called
     // verify that we get a BException on doSomething()
  }
}

现在假设我们稍后再编写数百个测试时,意识到 A 不应该真的抛出 AException,而是应该抛出 AOtherException。我们更正了这一点:

public class A {
  public int getInt() {
    if (..) {
      return 2;
    }
    else {
      throw new AOtherException();
    }
  }
}

我们现在更改了 A 的实现以抛出 AOtherException,然后我们运行所有的测试。他们通过。不好的是 B 的单元测试通过但错误。如果我们在这个阶段将 A 和 B 放在一起生产,B 将传播 AOtherException,因为它的实现认为 A 抛出 AException。

如果我们在 throwsBExceptionWhenFailsToReadInt 测试中使用 A 的真实实现,那么在更改 A 后它会失败,因为 B 不会再抛出 BException。

一个可怕的想法是,如果我们有上千个测试结构像上面的例子,并且我们改变了一点点,那么即使许多单元的行为是错误的,所有的单元测试仍然会运行!我可能遗漏了一些东西,我希望你们中的一些聪明人能告诉我它是什么。

【问题讨论】:

  • 测试代码也必须维护,这是不幸的事实
  • 是的。但在我看来,如果上述问题随时发生,你就没有真正的机会维护一个包含数百个测试的大型测试平台。你肯定不会记得在哪里更改测试代码,所以似乎必须有另一种方法来更容易地发现这种“错误类型”,因为这似乎是一个非常合理的场景。我觉得我错过了什么。我看不到的东西。也许是关于集成测试的东西?如果我们在 B 的测试中使用 A 的真实实现,我们会立即发现问题,正如我在帖子中提到的那样。
  • 如果你确实将这两个类结合在一起,并且它们之间做了很多逻辑,那么你可能会发现你的 uniut 测试很难编写,因为只是让所有东西都处于正确的状态真的很痛苦。事实上,您希望编写松散耦合的测试,以便依赖项的更改不会破坏您未测试的测试。您应该有一个测试来测试一件事,因此理论上您将只有一个测试来测试捕获和抛出该异常。如果您在整个应用程序中都存在这种行为变化,那么您希望所有这些测试都开始失败。

标签: unit-testing testing tdd integration-testing


【解决方案1】:

当你说

我们现在更改了 A 的实现以抛出 AOtherException,然后我们运行所有测试。他们通过。

我认为这是不正确的。您显然还没有实现您的单元测试,但是 B 类不会捕获 AException,因此不会抛出 BException,因为 AException 现在是 AOtherException。也许我遗漏了一些东西,但是您的单元测试不会在断言此时抛出 BException 时失败吗?您将需要更新您的类代码以适当地处理 AOtherException 的异常类型。

【讨论】:

  • 我明白为什么很难从伪代码中看出,但关键是 B 的测试使用了一个硬编码的存根来抛出 AException。在编写测试时这是合理的(A DID throw AException)。然后几年过去了,你忘记了考试。然后更改 A 的实现以引发另一个异常。显然,您不会更改 B 的测试,因为您无法记住它假设 A 抛出了 AException。所以最后的问题是,当我们为 B 编写测试时,我们对世界所做的假设在我们改变 A 时不再正确。
  • 他想说的是,如果您不更改 B 的实现但应该更改,那么这不是现有单元测试的错误(因为它们会通过)。您应该为行为变化编写一个新的测试用例,并尝试让它通过。在这样做时,您可能会更改 B 以捕获不同的异常。那时,您的遗留单元测试将无法提醒您您已经改变了行为。所以你必须决定新的行为是正确的还是现有的测试是正确的?然后你可以修复你的遗留单元测试。
  • 我应该如何知道要更改哪些实现?我正在寻找的是测试来告诉我我需要改变什么。如果您有数千个使用 A 的对象,那么您可能无法记住哪些需要更改,哪些不需要更改。我想这里的重点是,当您在一个测试(B 中)中对对象(A)的行为做出假设时,您应该在 A 中编写一个单元测试来测试这种行为是否真的发生了!这将解决问题,如果我们只有这样才能将 A 中现在失败的单元测试“链接”到 B 中应该失败的测试。问题是;我们可以这样做吗?
  • 你在这个例子中遇到的问题是你怎么知道链上的其他类也应该处理新的异常?单元测试失败是为了捕捉他们测试的类中的变化。如果你改变他们依赖的行为,那么你必须意识到这会产生深远的影响。如果您遵循 SOLID 原则,那么您所描述的情况应该很少发生。如果此更改归结为基本设计更改,那么您将不得不付出更多努力以确保所有部件都不会损坏。在这种情况下,单元测试不是灵丹妙药。
  • 我猜你是对的。理论上 A 是一个接口,它的规范会说 getInt() 应该抛出 ExceptionA。当我更改接口及其规范时,我应该知道这是一个严重的更改,并且可能应该检查 A 的所有用户及其测试并确保它们保持同步。使用现代 IDE 应该很容易找到使用 A 的人:)
【解决方案2】:

如果您更改class A的接口,那么您的存根代码将无法构建(我假设您对生产版本和存根版本使用相同的头文件)并且您会知道这一点。

但在这种情况下,您正在更改类的行为,因为异常类型实际上并不是接口的一部分。每当您更改类的行为时,您确实必须找到所有存根版本并检查您是否也需要更改它们的行为。

对于这个特定示例,我能想到的唯一解决方案是在头文件中使用#define 来定义异常类型。如果您需要将参数传递给异常的构造函数,这可能会变得混乱。

我使用的另一种技术(同样不适用于此特定示例)是从虚拟基类派生您的生产类和存根类。这将接口与实现分开,但如果您更改类的行为,您仍然必须查看这两个实现。

【讨论】:

  • 听起来您在谈论可以定义的语言 lige C++。在我的特殊情况下,上面的代码是 Java(可能不太清楚,抱歉)。无论如何,你有一个很好的观点,即在更改 A 时没有构建存根代码。但这并不完全正确。如果我使用像 jMock 或 Mockito 这样的模拟框架通过反射来存根接口,那么我不会注意到变化。问题是,我确实使用了这些框架。我觉得他们有很多优点,当然除此之外。
【解决方案3】:

您使用存根编写的测试不会失败是正常的,因为它旨在验证对象 B 与 A 通信良好并且可以处理来自 getInt() 的响应 假设 getInt() 抛出 AException。它不是旨在检查 getInt() 是否在任何时候真的抛出 AException。

您可以将您编写的那种测试称为“协作测试”。

现在您需要完成的是检查 getInt() 是否会首先抛出 AException(或 AOtherException,就此而言)的对应测试。这是一个“合同测试”。

J B Rainsberger 在合同和协作测试技术方面拥有出色的 presentation

使用这种技术,您通常会这样做,解决整个“假绿色测试”问题:

  1. 确定 getInt() 现在需要抛出 AOtherException 而不是 AException

  2. 编写合约测试,验证 getInt() 在给定情况下是否会引发 AOtherException

  3. 编写相应的生产代码,使测试通过

  4. 意识到您需要针对该合同测试进行协作测试:对于每个使用 getInt() 的协作者,它能否处理我们将要抛出的 AOtherException?

  5. 实施这些协作测试(假设您没有注意到此时已经有针对 AException 的协作测试检查)。

  6. 编写与测试匹配的生产代码,并意识到 B 在调用 getInt() 时已经期望 AException 而不是 AOtherException。

  7. 参考现有的协作测试,其中包含抛出 AException 的存根 A 并意识到它已过时,您需要将其删除。

如果你刚刚开始使用该技术,但假设你从一开始就采用了它,那么不会有任何真正的问题,因为你自然会更改 getInt() 的合同测试来实现它期待 AOtherException,并在此之后更改相应的协作测试(黄金法则是合同测试总是与协作测试一起进行,因此随着时间的推移,它变得很容易)。

如果我们改为使用 A 的真正实现作为我们的 throwsBExceptionWhenFailsToReadInt 测试,那么它会失败 在更改 A 之后,因为 B 不会再抛出 BException。

当然可以,但这完全是另一种测试——实际上是一种集成测试。集成测试验证硬币的两面:对象 B 是否正确处理来自对象 A 的响应 R,并且对象 A 是否曾经以这种方式响应?当测试中使用的 A 的实现开始响应 R' 而不是 R 时,这样的测试失败是正常的。

【讨论】:

    【解决方案4】:

    您提到的具体示例是一个棘手的示例.. 编译器无法捕获或通知您。在这种情况下,您必须努力查找所有用法并更新相应的测试。

    也就是说,这种类型的问题应该只是测试的一小部分——你不能仅仅为了这个极端案例就放弃好处。

    另请参阅:TDD how to handle a change in a mocked object - 在 testdrivendevelopment 论坛上有类似的讨论(链接在上面的问题中)。引用 Steve Freeman(以 GOOS 闻名,是基于交互的测试的支持者)

    这一切都是真的。在实践中,结合明智的 更高级别测试的组合,我还没有看到这是一个大的 问题。通常首先要处理更大的事情。

    【讨论】:

      【解决方案5】:

      古老的线程,我知道,但我想补充一点,JUnit 有一个非常方便的异常处理功能。不要在测试中执行 try/catch,而是告诉 JUnit 您希望类会抛出某个异常。

      @Test(expected=AOtherException)
      public void ensureCorrectExceptionForA {
          A a = new A();
          a.getInt();
      }
      

      将此扩展到您的 B 类,您可以省略一些 try/catch 并让框架检测异常的正确用法。

      【讨论】:

        猜你喜欢
        • 2022-08-07
        • 1970-01-01
        • 2021-09-10
        • 1970-01-01
        • 2015-01-16
        • 1970-01-01
        • 2014-07-22
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多