MVVM On iOS - 测试ViewModels

优质
小牛编辑
126浏览
2023-12-01

  本书的最后一节,我们谈谈测试,尤其是单元测试。在iOS的开发社区里,这是一个有争议的话题,这也是为什么我要把它放在最后的原因。理想的情况下。你应该在编写视图模型的同时为它编写单元测试。然而学习如何使用这种新的模式来编码已经很困难,尝试去测试这些你没有吃透的东西,多你来说压力太大,所以我把它放在了最后(学到这里我相信你已经理解了这种编码方式)。

  当然我也注意到,并不是每个人都以相同的方式来测试,或者能够测试到相同的程度。我有.Net编程背景,在.net中使用mocks来测试系统的实现细节是最平常不过的了。其他平台背景的开发者较少使用mocks来做,甚至从来没有这样的经验。本节我只将我的单元测试方法分享给大家,如果你觉得合适就采用。

  确保你的Podfile文件包含下面这些库:

  1. target "FRPTests" do
  2. pod 'ReactiveCocoa', '2.1.4'
  3. pod 'ReactiveViewModel', '0.1.1'
  4. pod 'libextobjc', '0.3'
  5. pod '500px-iOS-api', '1.0.5'
  6. pod 'Specta', '~> 0.2.1'
  7. pod 'Expecta', '~> 0.2'
  8. pod 'OCMock', '~> 2.2.2'
  9. end

  然后运行pod install.

  首先我们来看看FRPFullSizePhotoViewModel,因为它最具Objective-C风范(没有太多ReactiveCocoa).

  1. @interface FRPFullSizePhotoViewModel ()
  2. //Private access
  3. @property (nonatomic, assign) NSInteger initialPhotoIndex;
  4. @end
  5. @implementation FRPFullSizePhotoViewModel
  6. - (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex {
  7. self = [self initWithModel:photoArray];
  8. if(!self) return nil;
  9. self.initialPhotoIndex = initialPhotoIndex;
  10. return self;
  11. }
  12. - (NSString *)initialPhotoName {
  13. return [self.model[self.initialPhotoIndex] photoName];
  14. }
  15. - (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
  16. if(index < 0 || index > self.model.count - 1) {
  17. //Index was out of bounds, return nil
  18. return nil;
  19. }
  20. else {
  21. return self.model[index];
  22. }
  23. }
  24. @end

好了,我们先来测试这个初始化方法,然后在转移到其他两个方法上。

我们想印证初始化我们的视图模型时,它的两个属性modelinitialPhotoIndex被正确地赋值了。

  1. #import <Specta/Specta.h>
  2. #define EXP_SHORTHAND
  3. #import <Expecta/Expecta.h>
  4. #import <OCMock/OCMock.h>
  5. #import "FRPPhotoModel.h"
  6. #import "FRPFullSizePhotoViewModel.h"
  7. SpecBegin(FRPFullSizePhotoViewModel)
  8. describe(@"FRPFullSizePhotoModel", ^{
  9. it (@"Should assign correct attributes when initialized", ^{
  10. NSArray *model = @[];
  11. NSInteger initialPhotoIndex = 1337;
  12. FRPFullSizePhotoViewModel *viewModel =\
  13. [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model
  14. initialPhotoIndex: initialPhotoIndex];
  15. expect(model).to.equal(viewModel.model);
  16. expect(initialPhotoIndex).to.equal(viewModel.initialPhotoIndex);
  17. });
  18. });
  19. SpecEnd

  在该代码段顶部,我们导入了一些头文件,包括一个奇怪的预定义EXP_SHORTHAND,我们把他放在那里以便于可以使用类似expect()这样的shorthand matchers(速记匹配)的语法。然后我们引入我们的私有接口SpecBegin(...)/SpecEnd来为我们正在测试的视图模型屏蔽编译警告,最后的部分就是我们的单元测试本身。Specta的测试规范相当简单,你可以阅读更多的关于这方面的信息,但本书不会深入讲解它的一些细节。总之你的测试始于SpecBegin并终止于SpecEnd,测试例程用类似于@"应该。。。",^{ 预测正常的情况应该如何 }写在中间。

  好了,停止模拟器中正在运行的应用,按下cmd+U快捷键,你就可以运行这段单元测试了。如果一切正常,你就能通过测试。

接下来我们来看看photoModelAtIndex:方法

  1. - (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index {
  2. if(index < 0 || index > self.model.count - 1 ) {
  3. // Index was out of bounds ,return nil
  4. return nil;
  5. }
  6. else {
  7. return self.model[ index ];
  8. }
  9. }

这里面没有太多的业务逻辑,但是我们看到其他地方都要使用它,所以我们的测试应该是健壮的。

  1. it(@"Should return nil for an out-of-bounds photo index", ^{
  2. NSArray *model = @[[NSobject new]];
  3. NSInteger initialPhotoIndex = 0;
  4. FRPFullSizePhotoViewModel *viewModel = \
  5. [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
  6. id subzeroModel = [viewModel photoModelAtIndex:-1];
  7. expect(subzeroModel).to.beNil();
  8. id aboveBoundsModel = [viewModel photoModelAtIndex:model.count];
  9. expect(aboveBoundsModel).to.beNil();
  10. });
  11. it(@"Should return the correct model for photoModelAtIndex:",^{
  12. id photoModel = [NSObject new];
  13. NSArray *model = @[photoModel];
  14. NSInteger initialPhotoIndex = 0;
  15. FRPFullSizePhotoViewModel *viewModel = \
  16. [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
  17. id returnModel = [viewModel photoModelAtIndex:0];
  18. expect(returnModel).to.equal(photoModel);
  19. });

太棒了!我们这个新的测试保证了我们的代码具有完全的代码覆盖率。它检测了photoModelAtIndex:参数的三种可能的情况:少于0、在作用范围内以及越界。

最后,我们来看下initialPhotoName方法:

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

方法看起来很简单,但实际上这里面包含了更深层级的东西。恰当地重构一些代码并为它写一点不一样的更小的测试代码,来严格地测试这个方法。

  1. - (NSString *)initialPhotoName {
  2. FRPPhotoModel *photoModel = [self initialPhotoModel];
  3. return [photoModel photoName];
  4. }
  5. - (FRPPhotoModel *)initialPhotoModel {
  6. return [self photoModelAtIndex:self.initialPhotoIndex];
  7. }

这更清晰简单了,一个方法确切地只做一件事情,就像一棵树的树皮,层层叠叠相互依存。只要我们一路下来所有的代码都测试,那么最后我们就可以很确切地保证代码的健壮性。

initialPhotoModel是一个私有方法,所以测试它我们需要在测试文件中申明它。

  1. @interface FRPFullSizePhotoViewModel ()
  2. - (FRPPhotoModel *)initialPhotoModel;
  3. @end

你看到的所有我们的测试代码都非常简单。

  1. it (@"Should return the correct initial photo model", ^{
  2. NSArray *model = @[[NSobject new]];
  3. NSInteger initialPhotoIndex = 0;
  4. FRPFullSizePhotoViewModel *viewModel = \
  5. [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex];
  6. id mockViewModel = [OCMockObject partialMockForObject:viewModel];
  7. [[[mockViewModel expect] andReturn:model[0]] photoModelAtIndex:initialPhotoIndex];
  8. id returnedObject = [mockViewModel initialPhotoModel];
  9. expect(returnedObject).to.equal(model[0]);
  10. [mockViewModel verify];
  11. });

这个测试是用来确认当initialPhotoModel被调用时,接下来它应该调用photoModelAtIndex:方法并将initialPhotoIndex作为参数传入。这个测试是否简单取决于我们测试photoModelAtIndex:是否充分。

接下来,就让我们一起来看看FRPGalleryViewModel,这看似非常简单:

  1. - (instancetype)init {
  2. self = [super init];
  3. if(!self) return nil;
  4. RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];
  5. return self;
  6. }

然而,它可测性不高,需要重构。

我们简单地重构下视图模型。新的实现如下:

  1. @implementation FRPGalleryViewModel
  2. - (instancetype)init {
  3. self = [super init];
  4. if(!self) return nil;
  5. RAC(self, model) = [self importPhotosSignal];
  6. return self;
  7. }
  8. - (RACSignal *)importPhotosSignal {
  9. return [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]];
  10. }
  11. @end

我们把importPhotos的调用抽出来,以方便测试这个方法是否被调用。我们不会测试FRPPhotoImporter,关于它的测试(即单例测试)已经超出了本书的范畴。

这部分的测试代码如下:

  1. #import "Specta.h"
  2. #import <OCMock/OCMock.h>
  3. #import "FRPGalleryViewModel.h"
  4. @interface FRPGalleryViewModel ()
  5. - (RACSignal *)importPhotosSignal;
  6. @end
  7. SpecBegin(FRPGalleryViewModel)
  8. describe(@"FRPGalleryViewModel",^{
  9. it(@"should be initialized and call importPhotos", ^{
  10. id mockObject = [OCMockObject mockForClass:[FRPGalleryViewModel class]];
  11. [[[mockObject expect] andReturn:[RACSignal empty]] importPhotosSignal];
  12. mockObject = [mockObject init];
  13. [mockObject verify];
  14. [mockObject stopMocking];
  15. });
  16. });

  为了测试一个方法,测试代码也太多了吧! 我知道,我知道~ 这是OCMock没落的原因之一,它竟然需要这么多的模板。但你不能责怪它,因为它要工作在令它不寒而栗的Objective-C平台上!

  我们创建了一个FRPGalleryViewModel的mock版本,告诉它期望importPhotoSignal被调用。然后才进行对象的初始化。这里使用了一点点技巧,因为我们在mockObject上调用了init方法,但它(init)实际上是一个NSProxy的子类。然后,对OCMock来讲,它足够聪明,它了解这一切,有能力做出正确的选择。只是看起来有点诡异罢了。我们使用[mockObject init]mockObject赋值,也是为了屏蔽编译警告。最后我们验证了所有预期可能被调用的方法。

  这个例子中表现出来的测试很困难的情况也说明了另一个问题,你应该避免视图模型的初始化方法产生”副作用”(参见前面章节提到的“函数的副作用”),应该使用didBecomeActiveSignal来代理。

下面我们来测试FRPPhotoViewModel.再次突出引起函数副作用和使用didBecomeActiveSignal的区别。

快速浏览下实现:

  1. @implementation FRPPhotoViewModel
  2. - (intancetype)initWithModel:(FRPPhotoModel *)photoModel {
  3. self = [super initWithModel:photoModel];
  4. if(!self) return nil;
  5. @weakify(self);
  6. [self.didBecomeActiveSignal subscribeNext:^ (id x) {
  7. @strongify(self);
  8. self.loading = YES;
  9. [[FRPPhotoImporter fetchPhotoDetails:self.model]
  10. subscribeError: ^ (NSError *error) {
  11. NSLog(@"Could not fetch photo details: %@",error);
  12. }
  13. completed: ^ {
  14. self.loading = NO;
  15. NSLog(@"Fetched photo details");
  16. }];
  17. }];
  18. RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
  19. return [UIImage imageWithData:value];
  20. }];
  21. return self;
  22. }
  23. - (NSString *)photoName {
  24. return self.model.photoName;
  25. }
  26. @end

首先我们来测试photoName方法:

  1. #import <Specta/Specta.h>
  2. #define EXP_SHORTHAND
  3. #import <Expecta/Expecta.h>
  4. #import <OCMock/OCMock.h>
  5. #import "FRPPhotoViewModel.h"
  6. #import "FRPPhotoModel.h"
  7. SpecBegin(FRPPhotoViewModel)
  8. describe (@"FRPPhotoViewModel", ^{
  9. it(@"should return the photo's name property when photoName is invoked", ^{
  10. NSString *name = @"Ash";
  11. id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
  12. [[[mockPhotoModel stub] andReturn:name] photoName];
  13. FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
  14. id mockViewModel = [OCMockObject partialMockForObject:viewModel];
  15. [[[mockViewModel stub] andReturn:mockPhotoModel] model];
  16. id returnName = [mockViewModel photoName];
  17. expect(returnedName).to.equal(name);
  18. [mockPhotoModel stopMocking];
  19. });
  20. });

我们为mock的视图模型的model属性添加了一个mockPhotoModel,它会mocks所有的途径。

现在来看这个复杂的初始化方法,这东西看起来真巨大!近20行纯粹的未经测试的代码。哎呀!让我们来一点点简化这个事情,并逐步加上我们的测试代码。

  1. - (instancetype)initWithModel:(FRPPhotoModel *)photoModel {
  2. self = [super initWithModel:photoModel];
  3. if(!self) return nil;
  4. @weakify(self);
  5. [self.didBecomeActiveSignal subscribeNext:^(id x) {
  6. @strongify(self);
  7. [self downloadPhotoModelDetails];
  8. }];
  9. RAC(self, photoImage) = [RACObserve(self.model, fullsizedData) map:^id (id value) {
  10. return [UIImage imageWithData:value];
  11. }];
  12. return self;
  13. }
  14. - (void)downloadPhotoModelDetails {
  15. self.loading = YES;
  16. [[FRPPhotoImporter fetchPhotoDetails:self.model] subscribeError:^(NSError *error) {
  17. NSLog(@"Could not fetch photo details : %@",error);
  18. } completed:^ {
  19. self.loading = NO;
  20. NSLog(@"Fetched photo details.");
  21. }];
  22. }

我们选择了不直接测试fetchPhotoDetails:,所以我们把它置于一个实例方法中,以便更容易对它进行测试。这个方法(即fetchPhotoDetails:)实现的细节在这里对我们不重要。

现在开始写关于它的测试代码吧:

  1. it(@"should download photo model details when it becomes active", ^{
  2. FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
  3. id mockViewModel = [OCMockObject partialMockForObject:viewModel];
  4. [[mockViewModel expect] downloadPhotoModelDetails];
  5. [mockViewModel setActive:YES];
  6. [mockViewModel verify];
  7. });

注意看初始化方法中不产生(函数)副作用而是把这种副作用放在订阅didBecomeActiveSignal的Block块中时,测试视图模型的代码是多么简单!

现在我们需要测试剩下的那些视图模型,他们全部非常简单。我们使用更少的mock,因为很多的业务逻辑仅仅是视图模型的model值到他自己的属性的映射。

  1. it (@"should return the photo's name property when photoName is invoked", ^{
  2. NSString *name = @"Ash";
  3. id mockPhotoModel = [OCMockObject mockForClass:[FRPPhotoModel class]];
  4. [[[mockPhotoModel stub] andReturn:name] photoName];
  5. FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:nil];
  6. id mockViewModel = [OCMockObject partialMockForObject:viewModel];
  7. [[[mockViewModel stub] andReturn:mockPhotoModel] model];
  8. id returnedName = [mockViewModel photoName];
  9. expect(returnedName).to.equal(name);
  10. [mockPhotoModel stopMocking];
  11. });
  12. it (@"should correctly map image data to UIImage", ^{
  13. UIImage *image = [[UIImage alloc] init];
  14. NSData *imageData = [NSData data];
  15. id mockImage = [OCMockObject mockForClass:[UIImage class]];
  16. [[[mockImage stub] andReturn:image] imageWithData:imageData];
  17. FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];
  18. photoModel.fullsizedData = imageData;
  19. __unused FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];
  20. [mockImage verify];
  21. [mockImage stopMocking];
  22. });
  23. it(@"should return the correct photo name", ^{
  24. NSString *name = @"Ash";
  25. FRPPhotoModel *photoModel = [[FRPPhotoModel alloc] init];
  26. photoModel.photoName = name;
  27. FRPPhotoViewModel *viewModel = [[FRPPhotoViewModel alloc] initWithModel:photoModel];
  28. NSString *returnedName = [viewModel photoName];
  29. expect(name).to.equal(returnedName);
  30. });

  这就是为视图模型撰写单元测试的全部内容了。

  在理想的情况下,单元测试能帮助改进你的代码质量。小巧而高内聚的方法比随意的满是副作用的方法更招人待见。它简单而完美地诠释了函数响应型编程的精髓。

  测试MVVM的好处是:我们不用触及UIKit。请记住,写得好的MVVM视图模型的特点是:该视图模型不会与用户交互的接口类有任何交互。