【问题标题】:Value of Behavior Verification行为验证的价值
【发布时间】:2011-12-27 17:48:37
【问题描述】:

我一直在阅读(并尝试使用)几个 Java 模拟 API,例如 Mockito、EasyMock、JMock 和 PowerMock。我喜欢他们每个人都有不同的原因,但最终还是选择了 Mockito。 请注意,这不是关于使用哪个框架的问题 - 这个问题确实适用于 任何 模拟框架,尽管解决方案将看起来不同,因为 API (显然)不同。

与许多事情一样,您阅读教程、遵循示例并在沙盒项目中修改一些代码示例。但是,当到了实际使用这个东西的时候,你会开始窒息——这就是我的处境。

真的,真的很喜欢嘲笑的想法。是的,我知道关于嘲笑导致“脆弱”测试与被测类过度耦合的抱怨。但在我自己意识到这一点之前,我真的很想给 mocking 一个机会,看看它是否可以为我的单元测试增加一些好的价值。

我现在正尝试在我的单元测试中积极使用模拟。 Mockito 允许存根和模拟。假设我们有一个 Car 对象,它有一个 getMaxSpeed() 方法。在 Mockito 中,我们可以像这样对它进行存根:

Car mockCar = mock(Car.class);
when(mockCar.getMaxSpeed()).thenReturn(100.0);

这会“存根”Car 对象以始终返回 100.0 作为我们汽车的最大速度。

我的问题是,在编写了一些单元测试之后......我所做的只是给我的合作者打桩!我没有使用任何可用的模拟方法(verify 等)!

我意识到我陷入了一种“stubbing 的心态”,而且我发现它不可能打破。所有这些阅读内容,以及在我的单元测试中使用模拟所带来的所有这些兴奋......我想不出一个用于行为验证的用例。

所以我备份并重新阅读了 Fowler 的 article 和其他 BDD 风格的文献,但我仍然“没有得到”测试双重合作者行为验证的价值。

知道我错过了一些东西,我只是不确定是什么。有人可以给我一个具体的例子(甚至是一组例子!),比如使用 Car 类,并演示何时行为验证单元测试有利于状态验证测试?

提前感谢您在正确方向上的任何推动!

【问题讨论】:

  • 你能给我们看一门你的课吗?例如,上面被存根的 Car 的客户端。那么也许我们可以建议如何使用模拟来测试它。
  • 嗨,Tom - 我更喜欢 JB Nizet 的例子,而不是 Car。该示例以及 JB 的解释确实强调了我困惑的根源。我在 JB 回复下方的评论解释了这一点。

标签: java mocking bdd mockito


【解决方案1】:

好吧,如果被测对象调用具有计算值的协作者,并且测试应该测试计算是否正确,那么验证模拟协作者是正确的做法。示例:

private ResultDisplayer resultDisplayer;

public void add(int a, int b) {
    int sum = a + b; // trivial example, but the computation might be more complex
    displayer.display(sum);
}

显然,在这种情况下,您必须模拟显示器,并验证它的显示方法是否已被调用,如果 2 和 3 是 add 方法的参数,则值为 5。

如果您对协作者所做的只是调用不带参数的 getter,或者使用作为测试方法的直接输入的参数,那么存根可能就足够了,除非代码可能从两个不同的协作者那里获取值并且您想要验证已调用适当的协作者。

例子:

private Computer noTaxComputer;
private Computer taxComputer;

public BigDecimal computePrice(Client c, ShoppingCart cart) {
    if (client.isSubjectToTaxes()) {
        return taxComputer.compute(cart);
    }
    else {
        return noTaxComputer.compute(cart);
    }
}

【讨论】:

  • 谢谢 JB!我想我们现在是我困惑的根源。在您的第二段中,您声明“...您必须模拟显示器,并验证它的显示方法是否已被调用,值为 5...”我理解为什么我们需要验证显示器正在显示值 5...我理解的是为什么我们必须验证 displayer.display 甚至被调用了!为什么我们不能只写一个 assertEquals(displayer.getDisplay(), 5) 的 JUnit 断言?我只是不明白为什么检查执行的display(int) 方法会为单元测试增加价值。有什么想法/反驳吗?谢谢!
  • 因为显示器的显示方法可以做一些复杂得多的事情,而不仅仅是将值存储在通过 getter 可访问的字段中。它可以将它发送到一个流、一个 LCD 显示器、某个数据库等。即使它确实将它存储在一个字段中,在测试环境中构建真正的显示器可能很困难,因为它依赖于一个数据库、一个 JMS 引擎或其他复杂的基础设施。
  • 在方法中也可以多次调用displayer:一次用2,一次用3,一次用5。如果要验证每个中间状态,除了模拟displayer别无选择.
  • 还要注意,如果你模拟显示器,除了验证每个中间状态之外,你别无选择。这就是为什么基于模拟的测试并不总是您想要的原因之一。
【解决方案2】:

我喜欢@JB Nizet 的回答,但这是另一个例子。假设您想在进行一些更改后使用 Hibernate 将 Car 持久化到数据库中。所以你有一个这样的类:

public class CarController {

  private HibernateTemplate hibernateTemplate;

  public void setHibernateTemplate(HibernateTemplate hibernateTemplate) {
    this.hibernateTemplate = hibernateTemplate;
  }

  public void accelerate(Car car, double mph) {
    car.setCurrentSpeed(car.getCurrentSpeed() + mph);
    hibernateTemplate.update(car);
  }
}

要测试加速方法,您可以只使用存根,但不会进行竞争测试。

public class CarControllerTest {
  @Mock
  private HibernateTemplate mockHibernateTemplate;
  @InjectMocks
  private CarController controllerUT;

  @Test
  public void testAccelerate() {
    Car car = new Car();
    car.setCurrentSpeed(10.0);
    controllerUT.accelerate(car, 2.5);
    assertThat(car.getCurrentSpeed(), is(12.5));
  }
}

该测试通过并确实检查了计算,但我们不知道是否保存了汽车的新速度。为此,我们需要添加:

  verify(hibernateTemplate).update(car);

现在,假设如果您尝试加速超过最大速度,您预计不会发生加速和更新。在这种情况下,你会想要:

@Test
public void testAcceleratePastMaxSpeed() {
  Car car = new Car();
  car.setMaxSpeed(20.0);
  car.setCurrentSpeed(10.0);
  controllerUT.accelerate(car, 12.5);
  assertThat(car.getCurrentSpeed(), is(10.0));
  verify(mockHibernateTemplate, never()).update(car);
}

这个测试不会通过我们当前的 CarController 实现,但它不应该。它表明您需要做更多的工作来支持这种情况,并且要求之一是在这种情况下您不要尝试写入数据库。

基本上,verify 应该准确地用于它听起来的样子 - 验证某事发生了(或没有发生)。如果它发生或没有发生的事实并不是你真正想要测试的,那么跳过它。以我做的第二个例子为例。有人可能会争辩说,由于值没有改变,更新是否被调用并不重要。在这种情况下,您可以跳过第二个示例中的验证步骤,因为无论哪种方式,accelerate 的实现都是正确的。

我希望这听起来不像是我在为大量使用验证做辩护。它会使您的测试变得非常脆弱。但它也可以“验证”应该发生的重要事情确实发生了。

【讨论】:

    【解决方案3】:

    我对此的看法是每个测试用例都应该包含任何一个

    • 存根,加上一个或多个asserts OR
    • 一个或多个verifys。

    但不是两者兼而有之。

    在我看来,在大多数测试类中,您最终会得到“存根和断言”测试用例和“验证”测试用例的混合。测试用例是执行“存根和断言”还是执行“验证”取决于协作者返回的值对测试是否重要。我需要两个例子来说明这一点。

    假设我有一个Investment 类,它的价值以美元为单位。它的构造函数设置初始值。它有一个addGold 方法,该方法将Investment 的价值增加黄金数量乘以每盎司黄金价格的数量。我有一个名为PriceCalculator 的合作者计算黄金价格。我可能会写一个这样的测试。

    public void addGoldIncreasesInvestmentValueByPriceTimesAmount(){
       PriceCalculator mockCalculator = mock( PriceCalculator.class );
       when( mockCalculator.getGoldPrice()).thenReturn( new BigDecimal( 400 ));
       Investment toTest = new Investment( new BigDecimal( 10000 ));
       toTest.addGold( 5 );
       assertEquals( new BigDecimal( 12000 ), toTest.getValue());
    }
    

    在这种情况下,协作者方法的结果对测试很重要。我们将其存根,因为此时我们没有测试PriceCalculator。无需验证,因为如果没有调用该方法,则投资值的最终值是不正确的。所以我们只需要assert

    现在,假设要求 Investment 类在任何人从 Investment 提取超过 100000 美元时通知 IRS。它使用名为IrsNotifier 的合作者来执行此操作。因此,对此的测试可能如下所示。

    public void largeWithdrawalNotifiesIRS(){
       IrsNotifier mockNotifier = mock( IrsNotifier.class );
       Investment toTest = new Investment( new BigDecimal( 200000 ));
       toTest.setIrsNotifier( mockNotifier );
       toTest.withdraw( 150000 );
       verify( mockNotifier ).notifyIRS();
    }
    

    在这种情况下,测试不关心协作者方法notifyIRS() 的返回值。或者它可能是无效的。重要的是该方法被调用了。对于这样的测试,您将使用verify。像这样的测试中可能存在存根(设置其他协作者,或从不同方法返回值),但您不太可能想要存根您验证的相同方法。

    如果您发现自己在同一个协作者方法上同时使用存根和验证,您可能应该问自己为什么。测试真正想证明什么?返回值对测试有影响吗?因为这通常是测试代码的味道。

    希望这些示例对您有所帮助。

    【讨论】:

      猜你喜欢
      • 2016-05-30
      • 1970-01-01
      • 2017-11-14
      • 1970-01-01
      • 1970-01-01
      • 2016-05-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多