在本書的最后,講一下與測(cè)試相關(guān)的問(wèn)題,其中單元測(cè)試尤為重要。測(cè)試這個(gè)話題相對(duì)于iOS開(kāi)發(fā)社區(qū)來(lái)說(shuō)還是頗具爭(zhēng)議性的,在理想的情況下,我們?cè)诰帉懸晥D模型的時(shí)候就該為其編寫單元測(cè)試了。之所以將測(cè)試這一章放在最后一節(jié)來(lái)講解,就是考慮到大家在學(xué)習(xí)使用這種新的模式來(lái)進(jìn)行編碼的時(shí)候不是一件簡(jiǎn)單的事情了,再要試著測(cè)試一些沒(méi)有吃透的東西是非常有難度的,而學(xué)到最后的話就大致上已經(jīng)掌握了這種編碼方式了,這樣理解起來(lái)也相對(duì)容易。
當(dāng)然我也注意到,并不是每個(gè)人都以相同的方式來(lái)測(cè)試,或者能夠測(cè)試到相同的程度。我有.Net編程背景,在.net中使用mocks來(lái)測(cè)試系統(tǒng)的實(shí)現(xiàn)細(xì)節(jié)是最平常不過(guò)的了。其他平臺(tái)背景的開(kāi)發(fā)者較少使用mocks來(lái)做,甚至從來(lái)沒(méi)有這樣的經(jīng)驗(yàn)。本節(jié)我只將我的單元測(cè)試方法分享給大家,如果你覺(jué)得合適就采用。
確保你的Podfile
文件包含下面這些庫(kù):
target "FRPTests" do
pod 'ReactiveCocoa', '2.1.4'
pod 'ReactiveViewModel', '0.1.1'
pod 'libextobjc', '0.3'
pod '500px-iOS-api', '1.0.5'
pod 'Specta', '~> 0.2.1'
pod 'Expecta', '~> 0.2'
pod 'OCMock', '~> 2.2.2'
end
然后運(yùn)行pod install
.
首先我們來(lái)看看FRPFullSizePhotoViewModel
,因?yàn)樗罹逴bjective-C風(fēng)范(沒(méi)有太多ReactiveCocoa).
@interface FRPFullSizePhotoViewModel ()
//Private access
@property (nonatomic, assign) NSInteger initialPhotoIndex;
@end
@implementation FRPFullSizePhotoViewModel
- (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex {
self = [self initWithModel:photoArray];
if(!self) return nil;
self.initialPhotoIndex = initialPhotoIndex;
return self;
}
- (NSString *)initialPhotoName {
return [self.model[self.initialPhotoIndex] photoName];
}
- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
if(index < 0 || index > self.model.count - 1) {
//Index was out of bounds, return nil
return nil;
}
else {
return self.model[index];
}
}
@end
好了,我們先來(lái)測(cè)試這個(gè)初始化方法,然后在轉(zhuǎn)移到其他兩個(gè)方法上。
我們想印證初始化我們的視圖模型時(shí),它的兩個(gè)屬性model
和initialPhotoIndex
被正確地賦值了。
#import
#define EXP_SHORTHAND
#import
#import
#import "FRPPhotoModel.h"
#import "FRPFullSizePhotoViewModel.h"
SpecBegin(FRPFullSizePhotoViewModel)
describe(@"FRPFullSizePhotoModel", ^{
it (@"Should assign correct attributes when initialized", ^{
NSArray *model = @[];
NSInteger initialPhotoIndex = 1337;
FRPFullSizePhotoViewModel *viewModel =\
[[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model
initialPhotoIndex: initialPhotoIndex];
expect(model).to.equal(viewModel.model);
expect(initialPhotoIndex).to.equal(viewModel.initialPhotoIndex);
});
});
SpecEnd
在該代碼段頂部,我們導(dǎo)入了一些頭文件,包括一個(gè)奇怪的預(yù)定義EXP_SHORTHAND
,我們把他放在那里以便于可以使用類似expect()
這樣的shorthand matchers(速記匹配)的語(yǔ)法。然后我們引入我們的私有接口SpecBegin(...)/SpecEnd
來(lái)為我們正在測(cè)試的視圖模型屏蔽編譯警告,最后的部分就是我們的單元測(cè)試本身。Specta
的測(cè)試規(guī)范相當(dāng)簡(jiǎn)單,你可以閱讀更多的關(guān)于這方面的信息,但本書不會(huì)深入講解它的一些細(xì)節(jié)??傊愕臏y(cè)試始于SpecBegin
并終止于SpecEnd
,測(cè)試?yán)逃妙愃朴?code>@"應(yīng)該。。。",^{ 預(yù)測(cè)正常的情況應(yīng)該如何 }寫在中間。
好了,停止模擬器中正在運(yùn)行的應(yīng)用,按下cmd+U
快捷鍵,你就可以運(yùn)行這段單元測(cè)試了。如果一切正常,你就能通過(guò)測(cè)試。
接下來(lái)我們來(lái)看看photoModelAtIndex:
方法
- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
if(index < 0 || index > self.model.count - 1 ) {
// Index was out of bounds ,return nil
return nil;
}
else {
return self.model[ index ];
}
}
這里面沒(méi)有太多的業(yè)務(wù)邏輯,但是我們看到其他地方都要使用它,所以我們的測(cè)試應(yīng)該是健壯的。
it(@"Should return nil for an out-of-bounds photo index", ^{
NSArray *model = @[[NSobject new]];
NSInteger initialPhotoIndex = 0;
FRPFullSizePhotoViewModel *viewModel = \
[[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
id subzeroModel = [viewModel photoModelAtIndex:-1];
expect(subzeroModel).to.beNil();
id aboveBoundsModel = [viewModel photoModelAtIndex:model.count];
expect(aboveBoundsModel).to.beNil();
});
it(@"Should return the correct model for photoModelAtIndex:",^{
id photoModel = [NSObject new];
NSArray *model = @[photoModel];
NSInteger initialPhotoIndex = 0;
FRPFullSizePhotoViewModel *viewModel = \
[[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
id returnModel = [viewModel photoModelAtIndex:0];
expect(returnModel).to.equal(photoModel);
});
太棒了!我們這個(gè)新的測(cè)試保證了我們的代碼具有完全的代碼覆蓋率。它檢測(cè)了photoModelAtIndex:
參數(shù)的三種可能的情況:少于0、在作用范圍內(nèi)以及越界。
最后,我們來(lái)看下initialPhotoName
方法:
- (NSString *)initialPhotoName {
return [self.model[self.initialPhotoIndex] photoName];
}
方法看起來(lái)很簡(jiǎn)單,但實(shí)際上這里面包含了更深層級(jí)的東西。恰當(dāng)?shù)刂貥?gòu)一些代碼并為它寫一點(diǎn)不一樣的更小的測(cè)試代碼,來(lái)嚴(yán)格地測(cè)試這個(gè)方法。
- (NSString *)initialPhotoName {
FRPPhotoModel *photoModel = [self initialPhotoModel];
return [photoModel photoName];
}
- (FRPPhotoModel *)initialPhotoModel {
return [self photoModelAtIndex:self.initialPhotoIndex];
}
這更清晰簡(jiǎn)單了,一個(gè)方法確切地只做一件事情,就像一棵樹(shù)的樹(shù)皮,層層疊疊相互依存。只要我們一路下來(lái)所有的代碼都測(cè)試,那么最后我們就可以很確切地保證代碼的健壯性。
initialPhotoModel
是一個(gè)私有方法,所以測(cè)試它我們需要在測(cè)試文件中申明它。
@interface FRPFullSizePhotoViewModel ()
- (FRPPhotoModel *)initialPhotoModel;
@end
你看到的所有我們的測(cè)試代碼都非常簡(jiǎn)單。
it (@"Should return the correct initial photo model", ^{
NSArray *model = @[[NSobject new]];
NSInteger initialPhotoIndex = 0;
FRPFullSizePhotoViewModel *viewModel = \
[[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
id mockViewModel = [OCMockObject partialMockForObject:viewModel];
[[[mockViewModel expect] andReturn:model[0]] photoModelAtIndex:initialPhotoIndex];
id returnedObject = [mockViewModel initialPhotoModel];
expect(returnedObject).to.equal(model[0]);
[mockViewModel verify];
});
這個(gè)測(cè)試是用來(lái)確認(rèn)當(dāng)initialPhotoModel
被調(diào)用時(shí),接下來(lái)它應(yīng)該調(diào)用photoModelAtIndex:
方法并將initialPhotoIndex
作為參數(shù)傳入。這個(gè)測(cè)試是否簡(jiǎn)單取決于我們測(cè)試photoModelAtIndex:
是否充分。
接下來(lái),就讓我們一起來(lái)看看FRPGalleryViewModel
,這看似非常簡(jiǎn)單:
- (instancetype)init {
self = [super init];
if(!self) return nil;
RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];
return self;
}
然而,它可測(cè)性不高,需要重構(gòu)。
我們簡(jiǎn)單地重構(gòu)下視圖模型。新的實(shí)現(xiàn)如下:
@implementation FRPGalleryViewModel
- (instancetype)init {
self = [super init];
if(!self) return nil;
RAC(self, model) = [self importPhotosSignal];
return self;
}
- (RACSignal *)importPhotosSignal {
return [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];
}
@end
我們把importPhotos
的調(diào)用抽出來(lái),以方便測(cè)試這個(gè)方法是否被調(diào)用。我們不會(huì)測(cè)試FRPPhotoImporter
,關(guān)于它的測(cè)試(即單例測(cè)試)已經(jīng)超出了本書的范疇。
這部分的測(cè)試代碼如下:
#import "Specta.h"
#import
#import "FRPGalleryViewModel.h"
@interface FRPGalleryViewModel ()
- (RACSignal *)importPhotosSignal;
@end
SpecBegin(FRPGalleryViewModel)
describe(@"FRPGalleryViewModel",^{
it(@"should be initialized and call importPhotos", ^{
id mockObject = [OCMockObject mockForClass:[FRPGalleryViewModel class]];
[[[mockObject expect] andReturn:[RACSignal empty]] importPhotosSignal];
mockObject = [mockObject init];
[mockObject verify];
[mockObject stopMocking];
});
});
為了測(cè)試一個(gè)方法,測(cè)試代碼也太多了吧! 我知道,我知道~ 這是OCMock沒(méi)落的原因之一,它竟然需要這么多的模板。但你不能責(zé)怪它,因?yàn)樗ぷ髟诹钏缓醯腛bjective-C平臺(tái)上!
我們創(chuàng)建了一個(gè)FRPGalleryViewModel
的mock版本,告訴它期望importPhotoSignal
被調(diào)用。然后才進(jìn)行對(duì)象的初始化。這里使用了一點(diǎn)點(diǎn)技巧,因?yàn)槲覀冊(cè)趍ockObject上調(diào)用了init方法,但它(init)實(shí)際上是一個(gè)NSProxy的子類。然后,對(duì)OCMock來(lái)講,它足夠聰明,它了解這一切,有能力做出正確的選擇。只是看起來(lái)有點(diǎn)詭異罷了。我們使用[mockObject init]
給mockObject
賦值,也是為了屏蔽編譯警告。最后我們驗(yàn)證了所有預(yù)期可能被調(diào)用的方法。
這個(gè)例子中表現(xiàn)出來(lái)的測(cè)試很困難的情況也說(shuō)明了另一個(gè)問(wèn)題,你應(yīng)該避免視圖模型的初始化方法產(chǎn)生"副作用"(參見(jiàn)前面章節(jié)提到的“函數(shù)的副作用”),應(yīng)該使用didBecomeActiveSignal
來(lái)代理。
下面我們來(lái)測(cè)試FRPPhotoViewModel
.再次突出引起函數(shù)副作用和使用didBecomeActiveSignal
的區(qū)別。
快速瀏覽下實(shí)現(xiàn):
@implementation FRPPhotoViewModel
- (intancetype)initWithModel:(FRPPhotoModel *)photoModel {
self = [super initWithModel:photoModel];
if(!self) return nil;
@weakify(self);
[self.didBecomeActiveSignal subscribeNext:^ (id x) {
@strongify(self);
self.loading = YES;
[[FRPPhotoImporter fetchPhotoDetails:self.model]
subscribeError: ^ (NSError *error) {
NSLog(@"Could not fetch photo details: %@",error);
}
completed: ^ {
self.loading = NO;
NSLog(@"Fetched photo details");
}];
}];
RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
return [UIImage imageWithData:value];
}];
return self;
}
- (NSString *)photoName {
return self.model.photoName;
}
@end
首先我們來(lái)測(cè)試photoName
方法:
#import
#define EXP_SHORTHAND
#import
#import
#import "FRPPhotoViewModel.h"
#import "FRPPhotoModel.h"
SpecBegin(FRPPhotoViewModel)
describe (@"FRPPhotoViewModel", ^{
it(@"should return the photo's name property when photoName is invoked", ^{
NSString *name = @"Ash";
id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
[[[mockPhotoModel stub] andReturn:name] photoName];
FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
id mockViewModel = [OCMockObject partialMockForObject:viewModel];
[[[mockViewModel stub] andReturn:mockPhotoModel] model];
id returnName = [mockViewModel photoName];
expect(returnedName).to.equal(name);
[mockPhotoModel stopMocking];
});
});
我們?yōu)閙ock的視圖模型的model屬性添加了一個(gè)mockPhotoModel,它會(huì)mocks所有的途徑。
現(xiàn)在來(lái)看這個(gè)復(fù)雜的初始化方法,這東西看起來(lái)真巨大!近20行純粹的未經(jīng)測(cè)試的代碼。哎呀!讓我們來(lái)一點(diǎn)點(diǎn)簡(jiǎn)化這個(gè)事情,并逐步加上我們的測(cè)試代碼。
- (instancetype)initWithModel:(FRPPhotoModel *)photoModel {
self = [super initWithModel:photoModel];
if(!self) return nil;
@weakify(self);
[self.didBecomeActiveSignal subscribeNext:^(id x) {
@strongify(self);
[self downloadPhotoModelDetails];
}];
RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
return [UIImage imageWithData:value];
}];
return self;
}
- (void)downloadPhotoModelDetails {
self.loading = YES;
[[FRPPhotoImporter fetchPhotoDetails:self.model] subscribeError:^(NSError *error) {
NSLog(@"Could not fetch photo details : %@",error);
} completed:^ {
self.loading = NO;
NSLog(@"Fetched photo details.");
}];
}
我們選擇了不直接測(cè)試fetchPhotoDetails:
,所以我們把它置于一個(gè)實(shí)例方法中,以便更容易對(duì)它進(jìn)行測(cè)試。這個(gè)方法(即fetchPhotoDetails:
)實(shí)現(xiàn)的細(xì)節(jié)在這里對(duì)我們不重要。
現(xiàn)在開(kāi)始寫關(guān)于它的測(cè)試代碼吧:
it(@"should download photo model details when it becomes active", ^{
FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
id mockViewModel = [OCMockObject partialMockForObject:viewModel];
[[mockViewModel expect] downloadPhotoModelDetails];
[mockViewModel setActive:YES];
[mockViewModel verify];
});
注意看初始化方法中不產(chǎn)生(函數(shù))副作用而是把這種副作用放在訂閱didBecomeActiveSignal
的Block塊中時(shí),測(cè)試視圖模型的代碼是多么簡(jiǎn)單!
現(xiàn)在我們需要測(cè)試剩下的那些視圖模型,他們?nèi)糠浅:?jiǎn)單。我們使用更少的mock,因?yàn)楹芏嗟臉I(yè)務(wù)邏輯僅僅是視圖模型的model值到他自己的屬性的映射。
it (@"should return the photo's name property when photoName is invoked", ^{
NSString *name = @"Ash";
id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
[[[mockPhotoModel stub] andReturn:name] photoName];
FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
id mockViewModel = [OCMockObject partialMockForObject:viewModel];
[[[mockViewModel stub] andReturn:mockPhotoModel] model];
id returnedName = [mockViewModel photoName];
expect(returnedName).to.equal(name);
[mockPhotoModel stopMocking];
});
it (@"should correctly map image data to UIImage", ^{
UIImage *image = [[UIImage alloc] init];
NSData *imageData = [NSData data];
id mockImage = [OCMockObject mockForClass:[UIImage class]];
[[[mockImage stub] andReturn:image] imageWithData:imageData];
FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];
photoModel.fullsizedData = imageData;
__unused FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];
[mockImage verify];
[mockImage stopMocking];
});
it(@"should return the correct photo name", ^{
NSString *name = @"Ash";
FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];
photoModel.photoName = name;
FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];
NSString *returnedName = [viewModel photoName];
expect(name).to.equal(returnedName);
});
這就是為視圖模型撰寫單元測(cè)試的全部?jī)?nèi)容了。
在理想的情況下,單元測(cè)試能幫助改進(jìn)你的代碼質(zhì)量。小巧而高內(nèi)聚的方法比隨意的滿是副作用的方法更招人待見(jiàn),它簡(jiǎn)單而完美地詮釋了函數(shù)響應(yīng)型編程的精髓。
測(cè)試MVVM的好處是:我們不用觸及UIKit。請(qǐng)記住,寫得好的MVVM視圖模型的特點(diǎn)是:該視圖模型不會(huì)與用戶交互的接口類有任何交互。
更多建議: