玩转 Objective-C 的 Mock 对象

测试驱动开发(TDD)中,开发者常用模拟对象进行系统设计,模拟对象究竟是什么呢?部分模拟对象和所有模拟对象又是什么呢?模拟对象真的让人又爱又恨吗?让咱们以Objective-C测试框架OCMock来探个究竟。app

模拟对象设计

模拟对象能够解决两种问题。第一种是(它们也是所以而提出的)用于设计测试驱动开发的测试类。想象一下,你已经完成了第一个测试,并知道了一些关于第一个类的API的信息。你的测试调用了新类的方法,你知道,应该从它们协做者之一种抓取一些信息。问题是,协做者尚不存在,而你又不想放弃这个已经设计出来的并开始测试的类。框架

此时,你能够建立一个模拟对象表明这个还没有“出生”的协做者。你能够设定你想要经过该“协做者”测试调用对象的指望值,并且,若是须要的话,还能够返回一个能够测试控制的值。你的测试能够验证你所指望调用的方法是否真的被调用了,若是没有,则测试失败。函数

在这种状况下,模拟对象就像一台VCR,只是没有上世纪八十年代的矮胖的造型和易受损的磁带。测试期间,模拟对象会记录你发送给它的每一条消息。而后,能够经过重放与消息列表作比较来看是否是你所须要的。就像用VCR,若是你想要看的是小精灵2(Gremlins 2),可是记录的倒是上半年的新闻和欢乐酒店(Cheers),这就让人较为失望。测试

关键的部分是,你实际上并不须要创建真正的协做对象。事实上,你彻底不须要关心它是怎么实施的。惟一须要关注的是它须要返回的消息,这样就能够验证他们是否被发送了。实际上,模拟对象可让你以为说,“我知道,在某些时候,我会考虑这一点,但我不但愿所以而分心。” 对于测试驱动开发者,这就像一个待办事项清单同样清晰。fetch

让咱们来看一个例子。假设书呆子Ranch发现了市场上对博物馆库存管理App的需求。一般博物馆收藏了大量的文物,他们须要了解全部的库存,并能按主题,国家,年代等在画廊组织展览。关于库存的需求相似以下:编码

做为策展人,我想知道全部须要展出的文物,这样我就能够给个人游客们讲故事了。翻译

我会写一个能够提供一个全部文物的清单的库存类用来测试。固然,磁盘上还有其余类也存储了全部的文物,可是我不关心他们是如何工做的,我只要建立一个库存接口的模拟对象。个人测试类以下:设计

@implementation BNRMuseumInventoryTests

- (void)testArtefactsAreRetrievedFromTheStore

{

    //Assemble

    id store = [OCMockObject mockForProtocol:@protocol(BNRInventoryStore)];

    BNRMuseumInventory *inventory = [[BNRMuseumInventory alloc] initWithStore:store];

    NSArray *expectedArtefacts = @[@"An artefact"];

    [[[store expect] andReturn:expectedArtefacts] fetchAllArtefacts];

    //Act

    NSArray *allArtefacts = [inventory allArtefacts];

    //Assert

    XCTAssertEqualObjects(allArtefacts, expectedArtefacts);

    [store verify];

}

@end

为了让这个类编译经过,我须要建立BNRMuseumInventory类和它的initWithStore:和allArtefacts方法。代理

@interface BNRMuseumInventory : NSObject

- (id)initWithStore:(id <BNRInventoryStore>)store;

- (NSArray *)allArtefacts;

@end

@implementation BNRMuseumInventory

- (id)initWithStore:(id <BNRInventoryStore>)store

{

return nil;

}

- (NSArray *)allArtefacts

{

return nil;

}

@end

我还要定义BNRInventoryStore协议及其-fetchAllArtefacts方法,但我如今还不须要实现它们。为何要我将它定义为一个协议,而不是另外一个类?是为了提升灵活性:我知道我想发送给BNRInventoryStore的消息,但我并不须要关心它是如何处理这些消息的。使用协议能让我灵活的处理实现存储的方法:只要它能响应我所关心的消息,它能够是任何类型的类。code

@protocol BNRInventoryStore <NSObject>

- (NSArray *)fetchAllArtefacts;

@end

如今有足够的信息让编译器来编译和运行测试,但它仍是不能经过。

Test Case '-[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore]' started.

/Users/leeg/BNRMuseumInventory/BNRMuseumInventory Tests/BNRMuseumInventoryTests.m:91: error: -[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore] : ((allArtefacts) equal to (expectedArtefacts)) failed: ("(null)") is not equal to ("(

"An artefact"

)")

<unknown>:0: error: -[BNRMuseumInventoryTests testArtefactsAreRetrievedFromTheStore] : OCMockObject[BNRInventoryStore]: expected method was not invoked: fetchAllArtefacts

// snip more output

在测试中断言检测到期待的文物集合并未返回,fetchAllArtefacts方法没有被调用,模拟对象验证失败。只有修复这两个问题,咱们才能够经过测试。

@implementation BNRMuseumInventory

{

id <BNRInventoryStore> _store;

}

- (id)initWithStore:(id <BNRInventoryStore>)store

{

self = [super init];

if (self)

{

_store = store;

}

return self;

}

- (NSArray *)allArtefacts

{

return [_store fetchAllArtefacts];

}

@end

模拟一体化

第二种使用模拟对象的方法是使用外部代码,如苹果的框架或第三方库,进行一体化。模拟对象能够简化使用框架所带来的复杂性,由于测试并不须要搭建一个成熟的环境,只需确保咱们的应用程序能链接到该环境中的一小部分。这种使用模拟对象的模式叫作谦卑对象(Humble Object)。

继续VCR的比喻,咱们并无设计一个与框架交互的类,但咱们要检查咱们是否遵照了他们规定的规则。就像了你买了一台VHS录像机,但你不须要知道磁带的类型,你只能使用VHS录像带,由于这是厂家规定的。一样的,咱们能够告诉咱们的模拟对象,指望值是VHS磁带,因此若是咱们给它一个录像带Betamax,测试将会失败。

回到咱们的博物馆例子中,当应用程序启动时,首先应该看到的是博物馆全部文物的清单,这可使用UIKit设置窗口的根视图控制器来实现。可是要设置整个窗口的测试环境,会很是慢且复杂,因此咱们用一个模拟对象替换窗口。

- (void)testFirstScreenIsTheListOfAllArtefacts

{

BNRAppDelegate *appDelegate = [[BNRAppDelegate alloc] init];

id window = [OCMockObject mockForClass:[UIWindow class]];

appDelegate.window = window;

[[window expect] setRootViewController:[OCMArg checkWithBlock:^(id viewController) {

return [viewController isKindOfClass:[BNRAllArtefactsTableViewController class]];

}]];

[appDelegate application:nil didFinishLaunchingWithOptions:nil];

[window verify];

}

@end

为了使这个测试经过,须实现应用程序的委托方法。

@implementation BNRAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options

{

self.window.rootViewController = [[BNRAllArtefactsTableViewController alloc] initWithStyle:UITableViewStyleGrouped];
return YES;

}

@end

彻底模拟

该例子中,还有另一个需求:当加载一个UIKit的应用程序时:包含初始视图控制器的窗口必须是主要且可见的。咱们能够添加一个测试表达这一要求。请注意,因为这个测试和以前的测试使用的是相同的对象,该构造函数能够被分解成一个setup方法。

@implementation BNRAppDelegateTests

{

BNRAppDelegate *_appDelegate;

id _window;

}

- (void)setUp

{

_appDelegate = [[BNRAppDelegate alloc] init];

_window = [OCMockObject mockForClass:[UIWindow class]];

appDelegate.window = _window;

}

- (void)testWindowIsMadeKeyAndVisible

{

[[_window expect] makeKeyAndVisible];

[_appDelegate application:nil didFinishLaunchingWithOptions:nil];

[_window verify];

}

- (void)testFirstScreenIsTheListOfArtefacts

{

[[_window expect] setRootViewController:[OCMArg checkWithBlock:^(id viewController) {

return [viewController isKindOfClass:[BNRAllArtefactsTableViewController class]];

}];

[_appDelegate application:nil didFinishLaunchingWithOptions:nil];

[_window verify];

}

@end

如今咱们遇到了一个棘手的问题。新测试失败的缘由有两个:预期的makeKeyAndVisible消息没有被发送,却正在发送一个意外的消息setRootViewController:.在 [BNRAppDelegate application:didFinishLaunchingWithOptions:]方法中添加 -makeKeyAndVisible消息 -表示两个测试都失败了,由于模拟窗口对象在每一个测试都接收了一个未期待的方法。

彻底模拟能够解决这个问题。彻底模拟对象可记录它接收到的全部消息,就像一个普通的模拟消息对象,包括不期待的消息。这就像说,“我想记录星际旅行的那个情节:航海者,但若是在这以前有天气预报,我也不介意”,它忽略了额外的信息,而且不考虑致使测试失败的消息。

咱们能够在setUp方法中把这个测试的模拟窗口改为一个彻底模拟。

- (void)setUp

{

_appDelegate = [[BNRAppDelegate alloc] init];

_window = [OCMockObject niceMockForClass:[UIWindow class]];

appDelegate.window = _window;

}

如今,它能够改变应用程序的委托,这样两个测试均可以经过。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options

{

self.window.rootViewController = [[BNRAllArtefactsTableViewController alloc] initWithStyle:UITableViewStyleGrouped];

[self.window makeKeyAndVisible];
return YES;

}

部分模拟

有时候,你并不须要用模拟取代全部对象的行为。你只是想消除一些依赖或复杂的行为,并在你要测试的方法使用其结果。你能够建立一个子类,并重写复杂的方法,此时使用部分模拟会更容易。部分模拟做为真正的对象的代理,截取了部分消息,可是仍然可使用那些没有被替换的消息的实现方法。

再回到咱们的博物馆库存的App例子中,策展人须要将文物的原产地做为筛选条件。这意味着须要全部的文物清单,并对这些对象作一些测试,而咱们所作的是使allArtefacts方法与库存对象进行沟通。但这并非咱们在本次测试须要关心的事情:咱们要专一于筛选,且不重复咱们在以前已经完成的测试工做。使用库存对象的部分模拟就可让咱们去掉桩对象的那部分。这个测试类也会影响文物数据模型的设计。

@implementation BNRMuseumInventoryTests

{

BNRMuseumInventory *_inventory; //created in -setUp

}

//...

- (void)testArtefactsCanBeFilteredByCountryOfOrigin

{

id romanPot = [OCMockObject mockForProtocol:@protocol(BNRArtefact)];

[[[romanPot stub] andReturn:@"Italy"] countryOfOrigin];

id greekPot = [OCMockObject mockForProtocol:@protocol(BNRArtefact)];

[[[greekPot stub] andReturn:@"Greece"] countryOfOrigin];

id partialInventory = [OCMockObject partialMockForObject:_inventory];

[[[partialInventory stub] andReturn:@[romanPot, greekPot]] allArtefacts];

NSArray *greekArtefacts = [partialInventory artefactsFromCountry:@"Greece"];

XCTAssertTrue([greekArtefacts containsObject:greekPot]);

XCTAssertFalse([greekArtefacts containsObject:romanPot]);

}

@end

在上面的测试中,我用OCMock的-stub方法,而不是-expect方法。该方法告诉模拟对象处理该消息并返回指定的值(若是有),但不设置该测试稍后需验证的消息的指望值。我能够经过artefactsFromCountry的返回值来辨别代码是否有用,我并不须要关心如何实现(但若是你担忧硬编码的一些做弊行为,譬如:一般都会返回集合中的最后一个对象,你能够简单地添加更多的测试)。

这个测试告诉咱们一些关于BNRArtefact协议的事情。

- (NSString *)countryOfOrigin;

@end

如今就能够建立artfactsFromCountry:方法。

- (NSArray *)artefactsFromCountry:(NSString *)country

{

NSArray *artefacts = [self allArtefacts];

NSIndexSet *locationsOfMatchingArtefacts = [artefacts indexesOfObjectsPassingTest:^(id <BNRArtefact> anArtefact, NSUInteger idx, BOOL *stop){

return [[anArtefact countryOfOrigin] isEqualToString:country];

}];

return [artefacts objectsAtIndexes:locationsOfMatchingArtefacts];

}

结论

当你构建应用程序的测试驱动时,模拟对象能帮助你集中注意力。他们让你专一于你如今正在作的测试,同时推迟对你未建立对象的测试。他们让你专一于你正在测试的对象的部分,忽略你已经测试过或还没有测试的东西。他们还让你专一于你本身的代码,用简单的类代替复杂的框架类。

若是你由文中VCR想到了你家的那部录音机,那它估计已经到了进博物馆的年纪了,而咱们刚刚写的文物库存管理应用程序,它会在那找到一个温馨的家。


原文 Making a Mockery with Mock Objects
翻译 伯乐在线 - Stellar

相关文章
相关标签/搜索