测试:存根与实际实现

时间:2012-03-14 19:22:41

标签: unit-testing testing tdd integration-testing

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

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

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。

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

5 个答案:

答案 0 :(得分:2)

当你说

  

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

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

答案 1 :(得分:0)

如果您更改 A类的界面,那么您的存根代码将无法构建(我假设您对生产和存根版本使用相同的头文件),您将了解它。

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

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

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

答案 2 :(得分:0)

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

您可以将此类测试称为“协作测试”。

现在你需要完成的是对应的测试,它检查getInt()是否会首先抛出AException(或者AOtherException)。这是一次“合同测试”。

J B Rainsberger在合同和协作测试技术方面有很好的presentation

通过这种技术,您可以通常去解决整个“虚假绿色测试”问题:

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

  2. 编写合同测试,验证getInt()在给定情况下是否抛出AOtherException

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

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

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

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

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

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

      

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

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

答案 3 :(得分:0)

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

尽管如此,这类问题应该只是测试的一小部分 - 你不能仅仅为这个角落案例摆脱好处。

另见:TDD how to handle a change in a mocked object - 在testdrivendevelopment论坛上有类似的讨论(在上面的问题中链接)。引用史蒂夫弗里曼(GOOS成名和基于交互的测试的支持者)

  

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

答案 4 :(得分:0)

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

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

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