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

介绍 Rhino Mocks

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.66/5 (12投票s)

2005年6月18日

14分钟阅读

viewsIcon

126518

一个灵活的模拟对象框架和关于模拟的通用讨论。

Rhino Mocks 主页

您可以通过我的博客联系我。

许可:Rhino Mocks 是开源软件,并在 BSD 许可下发布

Rhino Mocks

Rhino Mocks 是一个用于 .Net 平台的动态模拟对象框架。它的目的是通过允许开发人员创建自定义对象的模拟实现并使用单元测试验证交互来简化测试。

“我们模仿,我们效仿,我们嘲弄。”
        ~劳伦斯·奥利维尔,爵士语录

什么是模拟对象?模拟对象
模拟对象将存根发挥到极致,用于设置和验证与模型代码的交互期望。我们发现这种单元测试方法具有使您的代码更符合迪米特定律的副作用。

对模拟对象的需求
一个模拟对象

  1. 易于创建
  2. 易于设置
  3. 快速
  4. 确定性
  5. 具有易于触发的行为
  6. 没有直接用户界面
  7. 可直接查询

如果满足以下条件,您可能希望使用模拟对象:

  1. 真实对象具有非确定性行为
  2. 真实对象难以设置
  3. 真实对象具有难以触发的行为(例如,网络错误)(类似于 1)
  4. 真实对象速度慢
  5. 真实对象具有(或就是)UI
  6. 测试需要查询对象,但真实对象中没有可用的查询(例如,“此回调是否被调用?”)(这需要一个具有比真实对象*更多*内容的模拟对象;其他情况通常需要一个比真实对象小得多的存根)。
  7. 真实对象大部分时间行为“正常”,但每月一次(甚至更不频繁)会做一些“异常”的事情。我们希望单元测试确保系统的其余部分无论对象是“正常”还是“异常”都执行正确的事情。(这与 #2 相同吗?)
  8. 真实对象尚未存在

模拟对象用于单元测试中以隔离测试。模拟对象允许您只测试特定的类/方法,而无需测试(以及设置、拆除等)整个环境。以下是使用模拟对象的一个简单示例

假设我们有以下接口

 

public interface ICarRental
{
    ICar[] FindCarsForMaxPrice(Money maxPrice);
    void RentCar(ICar carToRent, IPayable payment);
    void ReturnCar(ICar returnedCar);
}

我想编写一个测试,确保损坏的汽车未经采取任何措施不能归还。为了讨论目的,在这种情况下我想抛出 DamagedCarException()。这是我想写的测试,但我有一些问题。

[测试]
[ExpectedException(typeof(DamagedCarException),"汽车损坏!需要更多钱!")]
public void DamagedCarReturnedWouldThrowException()
{
    ICar rentedCar = // 我如何创建这个类?
    ICarRental avis= new AvidCarRental();
    // 我如何让 IsDamaged 属性返回 true?
    // rentedCar.DoDamage() -- 没有这样的方法,我不想添加一个。        
    avid.ReturnCar(rentedCar);
}


我将如何测试它?创建汽车的真实实例需要数据库访问,所以虽然我*可以*这样做,但我想要更好的东西。我不想每次运行单元测试时都访问数据库。我一直保持警惕,类之间通过接口进行通信,这意味着我可以创建一个新的类 MockedCar,这样我就可以创建它的实例,并且当调用 IsDamaged 属性时它将返回 true。这里只有一个问题,ICar 接口包含 40 个方法和 60 个属性,处理我需要的所有汽车细节(HasRadio、GasTankCapacity、Play Radio()、PreventAccident() 等),为了创建一个小测试,这是相当多的工作。但我很勤奋,我想为我所有的代码编写测试。所以这是我手动编写 MockedCar 类后的测试

[测试]
[ExpectedException(typeof(DamagedCarException),"汽车损坏!需要更多钱!")]
public void DamagedCarReturnedWouldThrowException()
{
 ICar rentedCar = new MockedDamagedCar();
 ICarRental avis= new AvisCarRental(); 
 avis.ReturnCar(rentedCar);
}

嗯,测试本身很容易阅读,但编写起来并不容易。我为这个测试付出了太多努力。经验告诉行业,不简单的东西根本就不会。所以这次我勤奋了,但下一次我会吗?如果为这*一点点*功能编写一个测试需要我半小时的苦力活,也许它就*没那么*重要了。我可以跳过这个去编写*重要*的代码。

 

这就是模拟对象框架登场的时刻。

 

“曾几何时,”假海龟终于深深地叹了口气说,“我是一只真正的海龟。”
    ~刘易斯·卡罗尔,《爱丽丝梦游仙境》

 

模拟对象框架

模拟对象框架是一个工具,通过为您完成所有繁琐的工作来简化测试。有几个用于 .Net 和 Java 的模拟对象框架。可能是 .Net 中创建模拟对象最常用的框架是 NMock,它是 Java jMock 的 .Net 移植。上面的测试使用 NMock 将这样编写

[测试]
[ExpectedException(typeof(DamagedCarException),"汽车损坏!需要更多钱!")]
public void DamagedCarReturnedWouldThrowException()
{
 DynamicMock mockedCar = new DynamicMock(typeof(ICar));
 mockedCar.ExpectAndReturn(
"IsDamaged",true);
 ICarRental avis= new AvisCarRental(); 
 avis.ReturnCar((ICar)mockedCar.MockInstance);
}

简单、强大,而且直截了当,不是吗?

当你超越这些简单的测试时,问题就开始了。让我们将示例进一步推进,现在我们正在测试当汽车完好归还时,租车公司能否收到钱。这是使用 NMock 的测试

[测试]
public void RenterPaysForCar()
{
 DynamicMock mockedCar = new DynamicMock(typeof(ICar));
 DynamicMock mockPerson = new DynamicMock(typeof(IPerson));
 mockCar.SetupResult("CurrentPrice",115);
 mockCar.SetupResult("RentedFrom",DateTime.Now.AddDays(-7));
 mockCar.SetupResult("RentedBy",mockPerson.MockInstance);
 mockCar.SetupResult("ReturnToParkingLot",new IsTypeOf(int),new IsAnything());
 ICarRental avis= new AvisCarRental(); 
 mockPerson.Expect("Pay",avis,805);
 avis.ReturnCar((ICar)mockedCar.MockInstance);
 mockPerson.Verify();
 mockCar.Verify();
}

上面的测试创建了一辆模拟汽车,告诉它返回其当前价格和租用时间。租车人也被模拟了,并期望支付租车费用。(SetupResult() 是一种在不为其创建期望的情况下返回值的方法,稍后会详细介绍)。现在我们可以看到 NMock 的一些问题,看看 ReturnToParkingLot 方法,IsTypeOf() 和 IsAnything() 是约束(NMock 非常强大的一部分)。我没有用它们来做任何事情,我必须把它们放在那里,这样 NMock 才会识别该方法存在(否则它会尝试查找没有参数的方法并在运行时失败)。

NMock 的另一个问题是它不适合重构。这意味着如果我决定将 RentedBy 属性重命名为 Renter,我很可能会错过测试,并且在运行时之前不会收到任何警告。

最近一次正是这种情况的经历促使我编写了 Rhino Mocks,但我跳得太远了。我刚刚意识到另一个 .Net 模拟框架(有几个,其中大多数以相同的方法工作,并具有相同的弱点)。

NMock2 尚未正式发布,但可从 CVS http://nmock.sf.net 获取。

 

以下是 NMock2 在上述测试中的用法示例

 [测试]
public void RenterPaysForCar ()
{
  Mockery mocker = new Mockery();
  ICar car = mocker.NewMock(
typeof(ICar)) as ICar;
  IPerson person = mocker.NewMock(
typeof(IPerson)) as IPerson;
  Stub.On(car).GetProperty(
"CurrentPrice").Will(Return.Value(115));
  Stub.On(car).GetProperty(
"RentedFrom").Will(Return.Value(DateTime.Now.AddDays(-7));
  Stub.On(car).GetProperty(
"RentedBy").Will(Return.Value(person));
  Stub.On(car).Method(
"ReturnToParkingLot");
  
ICarRental avis= new AvidCarRental(); 
  Expect.On(person).Method("Pay").With(avis,805);
  avid.ReturnCar((ICar)mockedCar.MockInstance);
  mocker.VerifyAllExpectationsHaveBeenMet();
 }

这是一种不同的方法,读起来很像书面语言。

请注意,我不需要指定空约束即可使其工作;它现在足够智能,可以识别我只是不关心参数,并且只验证我收到了调用。(关于重载调用有一些细微之处,但它们是次要的。)

 

一个仍然存在的弱点是它在重构时仍然会中断。将自己的逻辑注入模拟对象也仍然很困难[2]

 

 

“他们以空闲的状态嘲弄空气。”
     ~托马斯·格雷语录(英国诗人,1716-1771)

模拟的好处

  • 公认的测试模式
  • 鼓励将对象作为方法参数传递,而不是将对它们的引用作为对象状态的一部分。
  • 模拟对象倾向于将您的类推向高度组合的设计,包含许多小部件和大量接口表面。
  • 鼓励轻量级类和更灵活的方法。

EasyMock 和 EasyMock.Net

EasyMock 是最早出现在 Java 上的模拟对象框架之一,而 EasyMock.Net 是直接移植到 .Net 的版本。这些框架使用显式的录制与回放模型[3]来设置和验证测试。

它们在录制和回放阶段都使用强类型对象,因此可以轻松安全地支持重构工具。但是,它们在接受期望的方式上非常僵化。

 

以下是使用 EasyMock.Net 的上述测试方法

[测试]
public void RenterPaysForCar ()
{
  MockControl controlCar = MockControl.CreateControl(
typeof(ICar));
  MockControl controlPerson = MockControl.CreateControl(
typeof(IPerson));
  ICar car = controlCar.GetMock() as ICar;
  IPerson person = controlPerson.GetMock() as IPerson;
  controlCar.ExpectAndReturn(car.CurrentPrice, 115);
  controlCar.ExpectAndReturn(car.RentedFrom,DateTime.Now.AddDays(-7));

  controlCar.ExpectAndReturn(car.RentedBy,person);
  car.ReturnToParkingLot(null);
  controlCar.SetMatcher(MockControl.ALWAYS_MATCH);
  ICarRental avis= new AvisCarRental(); 
  person.Pay(avis,850);
 controlPerson.Replay();
  controlCar.Replay();

  avis.ReturnCar((car);
  controlCar.Verify();
  controlPerson.Verify();
 }

从这个小示例中很难看出,但移植到 .Net 后没有包含 .Net 风格指南,这导致在使用此库时感觉非常陌生。此外,无法对方法表达灵活的约束或注入您自己的逻辑。

 

“被嘲笑和惊讶。”
     ~亨利六世第三部,第五幕第四场,威廉·莎士比亚

Rhino Mocks 的案例

所以有所有这些框架,它们有它们的缺点,但生活中的大多数事物也如此,为什么我们还需要另一个框架呢?

原因很简单,我不赞同NIH;至少通常不是。

我的测试中的模拟对象有几个问题

当我重构应用程序时,它们多次中断。如果它们不在运行时中断,我不会介意。[并且出现了一连串的错误,让我觉得我需要恢复那周的所有工作。]

我不得不采取黑客手段从模拟方法中获取回调。这不是一个很好的模拟方法,但我别无选择,我必须测试一些线程调用,这意味着需要从模拟方法中回调。[4]

我被不必要的约束传递所困扰,我必须这样做才能让 NMock 识别正确的方法签名。

我想要一个能够适应我的重构、能够轻松注入我自己的验证并足够智能地在我出错时发出提示的模拟框架。

 

没有这样的框架,但 EasyMock.Net 很接近。它具有使用强类型对象来记录期望的基本能力。它所缺乏的是灵活的验证条件和将自定义代码注入验证的能力。另一个大问题是它仍然是一个 Java 库,但在 .Net 上,整个 API 使用诸如 SetVoidMethod()、SetReturnValue() 等概念。

 

我拿了 EasyMock.Net 并开始修改它。第一件事是给它一个 .Net 接口,这进展得很顺利,另外两个目标在三天内完成(有一些障碍,但没有什么我处理不了的)。

 

Rhino Mocks 对象允许设置期望约束回调,指定被测对象应该如何与模拟对象交互。这些条件在整个测试运行过程中进行评估,并提前失败,以便为您提供尽可能本地化的警告。

 

Rhino.Mocks 特性

  • 支持多种在模拟对象上放置条件的模型,所有这些模型都在测试执行期间实时验证。这允许测试对象之间的交互,并在测试偏离指定条件时立即失败。这样做的好处是提供精确的失败点。
  • 对象的模拟实现是即时生成的,避免了构建过程中的代码生成。
  • 清晰的错误消息,用于失败的条件。
  • 使用期望约束回调指定条件的灵活方式,这些方式是类型安全的,并且可以通过当前工具轻松重构。

Rhino.Mocks 局限性

  • 目前仅适用于接口和继承自 MarshalByRef 的类。

使用示例

以下是 Rhino Mocks 如何处理之前的测试

[测试]
public void RenterPaysForCar ()
{
  MockControl mockCar= MockControl.CreateControl(typeof(ICar));
  MockControl mockPerson = MockControl.CreateControl(typeof(IPerson));
  ICar car = controlCar.GetMock() as ICar;
  IPerson person = controlPerson.GetMock() as IPerson;
  mockCar.ExpectAndReturn(car.CurrentPrice, 115);
  mockCar.ExpectAndReturn(car.RentedFrom,DateTime.Now.AddDays(-7));
  mockCar.ExpectAndReturn(car.RentedBy,person);
  ICarRental avis= new AvisCarRental(); 
  mockCar.SetupResult(car.ReturnToParkingLot(avis))
  mockCar.Callback = new 
            CarRentalDelegate(InformReachedParkingLot);
  person.Pay(avis,850);
  avis.ReturnCar(car);
  controlCar.Verify();
  controlPerson.Verify();
 }

delegate void CarRentalDelegatE(ICarRental carRental);
void InformReachedParkingLot(ICarRental carRental)
{
//assumes that car is an instance variable, not a local variable in the test
  carRental.CarInParkingLot(car);
}

在这种情况下,差异不大,那是因为我们使用的是期望),与 EasyMock.Net 的工作方式相同。让我们尝试一些更困难的事情,假设当汽车租赁公司将汽车送往停车场时,他们要求汽车在放行租车人之前告知已安全抵达。本质上,这是一个经典的线程问题,但使用 NMock 很难解决。我创建了几个 hack 来解决。通常,解决这类问题的方法是编写自己的测试类。这对于大量类来说不太实用[5],以下是我使用 Rhino Mocks 解决此问题的方法,我将让您使用其他框架实现相同的功能。

您可以看到我们为 ReturnToParkingLot 设置了一个回调,并使用该回调获取了我们得到的 ICarRental 引用。因此我们可以调用 CarInParkingLot() 并从 ReturnCar() 方法返回。

指定条件的另一种方法是使用约束,它们的用法与 NMock 中的用法大致相同。

car.AddGas(0);
mockCar.Constraints(new IsEqualOrLessThan(150));

最后一个方法概念

Rhino.Mocks 中的大多数操作都在最后一个方法调用上完成,在每次调用之后,您可以设置更多关于调用的信息:返回值、是否以及抛出什么、约束和回调。您可以通过调用 MockControl 上的方法(Returns、Throws、Constraints 和 Callback)来设置这些条件。大多数方法都有重载,允许您为同一方法的多次调用设置返回或抛出。Rhino.Mocks 确保在记录下一个方法之前满足方法的所有要求,这意味着对于返回值的方法,需要设置返回值或要抛出的异常。您可以为一个方法设置多种行为。对于 Rhino.Mocks 来说,相同的方法但带有不同的参数意味着不同的方法调用。

返回值

返回值的方法*必须*定义 Returns 或 Throws 或其等效方法(ReturnsFor()、ThrowsFor()、AlwaysReturns、AlwaysThrow、SetupResult() 或 ExpectAndReturn())。

提示与技巧

  • 如果您只想要一个存根,可以使用 MockControl.CreateNiceControl(),它不会在意外调用时抛出异常,并且总是返回默认值。(通常是 null 或零)。
  • 如果您想检查调用的顺序,请使用 MockContrl.CreateStrictControl()。
  • 要模拟 get 属性,您需要使用以下类似的代码,使用便利方法会更容易、更具可读性(参见单独的部分);

object dummy = someObject.MyProperty;

回调

回调需要与最后一个方法签名匹配,但可以自由地具有不同的返回类型(当然,也可以没有返回类型)。回调的返回值被*忽略*,所以不要期望从回调中返回某个值并从模拟方法中获取它。这是设计使然,旨在减少回调被滥用的机会。

模拟类

Rhino.Mocks *可以*模拟继承自 MarhsalByRef 的类,但不推荐这种方法。强烈建议使用接口。

陷阱

  • 如果方法调用是具有*完全相同*参数的相同方法,则认为它们相等。这意味着 something("foo") 和 something("bar") *不*被认为是相同的方法。
  • 验证方法调用和*期望*方法调用是两个不同的主题。仅当调用被期望时,才验证调用(使用期望、约束或回调)。您可以使用 ReturnsFor()、CalledFor() 和 ThrowsFor() 方法设置期望调用的次数。
  • 回调将针对方法的每次调用而被调用,并且*所有*验证都依赖于回调是否抛出异常。如果回调不抛出异常,则该方法被认为是经过验证的。
  • 回调的返回值被忽略,设置返回值的唯一方法是使用 Returns 派生方法或其便利方法。
  • 别忘了调用 Replay() 方法

 



[1] 以色列前总理伊扎克·拉宾的名言

[2] 不过,我还没有测试过这个。

[3] 其他框架也使用相同的模型,只是隐式地

[4] 是的,我可以为它构建自己的测试类,但在我看来,对于一行代码来说,这有点极端。

[5] 我猜这是一个有争议的观点

© . All rights reserved.