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






4.87/5 (29投票s)
一篇关于如何使用 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
。通过这行代码,我们开始“教导”我们的 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:修复了一些格式问题。