这篇文章我们继续去开发第【二】篇文章未完成的部分,去实现让数据源代理类为表格视图提供数据和响应代理,上篇文章我们讲到了要测试验证“(3)确认表格视图的行数、行高和Cell跟其数据源代理类提供的数据一致。”,具体要怎么做呢?
答案会让人意外,那就是“做不到”或者“不要这样去做”。
要知道,单元测试只是众多测试技术工具的一种,它有自己的局限性,它显著的局限性之一就是不适合做跟UI相关的测试,比如这里(3)要测试的东西。原因是:1,UI多变,会导致测试用例不稳定。2,UI的实现方式多样,有些不一定在代码中能体现,比如用xib方式实现时,测代码就没什么用。3,成本较高,用单元测试去测UI肯定要绕各种弯子才能间接去测,那是非常吃力不讨好的,比如要让view出现在屏幕,并保证让被测的子view的frame在运行测试用例时有值,就不是那么容易做到。所以,我们不用单元测试去测UI。除了以上三个原因,针对当前这个表格视图的测试,还有另外一点原因导致我们不去直接做(3)里面提及的测试,那就是我们不要用单元测试去测系统框架的类。这里我们的表格视图就是一个系统框架的类,我们默认它总是正常的,我们不要去测试它拿到数据源提供的数据后,能不能按照数据要求显示正确的行数、行高和显示正常的Cell。因为这些逻辑是表格视图类本身的逻辑,显然这部分工作苹果已经为我们做好了,我们就不要再去测试它们了。
那么围绕表格视图我们还有什么可以测试的吗?有的,而且是一定要被测试的部分,那就是保证表格视图拿到了正确的数据源和代理类。我们在第【二】篇文章用单元测试用例保证了表格视图拿到了我们提供的数据源和代理类,但这个数据源和代理类是不是“正确”的,我们却还没测试,所以在这篇文章里,我们要测试这个数据源和代理类的内部逻辑是正确的。做完了这点,我们就完成了(3)的测试。我们把(3)的任务转变为:“确认表格视图的数据源和代理类为表格提供了正确的行数、正确的Cells,以及为表格的Cell点击事件作出了正确的响应”。
首先,数据源它自己不产生数据,它的数据也是从别处拿到的,所以,所谓数源的正确性就是它提供给表格用的数据是否跟它从别处拿到的数据一致。在MyTableViewDataSourceTests里面追加一些测试用例:
【tc 3.1,tc 3.2,tc 3.3,测试数据源为表格提供的数据个数跟它从别处拿到的数据个数一样】
/**
tc 3.1
*/
- (void)test_ProvideZeroRowCountWithNilOrEmptyDataArray{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
dataSource.theDataArray = nil;
NSInteger rowCount = [dataSource tableView:tableView numberOfRowsInSection:0];
XCTAssertEqual(rowCount, 0);
dataSource.theDataArray = @[];
rowCount = [dataSource tableView:tableView numberOfRowsInSection:0];
XCTAssertEqual(rowCount, 0);
}
/**
tc 3.2
*/
- (void)test_ProvideOneRowCountWithDataArrayContainsOneElement{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
dataSource.theDataArray = @[@"first row"];
NSInteger rowCount = [dataSource tableView:tableView numberOfRowsInSection:0];
XCTAssertEqual(rowCount, 1);
}
/**
tc 3.3
*/
- (void)test_ProvideTwoRowCountWithDataArrayContainsTwoElement{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
dataSource.theDataArray = @[@"first row",@"second row"];
NSInteger rowCount = [dataSource tableView:tableView numberOfRowsInSection:0];
XCTAssertEqual(rowCount, 2);
}
这是Red阶段,所以照例,它们是会失败的,甚至都不能通过编译,因为我们当前的数据源代理类还没有theDataArray这个属性。补全代码,让Red阶段进入到Green阶段:
#import <UIKit/UIKit.h>
@interface MyTableViewDataSource : NSObject<UITableViewDataSource,UITableViewDelegate>
@property (nonatomic, strong) NSArray *theDataArray;
@end
//.m文件修改这个方法
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section{
return [self.theDataArray count];
}
这样,上面三个测试用例都通过了,也没有让其他之前的用例失败,所以我们到这里完成了(3)里面的第一项测试,数据源个数的正确性测试。
接下来,我们要做第二项测试,测试数据源为表格提供了正确的Cells。什么是正确的Cell?那就是IndexPath对应的Cell展示的数据跟theDataArray里面对应的元素的数据是一致的。我准备绘制表格cell的方式是让cell拥有一个MyModel的属性,通过给cell赋值不同的model,绘制不同的内容,而model是数据源类根据外界获取的theDataArray字典数组按顺序封装的,每个model会分别被赋值给对应顺序的cell。cell要显示标题、类型图片,测试时,要验证图片是否一致可以转化为验证图片地址是否相等,由于这个demo,我设计图片的获取是由固定地址获取的,图片url不是从接口获取,而是根据从接口获取的type,通过MyModel内部判断输出成相对应的picUrl,所以对比cell的图片是否就是数据源对应的图片,只需要对比它的type跟数据源的type是否一致即可,毕竟在第【一】篇文章里面,已经测试了MyModel是可以根据type获取对应图片的url的。而由于models之间无论title、picUrl或type,这些属性值都可以相同,所以为了辨别model的唯一性,需要引入id属性,id是model唯一性的标记。
为MyModel类添加someId, title两个属性:
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSUInteger, ModelType){
ModelTypeA = 0,
ModelTypeB,
ModelTypeC
};
@interface MyModel : NSObject
@property (nonatomic, copy) NSString *someId;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, assign) ModelType type;
@property (nonatomic, copy) NSString *picUrl;
@end
【tc 3.4,测试数据源为表格返回MyCell类型的cell对象】
//MyTableViewDataSourceTests.m
/**
tc 3.4
*/
- (void)test_ProvideMyCellInstance{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
UITableViewCell *cell = [dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
XCTAssertTrue([cell isKindOfClass:[MyCell class]]);
}
新建MyCell类:
#import <UIKit/UIKit.h>
#import "MyModel.h"
@interface MyCell : UITableViewCell
@property (nonatomic, strong) MyModel *model;
@end
并修改数据源类的提供cell方法的实现,让测试通过:
// MyTableViewDataSource.m
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
return [[MyCell alloc] init];
}
【tc 3.5,测试第一个cell拿到的数据就是第一个数据】
【tc 3.6,测试第二个cell拿到的数据就是第二个数据】
// MyTableViewDataSourceTests
/**
tc 3.5
*/
- (void)test_FirstCellHasFirstDataModel{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
dataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"}];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
MyCell *cell = (MyCell *)[dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
XCTAssertTrue([cell.model.someId isEqualToString:@"0001"]);
XCTAssertTrue(cell.model.type == ModelTypeA);
XCTAssertTrue([cell.model.picUrl isEqualToString:@"AUrl"]);
XCTAssertTrue([cell.model.title isEqualToString:@"Type A Title"]);
}
/**
tc 3.6
*/
- (void)test_SecondCellHasSecondDataModel{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
dataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:1 inSection:0];
MyCell *cell = (MyCell *)[dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
XCTAssertTrue([cell.model.someId isEqualToString:@"0002"]);
XCTAssertTrue(cell.model.type == ModelTypeB);
XCTAssertTrue([cell.model.picUrl isEqualToString:@"BUrl"]);
XCTAssertTrue([cell.model.title isEqualToString:@"Type B Title"]);
}
修改数据源类的提供cell的方法的实现,来让上面两个测试通过:
// MyTableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
MyModel *model = [[MyModel alloc] init];
model.someId = self.theDataArray[indexPath.row][@"someId"];
model.type = [self.theDataArray[indexPath.row][@"type"] integerValue];
model.title = self.theDataArray[indexPath.row][@"title"];
MyCell *cell = [[MyCell alloc] init];
cell.model = model;
return cell;
}
到这里,(3)的第二项测试重点“测试数据源为表格提供了正确的Cells”已经完成,我们已经保证了每一行的cell都使用了其对应的数据源。但是这样还不够,注意到cell提供方法【- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath】当前的实现并没有使用Cell的重用机制,而这对于表格视图的使用是很重要的,所以我们要多写一些测试用例来覆盖这一点。我要把MyCell的reuseIdentifier设计成一个可以用MyCell的类方法来获取到的字符串,这样只要用到MyCell的地方,都可以获取到一份同样的reuseIdentifier。如果MyCell类被注册成表格对象的可重用Cell时,表格对象便可以通过队列获取cell的方法【- (nullable __kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier】来获取到它的可重用对象,当还没有可重用cell时,也会自动创建一个新的cell;反之未注册的话,调用这个方法会返回nil。如果我们在数据源的cell提供方法里面使用了重用机制,那么,在表格对象被注册了MyCell的情况下,调用这个方法就能获取到MyCell对象;表格对象没注册MyCell时,调用这个方法就会返回nil。我们利用这个思路来写关于数据源是否使用了Cell重用机制的测试用例。
执行Red流程。写下失败的测试用例。【tc 3.7】当前实现就可以通过,不过【tc 3.8】会失败。
【tc 3.7,注册了重用Cell后可以拿到cell对象】
【tc 3.8,没有注册了重用Cell不可以拿到cell对象】
// MyTableViewDataSourceTests.m
/**
tc 3.7
*/
- (void)test_ProvideCellIfTableViewRegistedReusableCell{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
[tableView registerClass:[MyCell class] forCellReuseIdentifier:[MyCell reuseIdentifier]];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
UITableViewCell *cell = [dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
XCTAssertNotNil(cell);
XCTAssertTrue([cell isKindOfClass:[MyCell class]]);
}
/**
tc 3.8
*/
- (void)test_DoNotProvideCellIfTableViewDoNotRegistedReusableCell{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
UITableViewCell *cell = [dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
XCTAssertNil(cell);
}
执行Green流程,让测试通过。
为MyCell添加获取重用标识符方法:
// MyCell.h
+ (NSString *)reuseIdentifier;
// MyCell.m
+ (NSString *)reuseIdentifier{
return @"MyCell";
}
修改数据源类的cell提供方法的实现:
// MyTableViewDataSource.m
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
MyModel *model = [[MyModel alloc] init];
model.someId = self.theDataArray[indexPath.row][@"someId"];
model.type = [self.theDataArray[indexPath.row][@"type"] integerValue];
model.title = self.theDataArray[indexPath.row][@"title"];
MyCell *cell = (MyCell *)[tableView dequeueReusableCellWithIdentifier:[MyCell reuseIdentifier]];
cell.model = model;
return cell;
}
重新运行整个MyTableViewDataSourceTests.m文件里面的测试用例,发现【tc 3.7,tc 3.8】通过了,但是之前的【tc 3.4,tc 3.5,tc 3.6】又失败了,这当然是在意料之中的,毕竟,它们使用的表格对象没有注册重用Cell,所以它们获取不到cell对象。不过,本轮的Green流程算是完成了。接下来要修复失败的用例,并且可以趁此时重构测试代码,因为测试代码里面有了很多可以提取到setUp方法去执行的冗余代码,所以我们执行一个Refactor流程。
修改测试文件MyTableViewDataSourceTests的代码,除了【tc 3.8】把其他用例的公共部分代码提取到setUp方法执行。
@interface MyTableViewDataSourceTests : XCTestCase
@property (nonatomic, strong) MyTableViewDataSource *dataSource;
@property (nonatomic, strong) UITableView *theTableView;
@end
@implementation MyTableViewDataSourceTests
- (void)setUp {
[super setUp];
self.dataSource = [[MyTableViewDataSource alloc] init];
self.theTableView = [[UITableView alloc] init];
[self.theTableView registerClass:[MyCell class] forCellReuseIdentifier:[MyCell reuseIdentifier]];
}
- (void)tearDown {
self.dataSource = nil;
self.theTableView = nil;
[super tearDown];
}
重新运行所有测试用例,都通过了,证明我们这次Refactor流程是成功了的。
由于改变实现后的方法【- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath】强制要求表格对象注册可重用Cell类,而如果忘记注册了会怎样呢?那么实际运行中这个方法就会崩溃。所以,我们要在方法里面设置断言,强制要求数据源对象的使用表格注册可重用Cell。我们通过测试用例保证,如果没有注册Cell,执行这个方法会抛异常。
// MyTableViewDataSourceTests
/**
tc 3.9
*/
- (void)test_AssertFailureIfTableViewDidNotRegistReuseCell{
UITableView *tableView = [[UITableView alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
XCTAssertThrows([dataSource tableView:tableView cellForRowAtIndexPath:indexPath]);
}
修改数据源的cell提供方法代码让这个用例通过:
\\ MyTableViewDataSource.m
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath{
MyModel *model = [[MyModel alloc] init];
model.someId = self.theDataArray[indexPath.row][@"someId"];
model.type = [self.theDataArray[indexPath.row][@"type"] integerValue];
model.title = self.theDataArray[indexPath.row][@"title"];
MyCell *cell = (MyCell *)[tableView dequeueReusableCellWithIdentifier:[MyCell reuseIdentifier]];
NSAssert(cell, @"要将MyCell注册给表格视图对象");
cell.model = model;
return cell;
}
重新运行所有测试后发现,【tc 3.8】失败了,因为执行它里面的代码UITableViewCell *cell = [dataSource tableView:tableView cellForRowAtIndexPath:indexPath];
后变触发了异常。这个测试用例本来就是用来测试当表格对象没有注册重用Cell时,数据源的cell提供方法会失败,现在有了【tc 3.9】后,这个用例我们就可以删掉了。
经过了【tc 3.1】~【tc 3.9】,我们演示了(3)的“测试数据源为表格提供了正确的行数”和“测试数据源为表格提供了正确的Cells”这两方面可以如何针对重点部分去做单元测试,这些测试用例只有几个,肯定很不完善,但这篇文章只是为了演示demo,思路可以重用和拓展,直到达到真实产品的测试需求。不过,即便只是demo,也有不少可以总结的经验:
1,单元测试只测逻辑,不测UI,当要测跟显示层相关的逻辑时,我们测试的重点是数据源类是否为显示层的类提供了正确的数据。
2,测试驱动的做法让我们的产品代码、功能随着测试用例的增多而得到逐渐增强,我们不用担心产品的实现代码一开始为了能让简单的测试用例通过,而实现得过于简陋(甚至毫无用处),因为当后续测试用例覆盖越来越深越来越广时,相应的产品代码也会得到不断完善的。比如我们的表格数据源类的cell提供方法就是这样,从【tc 3.4】到【tc 3.9】,它得到了不断完善直到符合我们的需求。
待续。。。。
demo:
https://github.com/zard0/TDDListModuleDemo.git