2012-03-14 77 views
2

我一直在想单元测试的存根的一般用法与使用真实(生产)的实现,特别是当我们在使用存根时没有碰到一个相当讨厌的问题,如下所示:测试:stub vs真正的实现

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

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() 
    } 
} 

现在假设我们在某些时候,当我们已经写了数百次试验更晚,意识到,真的不应该扔AException而是AOtherException。我们纠正这一点,

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

我们现在已经改变了的实施扔AOtherException然后我们运行我们的所有测试。他们通过。不好的是B的单元测试通过但错误。如果我们在此阶段将A和B放在生产中,则B将传播AOtherException,因为它的实现认为A引发AException。

如果我们改用A的throwsBExceptionWhenFailsToReadInt测试,那么它会在A发生变化后失败,因为B不会再抛出BException。

这只是一个可怕的想法,如果我们拥有的结构类似于上面的例子测试千元,我们改变了一个很小的事情,那么所有的单元测试仍然会运行,尽管许多单位的行为是错误的!我可能会错过一些东西,我希望你们中有些聪明的人能够启发我,知道它是什么。

+4

测试代码也必须保留,这是不幸的事实 – BrokenGlass 2012-03-14 19:24:29

+0

是真的。但是,正如我所看到的那样,如果上述问题随时出现,您就没有真正的机会维护一个带有数百个测试的大型测试平台。您当然不会记得更改测试代码的位置,因此似乎必须有另一种方式才能更轻松地发现此“错误类型”,因为它似乎是一个非常合理的场景。我觉得我错过了一些东西。我没看见的东西。关于集成测试的事情可能是?如果我们在B的测试中使用A的真实实现,我们会立即发现问题,正如我在帖子中提到的那样。 – johnrl 2012-03-14 19:39:57

+0

如果你将两个类连接起来,并且他们之间做了很多逻辑,那么你可能会发现你的uniut测试非常难以编写,因为只是让所有事情处于正确的状态是一种真正的痛苦。事实上,你希望编写松散耦合的测试,以便依赖项的更改不会中断未测试的测试。你应该有一个测试来测试一件事情,因此理论上你只有一个测试来测试捕捉并抛出异常。如果您在整个应用程序中发现行为错乱,那么您希望所有这些测试都开始失败。 – aqwert 2012-03-14 20:17:48

回答

2

当你说

我们现在已经改变了的实施扔AOtherException然后我们运行我们的所有测试。他们通过。

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

+0

我明白了为什么很难从伪代码中看到问题,但重点在于B的测试使用硬编码来引发异常的存根。测试写入时是合理的(A DID抛出AException)。然后几年过去了,你忘了测试的全部内容。然后您更改A的执行以引发另一个异常。显然你不会改变B的测试,因为你没有办法记住它假设A抛出了AException。所以最后问题是,当我们为B写测试时,我们对世界做出的假设在我们改变A时不再是真的了。 – johnrl 2012-03-14 20:04:09

+0

他想说的是,如果你不改变B,但应该有那并不是现有单元测试的错误(正如他们会通过的那样)。你应该为改变行为写一个新的测试用例,并试着让它通过。这样做可能会改变B来捕捉不同的异常。此时,您的传统单元测试将无法提醒您,您已改变行为。所以你必须决定新的行为是否正确或者现有的测试是否正确?然后你可以修复你的遗留单元测试。 – aqwert 2012-03-14 20:10:49

+1

我该如何知道要更改哪些实现?我正在寻找的是测试告诉我我需要改变什么。如果你有数千个使用A的对象,那么你不可能记住哪些需要改变,哪些不需要改变。我想这里的重点是,当你在一个测试中(B中)对一个对象(A)的行为进行假设时,你应该在A中编写一个单元测试来测试这种行为真的发生了!如果我们只有这样才能解决这个问题,那么可以将A中现在失败的单元测试与B中的应该失败的测试“联系起来”。我们可以这样做吗? – johnrl 2012-03-14 20:21:38

0

如果您更改A类的接口,那么您的存根代码将不会生成(我假设您对生产和存根版本使用相同的头文件),并且您将知道它。

但在这种情况下,您正在更改您的类的行为,因为异常类型不是界面的一部分。无论何时你改变你的课程的行为,你都必须找到所有的存根版本,并检查是否需要改变他们的行为。

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

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

+0

听起来像你正在谈论语言lige C++,其中定义可用。在我的特定情况下,上面的代码是Java(可能不那么明确)。无论如何,当改变A时,您对存根代码没有建立有一个好的观点。但这并不完全正确。如果我使用像jMock或Mockito这样的模拟框架来用反射存根界面,那么我不会注意到这个改变。问题是,我确实使用这些框架。除了这个,我觉得他们有很多优点。 – johnrl 2012-03-15 10:38:57

0

使用存根编写的测试不会失败,因为它旨在验证对象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()但不是AOtherException时已经期待AException。

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

这是,如果你开始使用该技术就在刚才,但假设你从一开始就采用了它,就不会有因为你会自然而然做的是改变getInt合同测试任何实际问题( )使它期望AOtherException,并在此之后更改相应的协作测试(黄金法则是合同测试总是与协作测试一起进行,所以随着时间的过去它变得毫不费力)。

如果我们不是用了真正实施A对我们的 throwsBExceptionWhenFailsToReadInt测试,那就有一个改变后失败 因为B没有扔BException了。

当然,不过这将是一个整体的其他类型的测试-an集成测试,实际上。集成测试验证硬币的两面:对象B是否正确处理来自对象A的响应R,确实对象A是否曾经以这种方式回应?当测试中使用的A开始响应R而不是R时,这样的测试失败是正常的。

0

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

也就是说,这种类型的问题应该只是测试的一小部分 - 您不能摆脱这种情况下的好处。

另请参阅:TDD how to handle a change in a mocked object - 关于testdriven开发论坛(在上述问题中链接)有类似的讨论。引用Steve Freeman(GOOS名声和基于交互的测试的支持者)

所有这些都是真实的。在实践中,加上一个明智的 组合更高水平的测试,我还没有看到这是一个大的 问题。首先通常有更大的事情需要处理。

0

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

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

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