65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Mock 对象进行单元测试(Rhino Mocks)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (29投票s)

2013年11月24日

CPOL

10分钟阅读

viewsIcon

173753

downloadIcon

3218

一篇关于如何使用 Mock 对象 (Rhino Mocks) 编写单元测试的基本介绍。

引言

本文是一篇关于如何在单元测试中使用 Mock 对象以及有效使用 Mock 的主要构建块的简单介绍。

在本文中,我还解释了 Mock 对象和 Stub 对象之间的主要区别,这两者在讨论测试时经常被混淆。

有许多框架可用于在编写测试时创建 Mock 对象:有些是商业的,有些是免费的。我在本教程中使用的框架是 Rhino Mocks,一个相对较旧的开源框架,但对于当今使用 C# 和 .NET 编写的大多数单元测试来说仍然绝对有效。

背景

要阅读本文,您需要熟悉单元测试,并且需要了解 Mock 的基本概念。但是,如果您想更深入地了解本文简短介绍背后的 Mock 世界,我强烈建议您阅读 Martin Fowler 关于 Mocks & Stubs 的精彩文章Rhino Mocks 文档

使用代码

作为示例的代码应该非常容易理解。我编写了一个小型应用程序作为教学工具。我希望这在本文中会有效。

附加的解决方案包含两个项目:一个是 LiviosPizzeria,另一个是 LiviosPizzeria.Test。前者是待测试项目,后者包含与之相关的单元测试。
在主项目 LiviosPizzeria 中,您可以找到我们想要测试的主类。该类名为 PizzaMaker,提供一个将要测试的单一方法 MakePizza()

这基本上是一个假装准备披萨的方法,它从某个地方获取食材并烹饪。烹饪好的披萨将被返回……嗯!美味!

下面您可以看到此方法的主体

public Pizza MakePizza()
{
    Pizza cookedPizza = null;

    ReadyForMakePizza = false;

    var ingredients = _ingredientsProvider.GetIngredients();
    var rawPizza = _rawPizzaBuilder.CreatePizza(ingredients);

    _oven.Temperature = 300;
    cookedPizza = _oven.CookPizza(rawPizza);
    _oven.Temperature = 150;

    ReadyForMakePizza = true;
    return cookedPizza;
}

在文章的其余部分,我们将逐步创建此方法的一些单元测试,使用 Rhino Mocks 并尝试应用使用 Mock 对象时建议的所有最佳实践。

单元测试 #1:检查 PizzaMaker 的状态

要点:通过此测试,我们正在测试 PizzaMaker 类的**状态**。我们检查此类的状态在某些操作结束时是否符合我们的预期。

在我们的示例类中,PizzaMaker 暴露了一个名为 ReadyForAnotherPizza 的属性。
每次我们使用 PizzaMaker 烹饪披萨时,此属性的状态都会设置为 **false**。当披萨准备好时,该属性会重新设置为 **true**。

我们将测试当我们调用 MakePizza 方法时,在此调用结束时,ReadyForAnotherPizza 属性是否如预期设置为 true。

这就是测试

[TestMethod]
public void WhenMakePizzaEndsThenPizzaMakerIsReadyForAnotherPizza()
{
    // ARRANGE
    var stubIngredientsProvider = 
             MockRepository.GenerateStub<iingredientsprovider>();
    var stubRawPizzaBUilder = MockRepository.GenerateStub<irawpizzabuilder>();
    var stubOven = MockRepository.GenerateStub<ioven>();

    // ACT
    var sut = new PizzaMaker(stubIngredientsProvider, stubRawPizzaBUilder, stubOven);
    var pizza = sut.MakePizza();

    // ASSERT
    Assert.IsTrue(sut.ReadyForMakePizza);
}

我们的第一个 Mock 对象:GenerateStub 方法

这个第一个测试展示了任何 Mocking 框架中最重要的调用:创建基本的 Mock 对象。
在 Rhino Mocks 中,此调用是 GenerateStub,我们用它来生成所有与我们的*系统 under test*(代码中的 sut 变量)交互的对象。

此测试的方法是任何使用 Mock 对象的测试的基本方法

  • 我们实例化一个我们想要测试的类
  • 这个类有各种可以(例如)从构造函数中注入的依赖项。
  • 我们使用 Mock 框架来模拟这些依赖项,并测试我们类与它们之间的交互。

现在,如果您查看我们的 PizzaMaker 对象的构造函数,您可以看到它有一些对其他对象的依赖

public PizzaMaker(IIngredientsProvider ingredientsProvider, 
        IRawPizzaBuilder rawPizzaBuilder, IOven oven)
{
    _ingredientsProvider = ingredientsProvider;
    _rawPizzaBuilder = rawPizzaBuilder;
    _oven = oven;
    ReadyForMakePizza = true;
}

我们使用 GenerateStub 调用来模拟这些依赖项,并将我们刚刚创建的假对象传递给 PizzaMaker。我们创建的这些对象可以通过多种不同的方式“驱动”,但目前,在此测试中,它们仅用于允许我们的代码编译。此时,所有这些 Mock 对象在被调用时都将返回 **null**。然而,这对于我们正在编写的特定测试并不重要,因此**不需要**用超出必要的配置来使我们的测试过载。

实际上,真正的测试完全围绕着检查我们类的状态。在这个测试中,我们不关心(也不想关心)与其他对象的交互。这些问题不是我们测试的一部分,我们的测试必须保持简单并**专注于一个断言**。

尽可能为每个测试使用一个断言。

这是单元测试中的一条好规则。有时很容易快速编写针对我们待测试系统的四五个断言,以检查属性 A、B、C 以及(为什么不)D 是否都设置为我们想要的值。您应该避免这样做,因为这会降低测试的可维护性,因为具有多个断言的测试通常不能自解释其测试内容,并且会因许多不同的原因而失败。当您在两年左右后回来维护您的测试时,将更难理解测试失败的原因,特别是当多个断言“隐式”相互关联时。

单元测试 #2:检查 PizzaMaker 与其他类的交互

要点:通过此测试,我们现在正在测试我们期望此类的**行为**。我们检查该类是否以特定方式与其他对象交互。

在我们的 PizzaMaker 中,在将披萨提供给顾客之前烹饪披萨非常重要。我如何检查我是否正确地与烤箱交互并将披萨传给它?这正是 AssertWasCalled 方法的范围。

此功能在 Mocking 框架中始终存在(当然,名称不同),用于验证我们是否在我们的类中正确地与其他类交互。

让我们看看代码

[TestMethod]
public void WeNeverForgetToCookPizzaInTheOven()
{
    // ARRANGE
    var stubIngredientsProvider = 
             MockRepository.GenerateStub<iingredientsprovider>();
    var stubRawPizzaBUilder = MockRepository.GenerateStub<irawpizzabuilder>();
    var mockOven = MockRepository.GenerateStub<ioven>();
    
    mockOven.Stub(oven => oven.CookPizza(Arg<irawpizza>.Is.Anything));

    // ACT
    var sut = new PizzaMaker(stubIngredientsProvider, stubRawPizzaBUilder, mockOven);
    var pizza = sut.MakePizza();

    // ASSERT
    mockOven.AssertWasCalled(oven => oven.CookPizza(Arg<irawpizza>.Is.Anything));
}

Stub( X => ...) 和 AssertWasCalled。这是什么?

我们的测试中现在有两个新“朋友”。

第一个是:mockOven.Stub(oven => oven.CookPizza(Arg.Is.Anything));。通过这行代码,我们开始“教导”我们的 Mock 对象在被调用时如何反应。在这行特定的代码中,我们对我们的 Mock 烤箱说:“嘿,注意有人要用你!那个人会调用你的‘CookPizza’方法,并且可以使用任何参数来调用你。你不用管它。”

我们为什么要这样做?因为现在我们的 Mock 烤箱已经知道这个调用,我们可以**问它**这个调用是否真的发生了!然后我们可以测试每次我们准备披萨时,这个烤箱中是否真的有什么东西被烹饪。在这种情况下,我们说我们正在测试一种**行为**。

这正是我们用最后一行(第二个朋友所在的地方)验证的

mockOven.AssertWasCalled(oven => oven.CookPizza(Arg<IRawPizza>.Is.Anything))

如果在 PizzaMaker 中没有发生对烤箱的调用(也许开发人员在重构期间不小心删除了它),那么测试将失败。Mock 对象会抱怨说:“嘿!等一下!你告诉我应该有人调用我,但什么都没发生!”

再次,我不会详细探讨这段代码的语法,因为它因不同的 Mocking 框架而异,但我希望这个测试背后的概念在某种程度上是清晰的。

单元测试 #3:如何检查我们是否正确设置了另一个对象

在我们的披萨店里,我们有这样的要求:每次我们开始烹饪披萨时,烤箱的温度必须设置为 300 摄氏度。同时,当烹饪完成后,我们需要将烤箱的温度降至 150 摄氏度。这可以通过设置烤箱中的 Temperature 属性来完成。如何测试 PizzaMaker 是否正确执行了此操作?

同样,这是一个我们将要验证行为的测试。然而在这种情况下,使用 Rhino Mocks 时,您需要使用一个新的方法,一种不同的方法:MockRepository.GenerateMock(...)

这是因为,在前一个测试中我们验证了我们是否正在调用一个方法(AssertWasCalled 对此非常完美),而现在我们想要检查一个属性是否已按预期设置。

要**检查**属性是否在 Stub 对象中设置,**我们不能使用 Stub**。使用 Stub 我们无法验证属性设置。这就是使用 Rhino Mocks 时,我们被迫使用 GenerateMock 调用的时机。

[TestMethod]
public void PizzaMakerSetOvenToTheProperTemperature()
{
    // ARRANGE
    IIngredientsProvider stubIngredientsProvider = MockRepository.GenerateStub<iingredientsprovider>();
    IRawPizzaBuilder stubRawPizzaBUilder = MockRepository.GenerateStub<irawpizzabuilder>();

    // this won't work. We can't verify this call. 
    // If you comment out the property settings in the method under test, this test won't fail
    //IOven mockOven = MockRepository.GenerateStub<ioven>();
    //mockOven.Temperature = 300;
    //mockOven.Temperature = 150;

    //this is the proper way to check settings on property
    IOven mockOven = MockRepository.GenerateMock<ioven>();
    mockOven.Expect(oven => oven.Temperature = 300);
    mockOven.Expect(oven => oven.Temperature = 150);
    
    // ACT
    var sut = new PizzaMaker(stubIngredientsProvider, stubRawPizzaBUilder, mockOven);
    var pizza = sut.MakePizza();

    // ASSERT
    mockOven.VerifyAllExpectations();
}

请注意我在上面测试中注释掉的行,在“这将不起作用”的代码块中标记

// this won't work. We can't verify this call. If you comment out
// the property settings in the method under test, this test won't fail
//IOven mockOven = MockRepository.GenerateStub();
//mockOven.Temperature = 300;
//mockOven.Temperature = 150;

我将这部分代码与正确的代码并排放置,以向您展示它们之间的区别。使用“错误”的语法,您可以设置一个值,该值将从“IOven”对象的“Temperature”属性返回,但这与检查其他人是否将此属性设置为预期值完全不同。

在第一种情况下,我们正在**告诉**烤箱“请,每当有人询问你的温度值时,返回 300 摄氏度”。在第二种情况下,我们正在**询问**模拟烤箱“请,验证是否有人将烤箱中的温度属性设置为 300 摄氏度”。

单元测试 #4:StrictMock 的错误用法及其不被鼓励的原因

有一种特殊类型的 Mock 对象,称为“StrictMock”,它可能非常有用……但仅在极少数情况下。

这种对象的主要特点是,当对 Mock 对象进行调用而之前没有明确设置时,对其的 VerifyAllExpectation 将会失败。

换句话说,当我们声明并使用 StrictMock 对象,并在我们的单元测试中编写其期望时,我们隐含地声明**对这个 StrictMock 对象进行的任何未设置的调用都是一个错误**,并且如果发生一些意外的调用,“VerifyExpectation”将失败。

让我用一个例子来澄清这一点。这是一个使用 StrictMock 的单元测试,在我们的项目中是 #4。

[TestMethod]
public void WhyTheStrictMockIsGenerallyBad()
{
    //ARRANGE
    var stubIngredientsProvider = MockRepository.GenerateStub<iingredientsprovider>();
    var stubRawPizzaBuilder = MockRepository.GenerateStub<irawpizzabuilder>();

    var mockOven = MockRepository.GenerateStrictMock<ioven>(); //DISCOURAGED
    mockOven.Expect(oven => oven.Temperature = 300);
    mockOven.Expect(oven => oven.CookPizza(Arg<irawpizza>.Is.Anything));
    mockOven.Expect(oven => oven.Temperature = 150);
    
    // ACT
    var sut = new PizzaMaker(stubIngredientsProvider, stubRawPizzaBuilder, mockOven);
    sut.MakePizza();

    // ASSERT
    mockOven.VerifyAllExpectations();
}

这个测试想检查我们是否在使用烤箱前正确设置了温度,然后调用 CookPizza 方法。我认为这样的测试可维护性较差,因为我们在同一个测试中放置了太多的断言(验证温度和验证 CookPizza),甚至涉及不同的行为。但是,我们现在也可以容忍它,因为当我们时间紧迫时,我们都会在代码库中做一些可怕的事情……

使用 StrictMock 在此测试中需要注意的重要一点是,从现在开始,我们与此测试签订了一种抵押协议,我们将永远与它非常接近!从现在开始,我们对 PizzaMaker 中关于与烤箱交互的任何“更改”(即使只是添加一个新调用)都将导致此测试失败,即使我们的“被测试行为”没有改变。

再举一个例子:如果我的烤箱在某个时候推出了一个名为“AutoClean()”的新方法,可以在烹饪结束时调用,会发生什么?我们将在 PizzaMaker 中添加新的调用来实现这个令人兴奋的功能!哇,一个可以自我清洁的烤箱,太酷了!我们立刻添加它。

public Pizza MakePizza()
{
    Pizza cookedPizza = null;

    ReadyForMakePizza = false;

    var ingredients = _ingredientsProvider.GetIngredients();
    var rawPizza = _rawPizzaBuilder.CreatePizza(ingredients);

    _oven.Temperature = 300;
    cookedPizza = _oven.CookPizza(rawPizza);
    
    // A NEW FEATURE JUST INTRODUCED THAT WILL BREAK ANY UNIT TEST MADE WITH STRICT MOCK!
    _oven.AutoClean();

    ReadyForMakePizza = true;
    return cookedPizza;
}

瞧!新功能已添加。我们运行我们的单元测试套件……会发生什么?我们知道会发生什么:我们的测试 #4 将失败,因为我们没有将此调用包含在 IOven 对象的“预期”调用中,但实际上我们的测试中没有错误!被测试的行为(检查我们是否设置了温度,检查我们是否烹饪了披萨)是否发生了变化?

现在试想一下,如果您在整个代码库中广泛使用 StrictMock。每次您在代码中添加一些东西,都会有一些东西被破坏。总是这样。在这些场景中使用 StrictMock 只会使我们的测试套件更难维护!

这就是为什么在使用 StrictMock 时必须始终非常仔细考虑的原因。

历史

  • 2013-11-24:初版。
  • 2013-11-25:修复了一些格式问题。
© . All rights reserved.