第【三】篇主要展示了如何测试驱动地开发表格试图的数据源类,保证其为表格提供正确的行数和Cells。这一篇主要将继续展示如何测试驱动地开发表格试图的数据源兼代理类,要实现:【功能4-1】当数据源更新数据时,刷新表格;【功能4-2】当表格的Cell被点击时,代理类响应点击事件,并正确地传递参数给控制器;【功能4-3】控制器在接收到数据源兼代理传递的参数后,可以根据参数跳转到正确的下一级界面。
一,开发【功能4-1】
这一功能的实现涉及三个环节:
【环节4-1-1】数据源类的数据被更新。
【环节4-1-2】数据源类通知控制器数据被更新了。
【环节4-1-3】控制器调用表格的刷新方法刷新表格。
开发【环节4-1-1】
这一环节当前不需要做什么测试和开发,数据源类已经有了可以接纳数据的theDataArray属性,这就可以了。
开发【环节4-1-2】
【Refactor: 提取之前测试用到的判断属性类型的方法,使之成为一个公用的类方法】
这些跟测试相关的辅助类都只是放在测试的target里面而已,不出现在产品代码target。
#import <Foundation/Foundation.h>
@interface NSObject (TestingHelper)
/**
用来判断一个类的某个属性的类型是什么
用法:
1,如果是block类型的属性,这个方法不能识别block的完整sinature,只能告知它是一个block,名字是什么。
返回的字符串样式是:"Block:[属性名]"。
2,如果是id<协议1,协议2>类型,返回字符串样式是:“<[协议1]><[协议2]>”。
3,如果是普通对象属性,返回字符串样式是:“[类名]”。
@param pName 属性名称
@param cName 类名称
@return 属性类型标识字符串
*/
+ (NSString *)typeForProperty:(NSString *)pName inClass:(NSString *)cName;
@end
【Red:tc 4.1,测试数据源类有一个更新block的属性】
// MyTableViewDataSourceTests.m
#import <XCTest/XCTest.h>
#import "MyTableViewDataSource.h"
#import "NSObject+TestingHelper.h"
/**
tc 4.1
*/
- (void)test_HasAnUpdateBlock{
NSString *type = [NSObject typeForProperty:@"updateBlock" inClass:@"MyTableViewDataSource"];
XCTAssertTrue([type isEqualToString:@"Block:updateBlock"]);
}
【Green:数据源类头文件添加updateBlock属性让测试tc 4.1通过】
#import <UIKit/UIKit.h>
@interface MyTableViewDataSource : NSObject<UITableViewDataSource,UITableViewDelegate>
@property (nonatomic, strong) NSArray *theDataArray;
@property (nonatomic, copy) void(^updateBlock)();
@end
【Red:tc 4.2,测试据源类数据更新时updateBlock如果存在会被调用】
// MyTableViewDataSourceTests.m
/**
tc 4.2
*/
- (void)test_ExecuteUpdateBlockIfExistWhenTheDataArrayUpdated{
__block BOOL update = NO;
self.dataSource.updateBlock = ^{
update = YES;
};
self.dataSource.theDataArray = @[];
XCTAssertTrue(update);
}
【Green:数据源类.m文件修改setTheDataArray:方法让测试tc 4.2通过】
// MyTableViewDataSource
- (void)setTheDataArray:(NSArray *)theDataArray{
_theDataArray = theDataArray;
if (self.updateBlock) {
self.updateBlock();
}
}
开发【环节4-1-3】
【tc 4.3,测试控制器的数据源有更新后表格会刷新】
这个测试用例的被测对象是控制器,测试动作是改变它的theDataSource属性的状态,测试要验证的是它的theTableView属性状态有没有对测试动作做出相应状态改变。这里的难点是如何验证theTableView改变了状态,毕竟测试动作不会造成它任何公共属性的值的变更。不过,状态的变更更广泛一点来讲不一定是属性值的变更,也可以表现为它调用了自身的某些方法。所以这里的测试验证转变成如何验证theTableView调用了reloadData方法。验证UITableView类对象是否调用了reloadData的方法我并不知道,感觉也不好做验证。好在有一种我们在单元测试中经常会使用的技术可以解决这个问题,那就是“假对象替换”,这种技术有很多应用场景,这里的应用场景可以说明为:创建一个与真实使用的对象有相同外观行为的假对象,这样假对象就可以被调用方当真对象使用,由于假对象是我们自己定义的,我们可以让假对象具备足够的可测试性,来方便我们验证它是如何被调用方使用的,验证了测试情形中的假对象如何被使用也就验证了真实情形中的真对象如何被使用。我的解释可能不够好,我希望通过对这个测试用例的演示来更好地说明。
我们这里需要用假对象替换的对象是控制器的theTableView,为了让假对象跟它有一样的行为和外观,我们让假对象的类继承于它的类UITableView,这个假对象只在测试中才使用,所以,我们只把它添加到测试target:
@interface FakeTableView : UITableView
/// 可以在要被观察的方法里面使用这个block,让外界知道方法是否被调用,调用时传参是什么
@property (nonatomic, copy) void(^callMethodBlock)(NSString *methodName, NSDictionary *parameters);
@end
#import "FakeTableView.h"
@implementation FakeTableView
/**
重写系统方法,让它支持调用时可以被观察
*/
- (void)reloadData{
if (self.callMethodBlock) {
self.callMethodBlock(@"reloadData", nil);
}
}
@end
当FakeTableView的对象,替换了theTableView的真实对象被控制器调用其执行reloadData方法时,这个执行就会被观察到。
【Red:tc 4.3,测试表格数据源更新时表格应该调用reloadData方法刷新数据】
// MyViewControllerTests.m
#import "FakeTableView.h"
/**
tc 4.3
*/
- (void)test_reloadTableViewWhenDataSouceGetNewData{
MyViewController *vc = [[MyViewController alloc] init];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
FakeTableView *tableView = [[FakeTableView alloc] init];
__block NSString *calledMethod;
tableView.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
calledMethod = methodName;
};
vc.theTableView = tableView;
vc.theDataSource = dataSource;
[vc viewDidLoad];
dataSource.theDataArray = @[];
XCTAssertTrue([calledMethod isEqualToString:@"reloadData"]);
}
【Green:为控制器新添刷新表格逻辑让测试通过】
这里发现控制器的theDataSource属性并不支持真实数据源新添属性的使用,因为id
#import <Foundation/Foundation.h>
@protocol MyDataSourceProtocol <NSObject>
@optional
@property (nonatomic, copy) void(^updateBlock)();
@end
让数据源类遵守和实现这个协议
#import "MyDataSourceProtocol.h"
@interface MyTableViewDataSource : NSObject<UITableViewDataSource,UITableViewDelegate,MyDataSourceProtocol>
@property (nonatomic, strong) NSArray *theDataArray;
@end
// MyTableViewDataSource.m
@implementation MyTableViewDataSource
@synthesize updateBlock;
// 其他代码。。。
@end
修改控制器属性theDataSource的类型:
#import "MyDataSourceProtocol.h"
@interface MyViewController : UIViewController
@property (nonatomic, strong) UITableView *theTableView;
@property (nonatomic, strong) id<UITableViewDataSource,UITableViewDelegate,MyDataSourceProtocol> theDataSource;
@end
在控制器viewDidLoad里面实现刷新逻辑:
// MyViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
__weak typeof(self) wSelf = self;
self.theDataSource.updateBlock = ^{
__strong typeof(self) sSelf = wSelf;
[sSelf.theTableView reloadData];
};
self.theTableView.dataSource = self.theDataSource;
self.theTableView.delegate = self.theDataSource;
}
重新运行所有控制器的测试,发现【tc 4.3】通过了,不过一个之前的用例【tc 2.2.4】失败了,它的失败不是我们产品代码出了bug,而是这个用例已经过时需要更新,修改它让它在新代码下继续通过。
// MyViewControllerTests.m
/**
tc 2.2.4
*/
- (void)test_Property_TheDataSource_ShouldConformUITableViewDataSourceAndUITableViewDelegate{
NSString *typeName = [NSObject typeForProperty:@"theDataSource" inClass:@"MyViewController"];
// 旧的验证
//XCTAssertTrue([typeName isEqualToString:@"<UITableViewDataSource><UITableViewDelegate>"]);
// 新的验证
XCTAssertTrue([typeName isEqualToString:@"<UITableViewDataSource><UITableViewDelegate><MyDataSourceProtocol>"]);
}
二,开发【功能4-2】
【Red:tc 4.4,测试代理类有一个专门的cellTapBlock】
// MyTableViewDataSourceTests
/**
tc 4.4
*/
- (void)test_HasACellTapBlock{
NSString *type = [NSObject typeForProperty:@"cellTapBlock" inClass:@"MyTableViewDataSource"];
XCTAssertTrue([type isEqualToString:@"Block:cellTapBlock"]);
}
【Green:往MyDataSourceProtocol添加cellTapBlock属性,并让代理类实现它】
只要是block就能让测试通过,我们先不管这个block要怎么设计,先把一个block加进来。
@protocol MyDataSourceProtocol <NSObject>
@optional
@property (nonatomic, copy) void(^updateBlock)();
@property (nonatomic, copy) void(^cellTapBlock)();
@end
// MyTableViewDataSource.m
@implementation MyTableViewDataSource
@synthesize cellTapBlock;
// 其他代码。。。
@end
【Red:tc 4.5,测试cell的点击事件代理方法执行后cellTapBlock也会被执行】
// MyTableViewDataSourceTests
/**
tc 4.5
*/
- (void)test_ExecuteCellTapBlockIfCellSelectedMethodCalled{
__block BOOL called = NO;
self.dataSource.cellTapBlock = ^{
called = YES;
};
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[self.dataSource tableView:self.theTableView didSelectRowAtIndexPath:indexPath];
XCTAssertTrue(called);
}
【Green:修改代理类的cell点击代理方法逻辑,让上面测试通过】
// MyTableViewDataSource.m
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
if (self.cellTapBlock) {
self.cellTapBlock();
}
}
【Red:tc 4.6,测试cell的点击事件代理方法执行后cellTapBlock能拿到cell对应的行号】
// MyTableViewDataSourceTests
/**
tc 4.6
*/
- (void)test_CellTapBlockReceiveDataOfTappedCell{
__block NSInteger row = 0;
self.dataSource.cellTapBlock = ^(NSIndexPath *indexPath){
row = indexPath.row;
};
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:1 inSection:0];
[self.dataSource tableView:self.theTableView didSelectRowAtIndexPath:indexPath];
XCTAssertTrue(row == 1);
}
这时候我们终于知道cellTapBlock要设计成什么样了,所以我们修改之前的cellTapBlock,为它添加一个NSIndexPath参数,同时修改【tc 4.5】让它适应测试有参数的block。
【Green:修改代理类cell被选择的代理方法实现逻辑让测试通过】
// MyTableViewDataSource
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
if (self.cellTapBlock) {
self.cellTapBlock(indexPath);
}
}
三,开发【功能4-3】
这个功能要求控制器从表格代理类拿到被点击cell的行号后,根据行号查找数据源对应的数据,解析数据根据数据的type选择push对应类型的控制器,同时把数据的id传给被push的控制器。从代理类执行了cell点击代理方法后的所有过程都是在控制器里面完成的,控制器没有任何暴露的公共属性来记录这些过程的状态变化,除了它的navigationController这个属性,push执行之后,它的topViewController将变成新的控制器,我们用这个属性来检测控制器是否推出了正确的下级界面。
【Red:tc 4.7,测试点击A类型数据cell时push到ATypeViewController】
// MyViewControllerTests.m
/**
tc 4.7
*/
- (void)test_pushATypeViewControllerIfTapATypeCell{
MyViewController *myVC = [[MyViewController alloc] init];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:myVC];
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
UITableView *tableView = [[UITableView alloc] init];
myVC.theTableView = tableView;
myVC.theDataSource = dataSource;
[myVC viewDidLoad];
myVC.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[myVC.theDataSource tableView:myVC.theTableView didSelectRowAtIndexPath:indexPath];
XCTAssertTrue([myVC.navigationController.topViewController isKindOfClass:[ATypeViewController class]]);
}
【Green:修改产品代码,让测试通过】
修改MyDataSourceProtocol,添加theDataArray属性,是的数据源的数据字典数组可以在控制器内部被通过self.theDataSource.theDataArray这种方式使用:
// MyDataSourceProtocol.h
// 添加
@property (nonatomic, strong) NSArray *theDataArray;
// MyTableViewDataSource.m
// 添加
@synthesize theDataArray = _theDataArray;
创建要被push的新的视图控制器:
@interface ATypeViewController : UIViewController
@property (nonatomic, copy) NSString *someId;
@end
修改控制器viewDidLoad的逻辑:
// MyViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
__weak typeof(self) wSelf = self;
self.theDataSource.updateBlock = ^{
__strong typeof(self) sSelf = wSelf;
[sSelf.theTableView reloadData];
};
self.theDataSource.cellTapBlock = ^(NSIndexPath *indexPath) {
__strong typeof(self) sSelf = wSelf;
NSDictionary *data = sSelf.theDataSource.theDataArray[indexPath.row];
if ([data[@"type"] integerValue] == 0) {
ATypeViewController *vc = [[ATypeViewController alloc] init];
[sSelf.navigationController pushViewController:vc animated:NO];
}
};
self.theTableView.dataSource = self.theDataSource;
self.theTableView.delegate = self.theDataSource;
}
经过这些修改后,【tc 4.7】可以运行通过。但是这种测试方式有个缺点,那就是产品代码里面的[self.navigationController pushViewController:vc animated:NO];
方法的animated:参数必须传NO,否则在测试环境中就不能push成功,会发现,虽然产品代码执行了push方法,但是导航控制器的topViewController指向的仍然是MyViewController对象而非ATypeViewController对象,也就是push实际没成功。这个缺点不能容忍,因为一般我们push都会把动画参数设为YES的。
我决定换一种思路去测试控制器的push。根据第【三】篇提到的不要用单元测试去测系统框架的类的测试原则,我们其实不需要去测试最终push的结果,只需要去确认导航控制器调用了push方法,而且调用push方法时传递的参数是正确的,这就可以了。为了实现这种测试思路,我们要像前面创建FakeTableView一样,创建一个FakeNavigationController,用它来替换真实场景中的导航控制器,然后我们观察它的push方法有没有被执行,并拿到被pushed的控制器,验证是不是我们想要的控制器。
我发现前面给FakeTableView使用的用来检测方法是否被调用的block也适合这个FakeNavigationViewController,所以我要把这部分代码抽取到NSObject+TestingHelper这个类别,方便以后的应用需求。
【Refactor:提取测试辅助共用代码,提高测试代码重新性】
补充TestingHelper类别的功能。
NSObject + TestingHelper.h文件:
@interface NSObject (TestingHelper)
/// 可以在要被观察的方法里面使用这个block,让外界知道方法是否被调用,调用时传参是什么
@property (nonatomic, copy) void(^callMethodBlock)(NSString *methodName, NSDictionary *parameters);
/**
方便调用callMethodBlock的方法
@param methodName <#methodName description#>
@param parasDic <#parasDic description#>
*/
- (void)callMethod:(NSString *)methodName parameters:(NSDictionary *)parasDic;
// 其他代码。。。。
@end
NSObject + TestingHelper.m文件:
#import "NSObject+TestingHelper.h"
#import <objc/runtime.h>
static const void * CallMethodBlockKey = &CallMethodBlockKey;
@implementation NSObject (TestingHelper)
- (void(^)(NSString *methodName, NSDictionary *parameters))callMethodBlock{
return objc_getAssociatedObject(self, CallMethodBlockKey);
}
- (void)setCallMethodBlock:(void (^)(NSString *, NSDictionary *))callMethodBlock{
objc_setAssociatedObject(self, CallMethodBlockKey, callMethodBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
/**
方便调用callMethodBlock的方法
@param methodName <#methodName description#>
@param parasDic <#parasDic description#>
*/
- (void)callMethod:(NSString *)methodName parameters:(NSDictionary *)parasDic{
if (self.callMethodBlock) {
self.callMethodBlock(methodName, parasDic);
}
}
// 其他代码。。。
@end
实现FakeNavigationViewController类。
FakeNavigationViewController.h文件:
#import <UIKit/UIKit.h>
#import "NSObject+TestingHelper.h"
@interface FakeNavigationViewController : UINavigationController
/**
拿到push方法名称
@return <#return value description#>
*/
+ (NSString *)pushMethodName;
/**
拿到push方法参数字典的控制器参数的key
@return <#return value description#>
*/
+ (NSString *)pushControllerParaKey;
/**
拿到push方法参数字典的是否执行动画参数的key
@return <#return value description#>
*/
+ (NSString *)pushAnimateParaKey;
@end
FakeNavigationViewController.m文件:
#import "FakeNavigationViewController.h"
@interface FakeNavigationViewController ()
@end
@implementation FakeNavigationViewController
/**
重写系统方法,让这个方法的调用可以被验证
@param viewController <#viewController description#>
@param animated <#animated description#>
*/
- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{
// 注意调用一下super方法,否则在执行initWithRootViewController:方法时就不能达到预期效果了。
[super pushViewController:viewController animated:animated];
[self callMethod:[FakeNavigationViewController pushMethodName]
parameters:@{
[FakeNavigationViewController pushControllerParaKey]:viewController,
[FakeNavigationViewController pushAnimateParaKey]:@(animated)}];
}
+ (NSString *)pushMethodName{
return @"pushViewController:animated:";
}
+ (NSString *)pushControllerParaKey{
return @"Controller";
}
+ (NSString *)pushAnimateParaKey{
return @"Animate";
}
@end
【Red:tc 4.8 测试点击A类型数据cell时,导航控制器会调用push方法,push的控制器是ATypeViewController对象,push的animate参数是YES】
// MyViewControllerTests.m
/**
tc 4.8
*/
- (void)test_pushMethodIsCalledWithATypeViewControllerIfTapATypeCell{
MyViewController *myVC = [[MyViewController alloc] init];
FakeNavigationViewController *nav = [[FakeNavigationViewController alloc] initWithRootViewController:myVC];
__block NSString *name;
__block NSDictionary *paras;
nav.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
name = methodName;
paras = parameters;
};
MyTableViewDataSource *dataSource = [[MyTableViewDataSource alloc] init];
UITableView *tableView = [[UITableView alloc] init];
myVC.theTableView = tableView;
myVC.theDataSource = dataSource;
[myVC viewDidLoad];
myVC.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
[myVC.theDataSource tableView:myVC.theTableView didSelectRowAtIndexPath:indexPath];
XCTAssertTrue([name isEqualToString:[FakeNavigationViewController pushMethodName]]);
XCTAssertTrue([paras[[FakeNavigationViewController pushControllerParaKey]] isKindOfClass:[ATypeViewController class]]);
XCTAssertEqual([paras[[FakeNavigationViewController pushAnimateParaKey]] boolValue] , YES);
}
【Green:修改控制器viewDidLoad逻辑,把push方法的animate参数设为YES】
修改代码后,重新执行所有测试,【tc 4.8】可以通过,【tc 4.7】如预料的失败了,因为它不支持有动画的push,现在有了【tc 4.8】,我们可以把【tc 4.7】删掉了。
在进一步去完善cell的跳转测试之前,可以先执行一下Refactor流程,因为现在MyViewControllerTests.m里面有不少冗余测试代码,而且如果不执行重构的话即将新增的测试用例会带来更多的冗余代码,重构测试代码后,将使得接下来要写的每个测试用例代码行数更少,所以现在这个点重构很有好处。而产品代码里面,MyViewController里面执行跳转时解析的数据源还是字典,并且无法使用我在MyModel里面定义好的类型枚举变量,我希望修改为让它用的是MyModel对象,并且使用上类型枚举。
【Refactor:减少测试类里面的冗余代码】
@interface MyViewControllerTests : XCTestCase
@property (nonatomic, strong) UITableView *theTableView;
@property (nonatomic, strong) MyTableViewDataSource *theDataSource;
@property (nonatomic, strong) MyViewController *theController;
@end
@implementation MyViewControllerTests
- (void)setUp {
[super setUp];
self.theTableView = [[UITableView alloc] init];
self.theDataSource = [[MyTableViewDataSource alloc] init];
self.theController = [[MyViewController alloc] init];
}
- (void)tearDown {
self.theDataSource = nil;
self.theTableView = nil;
self.theController = nil;
[super tearDown];
}
#pragma mark - 重用方法
/**
这部分重用代码我不写在setUp方法,而要写成这个公共方法来让测试用例调用,因为:
1,这部分代码只适合一部分测试用例使用,在用不上setUp里面这些代码的测试用例要减少它们执行无谓的代码的时间。
2,这部分代码也是测试用例比较重要的前置步骤,如果写在setUp,以后查看相关测试用例代码时,可能不自觉会忽略这些前置条件,降低了代码的说明性。
*/
- (void)setupDataSourceAndTableViewThenDoViewDidLoad{
self.theController.theTableView = self.theTableView;
self.theController.theDataSource = self.theDataSource;
[self.theController viewDidLoad];
}
#pragma mark - 测试用例
重构测试代码有些地方要注意,比如上面代码里面的注释提到的点。
现在这个文件里面的测试用例代码变得更简洁了,运行一次,全部用例继续通过,说明重构没问题。
【Refactor:重构MyViewController类的产品代码】
我发现要重构跳转逻辑代码来达到我上面所提的目的,并不是一个微调整就可以的。它让我意识到当前产品代码架构有问题,我明明希望MyViewController的theDataSource是一个能够替换不同实现的数据源引用,来达到让MyViewController可以重用的目的;同时,我却不断要求往这个这个引用指向的类型上去添加只属于当前模块才有的功能,如果说目前追加的MyDataSourceProtocol上面的内容还属于有一定通用性的话,那么我刚刚准备的让数据源公共属性cellTapBlock的传参变成传MyModel对象,好让控制器在这个block里面不用解析字典数据,直接读取model数据,直接使用ModelType枚举,这一意图则将彻底让theDataSource属性沦落到只接受当前模块数据源的境地了。现在,为了实现上述重构需求,我有两个代码架构的方向可考虑:第一,丢弃MyViewController的重用想法,让它只与当前模块的数据源结合,成为专用控制器;第二,依然坚持要让MyViewController通过更换数据源实现重用,只是,不能单单只有数据源可替换,还得把Cell的跳转逻辑也抽离出来,让其类似于数据源一样被MyViewController所依赖,然后也可以替换,来达到让MyViewController彻底不去依赖当前模块任何一项专有功能的实现细节,转而只依赖它们遵循的协议。
看上去我更喜欢第二种方案,毕竟易修改、易拓展、可重用的架构怎么也比无法重用、耦合性强的架构好吧。可是,学习测试驱动开发的同时也让我看重了一项软件开发原则,即不要去做多余的事情,也许这个原则这样表达还不够贴切。那么参考老外们的说法,英文里这一原则叫YAGNT(You Aren’t Going to Need It)。只要我们的产品代码,让我们的所有的测试都通过了,那么,我们的开发工作就做完了。那些我们费尽心思设计的精巧架构,或许并不需要。
纠结啊,到底要怎么选择?
待续。。。。
demo:
https://github.com/zard0/TDDListModuleDemo.git