|
1 | 1 | # 测试ViewModels |
| 2 | + |
| 3 | +本书的最后一节,我们谈谈测试,尤其是单元测试。在iOS的开发社区里,这是一个有争议的话题,这也是为什么我要把它放在最后的原因。理想的情况下。你应该在编写视图模型的同时为它编写单元测试。然而学习如何使用这种新的模式来编码已经很困难,尝试去测试这些你没有吃透的东西,多你来说压力太大,所以我把它放在了最后(学到这里我相信你已经理解了这种编码方式)。 |
| 4 | + |
| 5 | +当然我也注意到,并不是每个人都以相同的方式来测试,或者能够测试到相同的程度。我有.Net编程背景,在.net中使用mocks来测试系统的实现细节是最平常不过的了。其他平台背景的开发者较少使用mocks来做,甚至从来没有这样的经验。本节我只将我的单元测试方法分享给大家,如果你觉得合适就采用。 |
| 6 | + |
| 7 | +确保你的`Podfile`文件包含下面这些库: |
| 8 | + |
| 9 | +```Objective-C |
| 10 | +target "FRPTests" do |
| 11 | + |
| 12 | +pod 'ReactiveCocoa', '2.1.4' |
| 13 | +pod 'ReactiveViewModel', '0.1.1' |
| 14 | +pod 'libextobjc', '0.3' |
| 15 | +pod '500px-iOS-api', '1.0.5' |
| 16 | +pod 'Specta', '~> 0.2.1' |
| 17 | +pod 'Expecta', '~> 0.2' |
| 18 | +pod 'OCMock', '~> 2.2.2' |
| 19 | + |
| 20 | +end |
| 21 | + |
| 22 | +``` |
| 23 | + |
| 24 | +然后运行`pod install`. |
| 25 | + |
| 26 | +首先我们来看看`FRPFullSizePhotoViewModel`,因为它最具Objective-C风范(没有太多ReactiveCocoa). |
| 27 | + |
| 28 | +```Objective-C |
| 29 | +@interface FRPFullSizePhotoViewModel () |
| 30 | +//Private access |
| 31 | +@property (nonatomic, assign) NSInteger initialPhotoIndex; |
| 32 | + |
| 33 | +@end |
| 34 | + |
| 35 | +@implementation FRPFullSizePhotoViewModel |
| 36 | + |
| 37 | +- (instancetype)initWithPhotoArray:(NSArray *)photoArray initialPhotoIndex:(NSInteger)initialPhotoIndex { |
| 38 | + self = [self initWithModel:photoArray]; |
| 39 | + if(!self) return nil; |
| 40 | + |
| 41 | + self.initialPhotoIndex = initialPhotoIndex; |
| 42 | + |
| 43 | + return self; |
| 44 | +} |
| 45 | + |
| 46 | +- (NSString *)initialPhotoName { |
| 47 | + return [self.model[self.initialPhotoIndex] photoName]; |
| 48 | +} |
| 49 | + |
| 50 | +- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index { |
| 51 | + if(index < 0 || index > self.model.count - 1) { |
| 52 | + //Index was out of bounds, return nil |
| 53 | + return nil; |
| 54 | + } |
| 55 | + else { |
| 56 | + return self.model[index]; |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +@end |
| 61 | + |
| 62 | +``` |
| 63 | +好了,我们先来测试这个初始化方法,然后在转移到其他两个方法上。 |
| 64 | +
|
| 65 | +我们想印证初始化我们的视图模型时,它的两个属性`model`和`initialPhotoIndex`被正确地赋值了。 |
| 66 | +
|
| 67 | +```Objective-C |
| 68 | +#import <Specta/Specta.h> |
| 69 | +#define EXP_SHORTHAND |
| 70 | +#import <Expecta/Expecta.h> |
| 71 | +#import <OCMock/OCMock.h> |
| 72 | +#import "FRPPhotoModel.h" |
| 73 | +
|
| 74 | +#import "FRPFullSizePhotoViewModel.h" |
| 75 | +
|
| 76 | +SpecBegin(FRPFullSizePhotoViewModel) |
| 77 | +
|
| 78 | +describe(@"FRPFullSizePhotoModel", ^{ |
| 79 | + it (@"Should assign correct attributes when initialized", ^{ |
| 80 | + NSArray *model = @[]; |
| 81 | + NSInteger initialPhotoIndex = 1337; |
| 82 | + |
| 83 | + FRPFullSizePhotoViewModel *viewModel =\ |
| 84 | + [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model |
| 85 | + initialPhotoIndex: initialPhotoIndex]; |
| 86 | + |
| 87 | + expect(model).to.equal(viewModel.model); |
| 88 | + expect(initialPhotoIndex).to.equal(viewModel.initialPhotoIndex); |
| 89 | + |
| 90 | + }); |
| 91 | +}); |
| 92 | +
|
| 93 | +SpecEnd |
| 94 | +
|
| 95 | +``` |
| 96 | +在该代码段顶部,我们导入了一些头文件,包括一个奇怪的预定义`EXP_SHORTHAND`,我们把他放在那里以便于可以使用类似`expect()`这样的shorthand matchers(速记匹配)的语法。然后我们引入我们的私有接口`SpecBegin(...)/SpecEnd`来为我们正在测试的视图模型屏蔽编译警告,最后的部分就是我们的单元测试本身。`Specta`的测试规范相当简单,你可以阅读更多的关于这方面的信息,但本书不会深入讲解它的一些细节。总之你的测试始于`SpecBegin`并终止于`SpecEnd`,测试例程用类似于`@"应该。。。",^{ 预测正常的情况应该如何 }`写在中间。 |
| 97 | + |
| 98 | +好了,停止模拟器中正在运行的应用,按下`cmd+U`快捷键,你就可以运行这段单元测试了。如果一切正常,你就能通过测试。 |
| 99 | + |
| 100 | +接下来我们来看看`photoModelAtIndex:`方法 |
| 101 | + |
| 102 | +```Objective-C |
| 103 | +- (FRPPhotoModel *)photoModelAtIndex:(NSInteger)index { |
| 104 | + if(index < 0 || index > self.model.count - 1 ) { |
| 105 | + // Index was out of bounds ,return nil |
| 106 | + return nil; |
| 107 | + } |
| 108 | + else { |
| 109 | + return self.model[ index ]; |
| 110 | + } |
| 111 | +} |
| 112 | +``` |
| 113 | +这里面没有太多的业务逻辑,但是我们看到其他地方都要使用它,所以我们的测试应该是健壮的。 |
| 114 | + |
| 115 | +```Objective-C |
| 116 | +it(@"Should return nil for an out-of-bounds photo index", ^{ |
| 117 | + NSArray *model = @[[NSobject new]]; |
| 118 | + NSInteger initialPhotoIndex = 0; |
| 119 | + |
| 120 | + FRPFullSizePhotoViewModel *viewModel = \ |
| 121 | + [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex]; |
| 122 | + |
| 123 | + id subzeroModel = [viewModel photoModelAtIndex:-1]; |
| 124 | + expect(subzeroModel).to.beNil(); |
| 125 | + |
| 126 | + id aboveBoundsModel = [viewModel photoModelAtIndex:model.count]; |
| 127 | + expect(aboveBoundsModel).to.beNil(); |
| 128 | +}); |
| 129 | + |
| 130 | +it(@"Should return the correct model for photoModelAtIndex:",^{ |
| 131 | + id photoModel = [NSObject new]; |
| 132 | + NSArray *model = @[photoModel]; |
| 133 | + NSInteger initialPhotoIndex = 0; |
| 134 | + |
| 135 | + FRPFullSizePhotoViewModel *viewModel = \ |
| 136 | + [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex]; |
| 137 | + |
| 138 | + id returnModel = [viewModel photoModelAtIndex:0]; |
| 139 | + expect(returnModel).to.equal(photoModel); |
| 140 | + |
| 141 | +}); |
| 142 | + |
| 143 | +``` |
| 144 | +太棒了!我们这个新的测试保证了我们的代码具有完全的代码覆盖率。它检测了`photoModelAtIndex:`参数的三种可能的情况:少于0、在作用范围内以及越界。 |
| 145 | +
|
| 146 | +最后,我们来看下`initialPhotoName`方法: |
| 147 | +
|
| 148 | +```Objective-C |
| 149 | +- (NSString *)initialPhotoName { |
| 150 | + return [self.model[self.initialPhotoIndex] photoName]; |
| 151 | +} |
| 152 | +
|
| 153 | +``` |
| 154 | +方法看起来很简单,但实际上这里面包含了更深层级的东西。恰当地重构一些代码并为它写一点不一样的更小的测试代码,来严格地测试这个方法。 |
| 155 | + |
| 156 | +```Objective-C |
| 157 | +- (NSString *)initialPhotoName { |
| 158 | + FRPPhotoModel *photoModel = [self initialPhotoModel]; |
| 159 | + return [photoModel photoName]; |
| 160 | +} |
| 161 | + |
| 162 | +- (FRPPhotoModel *)initialPhotoModel { |
| 163 | + return [self photoModelAtIndex:self.initialPhotoIndex]; |
| 164 | +} |
| 165 | + |
| 166 | +``` |
| 167 | + |
| 168 | +这更清晰简单了,一个方法确切地只做一件事情,就像一棵树的树皮,层层叠叠相互依存。只要我们一路下来所有的代码都测试,那么最后我们就可以很确切地保证代码的健壮性。 |
| 169 | + |
| 170 | +`initialPhotoModel`是一个私有方法,所以测试它我们需要在测试文件中申明它。 |
| 171 | + |
| 172 | +```Objective-C |
| 173 | +@interface FRPFullSizePhotoViewModel () |
| 174 | + |
| 175 | +- (FRPPhotoModel *)initialPhotoModel; |
| 176 | + |
| 177 | +@end |
| 178 | +``` |
| 179 | +
|
| 180 | +你看到的所有我们的测试代码都非常简单。 |
| 181 | +
|
| 182 | +```Objective-C |
| 183 | +it (@"Should return the correct initial photo model", ^{ |
| 184 | + NSArray *model = @[[NSobject new]]; |
| 185 | + NSInteger initialPhotoIndex = 0; |
| 186 | + |
| 187 | + FRPFullSizePhotoViewModel *viewModel = \ |
| 188 | + [[FRPFullSizePhotoViewModel alloc] initWithPhotoArray:model initialPhotoIndex:initialPhotoIndex]; |
| 189 | + |
| 190 | + id mockViewModel = [OCMockObject partialMockForObject:viewModel]; |
| 191 | + [[[mockViewModel expect] andReturn:model[0]] photoModelAtIndex:initialPhotoIndex]; |
| 192 | + |
| 193 | + id returnedObject = [mockViewModel initialPhotoModel]; |
| 194 | + |
| 195 | + expect(returnedObject).to.equal(model[0]); |
| 196 | + |
| 197 | + [mockViewModel verify]; |
| 198 | +}); |
| 199 | +``` |
| 200 | + |
| 201 | +这个测试是用来确认当`initialPhotoModel`被调用时,接下来它应该调用`photoModelAtIndex:`方法并将`initialPhotoIndex`作为参数传入。这个测试是否简单取决于我们测试`photoModelAtIndex:`是否充分。 |
| 202 | + |
| 203 | +接下来,就让我们一起来看看`FRPGalleryViewModel`,这看似非常简单: |
| 204 | + |
| 205 | +```Objective-C |
| 206 | +- (instancetype)init { |
| 207 | + self = [super init]; |
| 208 | + if(!self) return nil; |
| 209 | + |
| 210 | + RAC(self, model) = [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]]; |
| 211 | + |
| 212 | + return self; |
| 213 | +} |
| 214 | + |
| 215 | +``` |
| 216 | + |
| 217 | +然而,它可测性不高,需要重构。 |
| 218 | + |
| 219 | +我们简单地重构下视图模型。新的实现如下: |
| 220 | + |
| 221 | +```Objective-C |
| 222 | +@implementation FRPGalleryViewModel |
| 223 | + |
| 224 | +- (instancetype)init { |
| 225 | + self = [super init]; |
| 226 | + if(!self) return nil; |
| 227 | + |
| 228 | + RAC(self, model) = [self importPhotosSignal]; |
| 229 | + |
| 230 | + return self; |
| 231 | +} |
| 232 | + |
| 233 | +- (RACSignal *)importPhotosSignal { |
| 234 | + return [[[FRPPhotoImporter importPhotos] logError] catchTo:[RACSignal empty]]; |
| 235 | +} |
| 236 | + |
| 237 | +@end |
| 238 | + |
| 239 | +``` |
| 240 | +
|
| 241 | +我们把`importPhotos`的调用抽出来,以方便测试这个方法是否被调用。我们不会测试`FRPPhotoImporter`,关于它的测试(即单例测试)已经超出了本书的范畴。 |
| 242 | +
|
| 243 | +这部分的测试代码如下: |
| 244 | +
|
| 245 | +```Objective-C |
| 246 | +#import "Specta.h" |
| 247 | +#import <OCMock/OCMock.h> |
| 248 | +
|
| 249 | +#import "FRPGalleryViewModel.h" |
| 250 | +
|
| 251 | +@interface FRPGalleryViewModel () |
| 252 | +
|
| 253 | +- (RACSignal *)importPhotosSignal; |
| 254 | +
|
| 255 | +@end |
| 256 | +
|
| 257 | +SpecBegin(FRPGalleryViewModel) |
| 258 | +
|
| 259 | +describe(@"FRPGalleryViewModel",^{ |
| 260 | + it(@"should be initialized and call importPhotos", ^{ |
| 261 | + id mockObject = [OCMockObject mockForClass:[FRPGalleryViewModel class]]; |
| 262 | + [[[mockObject expect] andReturn:[RACSignal empty]] importPhotosSignal]; |
| 263 | + |
| 264 | + mockObject = [mockObject init]; |
| 265 | + |
| 266 | + [mockObject verify]; |
| 267 | + [mockObject stopMocking]; |
| 268 | + }); |
| 269 | +}); |
| 270 | +
|
| 271 | +``` |
| 272 | + |
| 273 | +为了测试一个方法,测试代码也太多了吧! 我知道,我知道~ 这是OCMock没落的原因之一,它竟然需要这么多的模板。但你不能责怪它,因为它要工作在令它不寒而栗的Objective-C平台上! |
| 274 | + |
| 275 | + |
| 276 | + |
| 277 | + |
| 278 | + |
0 commit comments