測試ViewModels

2018-08-01 16:25 更新

  在本書的最后,講一下與測試相關(guān)的問題,其中單元測試尤為重要。測試這個話題相對于iOS開發(fā)社區(qū)來說還是頗具爭議性的,在理想的情況下,我們在編寫視圖模型的時候就該為其編寫單元測試了。之所以將測試這一章放在最后一節(jié)來講解,就是考慮到大家在學(xué)習(xí)使用這種新的模式來進(jìn)行編碼的時候不是一件簡單的事情了,再要試著測試一些沒有吃透的東西是非常有難度的,而學(xué)到最后的話就大致上已經(jīng)掌握了這種編碼方式了,這樣理解起來也相對容易。

  當(dāng)然我也注意到,并不是每個人都以相同的方式來測試,或者能夠測試到相同的程度。我有.Net編程背景,在.net中使用mocks來測試系統(tǒng)的實現(xiàn)細(xì)節(jié)是最平常不過的了。其他平臺背景的開發(fā)者較少使用mocks來做,甚至從來沒有這樣的經(jīng)驗。本節(jié)我只將我的單元測試方法分享給大家,如果你覺得合適就采用。

  確保你的Podfile文件包含下面這些庫:

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.

  首先我們來看看FRPFullSizePhotoViewModel,因為它最具Objective-C風(fēng)范(沒有太多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

好了,我們先來測試這個初始化方法,然后在轉(zhuǎn)移到其他兩個方法上。

我們想印證初始化我們的視圖模型時,它的兩個屬性modelinitialPhotoIndex被正確地賦值了。

#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)入了一些頭文件,包括一個奇怪的預(yù)定義EXP_SHORTHAND,我們把他放在那里以便于可以使用類似expect()這樣的shorthand matchers(速記匹配)的語法。然后我們引入我們的私有接口SpecBegin(...)/SpecEnd來為我們正在測試的視圖模型屏蔽編譯警告,最后的部分就是我們的單元測試本身。Specta的測試規(guī)范相當(dāng)簡單,你可以閱讀更多的關(guān)于這方面的信息,但本書不會深入講解它的一些細(xì)節(jié)??傊愕臏y試始于SpecBegin并終止于SpecEnd,測試?yán)逃妙愃朴?code>@"應(yīng)該。。。",^{ 預(yù)測正常的情況應(yīng)該如何 }寫在中間。

  好了,停止模擬器中正在運(yùn)行的應(yīng)用,按下cmd+U快捷鍵,你就可以運(yùn)行這段單元測試了。如果一切正常,你就能通過測試。

接下來我們來看看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 ];
    }
}

這里面沒有太多的業(yè)務(wù)邏輯,但是我們看到其他地方都要使用它,所以我們的測試應(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);

});

太棒了!我們這個新的測試保證了我們的代碼具有完全的代碼覆蓋率。它檢測了photoModelAtIndex:參數(shù)的三種可能的情況:少于0、在作用范圍內(nèi)以及越界。

最后,我們來看下initialPhotoName方法:

- (NSString *)initialPhotoName {
    return [self.model[self.initialPhotoIndex] photoName];
}

方法看起來很簡單,但實際上這里面包含了更深層級的東西。恰當(dāng)?shù)刂貥?gòu)一些代碼并為它寫一點不一樣的更小的測試代碼,來嚴(yán)格地測試這個方法。

- (NSString *)initialPhotoName {
    FRPPhotoModel *photoModel = [self initialPhotoModel];
    return [photoModel photoName];
}

- (FRPPhotoModel *)initialPhotoModel {
    return [self photoModelAtIndex:self.initialPhotoIndex];
}

這更清晰簡單了,一個方法確切地只做一件事情,就像一棵樹的樹皮,層層疊疊相互依存。只要我們一路下來所有的代碼都測試,那么最后我們就可以很確切地保證代碼的健壯性。

initialPhotoModel是一個私有方法,所以測試它我們需要在測試文件中申明它。

@interface FRPFullSizePhotoViewModel ()

- (FRPPhotoModel *)initialPhotoModel;

@end

你看到的所有我們的測試代碼都非常簡單。

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];
});

這個測試是用來確認(rèn)當(dāng)initialPhotoModel被調(diào)用時,接下來它應(yīng)該調(diào)用photoModelAtIndex:方法并將initialPhotoIndex作為參數(shù)傳入。這個測試是否簡單取決于我們測試photoModelAtIndex:是否充分。

接下來,就讓我們一起來看看FRPGalleryViewModel,這看似非常簡單:

- (instancetype)init {
    self = [super init];
    if(!self) return nil;

    RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];

    return self;
}

然而,它可測性不高,需要重構(gòu)。

我們簡單地重構(gòu)下視圖模型。新的實現(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)用抽出來,以方便測試這個方法是否被調(diào)用。我們不會測試FRPPhotoImporter,關(guān)于它的測試(即單例測試)已經(jīng)超出了本書的范疇。

這部分的測試代碼如下:

#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];
    });
});

  為了測試一個方法,測試代碼也太多了吧! 我知道,我知道~ 這是OCMock沒落的原因之一,它竟然需要這么多的模板。但你不能責(zé)怪它,因為它要工作在令它不寒而栗的Objective-C平臺上!

  我們創(chuàng)建了一個FRPGalleryViewModel的mock版本,告訴它期望importPhotoSignal被調(diào)用。然后才進(jìn)行對象的初始化。這里使用了一點點技巧,因為我們在mockObject上調(diào)用了init方法,但它(init)實際上是一個NSProxy的子類。然后,對OCMock來講,它足夠聰明,它了解這一切,有能力做出正確的選擇。只是看起來有點詭異罷了。我們使用[mockObject init]mockObject賦值,也是為了屏蔽編譯警告。最后我們驗證了所有預(yù)期可能被調(diào)用的方法。

  這個例子中表現(xiàn)出來的測試很困難的情況也說明了另一個問題,你應(yīng)該避免視圖模型的初始化方法產(chǎn)生"副作用"(參見前面章節(jié)提到的“函數(shù)的副作用”),應(yīng)該使用didBecomeActiveSignal來代理。

下面我們來測試FRPPhotoViewModel.再次突出引起函數(shù)副作用和使用didBecomeActiveSignal的區(qū)別。

快速瀏覽下實現(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

首先我們來測試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屬性添加了一個mockPhotoModel,它會mocks所有的途徑。

現(xiàn)在來看這個復(fù)雜的初始化方法,這東西看起來真巨大!近20行純粹的未經(jīng)測試的代碼。哎呀!讓我們來一點點簡化這個事情,并逐步加上我們的測試代碼。

- (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.");
    }];
}

我們選擇了不直接測試fetchPhotoDetails:,所以我們把它置于一個實例方法中,以便更容易對它進(jìn)行測試。這個方法(即fetchPhotoDetails:)實現(xiàn)的細(xì)節(jié)在這里對我們不重要。

現(xiàn)在開始寫關(guān)于它的測試代碼吧:

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塊中時,測試視圖模型的代碼是多么簡單!

現(xiàn)在我們需要測試剩下的那些視圖模型,他們?nèi)糠浅:唵?。我們使用更少的mock,因為很多的業(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);
});

  這就是為視圖模型撰寫單元測試的全部內(nèi)容了。

  在理想的情況下,單元測試能幫助改進(jìn)你的代碼質(zhì)量。小巧而高內(nèi)聚的方法比隨意的滿是副作用的方法更招人待見,它簡單而完美地詮釋了函數(shù)響應(yīng)型編程的精髓。

  測試MVVM的好處是:我們不用觸及UIKit。請記住,寫得好的MVVM視圖模型的特點是:該視圖模型不會與用戶交互的接口類有任何交互。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號