美文网首页
iOS尝试用测试驱动的方法开发一个列表模块【五】

iOS尝试用测试驱动的方法开发一个列表模块【五】

作者: zard0 | 来源:发表于2017-08-24 22:12 被阅读0次

    第【四】篇的最后,我说道我碰到了一个令人纠结的代码重构的选择方案问题,到底选择让控制器成为可重用的控制器还是成为专用的控制器。让控制器可重用的重构方案,会让代码具备更好的重用性、可变性和可测试性,我喜欢这种追求,我估摸着要做到这一点,工作量不会太大,所以我选择这种重构方案。

    那么现在最主要的是重构cell的跳转部分的代码,我将把这部分代码从控制器里面剥离出来,放到独立的跳转类里面,然后让控制器通过协议依赖这个跳转类。我会让数据源代理类通过控制器跟跳转类协作,通过数据源代理类传递的数据决定让跳转类怎么执行跳转。跳转类引入了专有模块的数据model,要跳转的控制器,这两个类都不应该要求控制器知道,所以跳转类与数据源代理类相互间协作所使用到的公共方法都不涉及具体数据模型,数据源代理类像外界传的是id类型的数据,跳转类拿到id类型的数据后,自身判断它是不是自己所需的model,是的话就解析model做跳转,否则什么也不做。虽然如果让跳转类直接被数据源代理类所依赖的话,那么它们之间的交互可以使用模块专有的数据model,似乎很多事情会更方便,但是因为跳转类跟UI打交道,他需要知道要跳转的目的控制器和执行跳转的导航控制器,而数据源代理类,为了方便测试,并保持它的纯粹性,我希望它只做数据逻辑,不依赖UI相关的类(除了UITableViewCell),所以跳转类不会跟数据源类有直接关系,它将被控制器强引用,被控制器在数据源代理类响应表格cell点击事件后所使用。它被使用的公共方法将在它与控制器约定的协议里面定义。

    有了想法后,我们继续用测试驱动的方式进行开发。

    一,为控制器添加一个引用跳转类的属性。

    【Red:tc 5.1,控制器属性theJumper遵循JumperProtocol协议】
    MyViewControllerTests.m文件:

    /**
     tc 5.1
     */
    - (void)test_Property_TheJumper_ConformJumperProtocol{
        NSString *typeName = [NSObject typeForProperty:@"theJumper" inClass:@"MyViewController"];
        XCTAssertTrue([typeName isEqualToString:@"<JumperProtocol>"]);
    }
    

    【Green,定义JumperProtocol,往控制器添加theJumper属性,让测试通过】
    MyViewController.h文件:

    #import <UIKit/UIKit.h>
    #import "MyDataSourceProtocol.h"
    #import "JumperProtocol.h"
    
    @interface MyViewController : UIViewController
    
    @property (nonatomic, strong) UITableView *theTableView;
    @property (nonatomic, strong) id<UITableViewDataSource,UITableViewDelegate,MyDataSourceProtocol> theDataSource;
    @property (nonatomic, strong) id<JumperProtocol> theJumper;
    
    @end
    

    JumperProtocol.h文件:

    #import <Foundation/Foundation.h>
    
    /**
     跳转类与控制器约定的协议
     */
    @protocol JumperProtocol <NSObject>
    
    @end
    

    【Red:tc 5.2,控制器theJumper属性是强引用】

    /**
     tc 5.2
     */
    - (void)test_Property_TheJumper_IsStronglyRefered{
        @autoreleasepool {
            self.theController.theJumper = (id<JumperProtocol>)[[NSObject alloc] init];
        }
        // weak引用,会被自动释放池释放,强引用不会。
        XCTAssertNotNil(self.theController.theJumper);
    }
    

    【Green,当前定义的属性已经满足此测试用例】

    二,实现一个专门的cell点击跳转类。

    【Red,tc 5.3 跳转类要实现JumperProtocol协议】
    新建一个关于跳转类的测试类,添加这个测试用例。
    MyJumperTests.m文件:

    /**
     tc 5.3
     */
    - (void)test_ShouldConformJumperProtocol{
        MyCellJumper *jumper = [[MyCellJumper alloc] init];
        XCTAssertTrue([jumper conformsToProtocol:@protocol(JumperProtocol)]);
    }
    

    【Green,创建MyCellJumper类并让它遵循JumperProtocol协议,让上面测试用例通过】
    MyCellJumper.h文件:

    #import "JumperProtocol.h"
    
    @interface MyCellJumper : NSObject <JumperProtocol>
    
    @end
    

    【Red:tc 5.4 跳转类要实现一个接受一个id类型的参数的跳转方法】
    MyJumperTests.m文件:

    /**
     tc 5.4
     */
    - (void)test_Method_ToControllerWithData_ShouldBeImplemented{
        MyCellJumper *jumper = [[MyCellJumper alloc] init];
        XCTAssertTrue([jumper respondsToSelector:@selector(toControllerWithData:)]);
    }
    

    【Green,给JumperProtocol协议添加方法- (void)
    toControllerWithData:(id)data,并让MyCellJumper实现它,让上面测试用例通过】
    JumperProtocol.h文件:

    /**
     各个模块的jumper实现这个方法时,要在方法里面对data做判断,data是想要的数据时,才解析
     拿出数据,执行跳转。
    
     @param data <#data description#>
     */
    - (void)toControllerWithData:(id)data;
    

    MyCellJumper.m文件:

    #import "MyCellJumper.h"
    
    @implementation MyCellJumper
    
    #pragma mark - JumperProtocol
    
    - (void)toControllerWithData:(id)data{
        
    }
    
    @end
    

    【Red:tc 5.5,MyCellJumper应该实现一个依赖于导航控制器的初始化方法】
    MyJumperTests.m文件:

    /**
     tc 5.5
     */
    - (void)test_Method_InitWithNavigationController_ShouldBeImplemented{
        MyCellJumper *jumper = [[MyCellJumper alloc] init];
        XCTAssertTrue([jumper respondsToSelector:@selector(initWithNavigationController:)]);
    }
    

    因为跳转类一定要用到导航控制器,所以吧这个初始化方法作为协议必须实现的方法。
    【Green:往JumperProtocol里面添加- (instancetype)initWithNavigationController:(UINavigationController *)navVC方法,并让MyCellJumper.m实现它】
    JumperProtocol.h文件:

    /**
     这是应该被使用的正确的初始化方法。
     1,navVC不能为空。
     2,navVC应该在内部被弱引用。
    
     @param navVC <#navVC description#>
     @return <#return value description#>
     */
    - (instancetype)initWithNavigationController:(UINavigationController *)navVC;
    

    MyCellJumper.m文件:

    - (instancetype)initWithNavigationController:(UINavigationController *)navVC{
        return nil;
    }
    

    现在一般跳转类所需的公共方法已经设计完成,接下来看怎么实现MyCellJumper这个跳转类的这些方法,来保证它能够被正确初始化和实现正确的跳转。
    【Red:tc 5.6,MyCellJumper的初始化方法不能传入空的导航控制器,否则会触发断言异常】
    MyJumperTests.m文件:

    /**
     tc 5.6
     */
    - (void)test_ShouldNotPassNilWhenInitWithNavigationController{
        XCTAssertThrows([[MyCellJumper alloc] initWithNavigationController:nil]);
    }
    

    【Green,在MyCellJumper的初始化方法里面加入判断导航控制器是否存在的断言】
    MyCellJumper.m文件:

    - (instancetype)initWithNavigationController:(UINavigationController *)navVC{
        NSAssert(navVC, @"导航控制器不能为nil");
        return nil;
    }
    

    【Red:tc 5.7,MyCellJumper对导航控制器的持有应该是弱引用】
    MyCellJumperTests.m文件:

    /**
     tc 5.7
     */
    - (void)test_NavigationController_ShouldBeWeaklyRefered{
        __block MyCellJumper *jumper;
        @autoreleasepool {
            UINavigationController *navVC = [[UINavigationController alloc] init];
            jumper = [[MyCellJumper alloc] initWithNavigationController:navVC];
        }
        XCTAssertNil(jumper.navigationController);
    }
    

    这里碰到了有趣的事情,为了写上面的测试用例,需要MyCellJumper暴露一个导航控制器的引用属性,这个属性本可以不暴露的,但是,我们为了增强类的可测试性,把它暴露出来了,这种暴露与不暴露是需要平衡的,毕竟有些情况,暴露的东西多了,就破坏了类的封装性了,而什么都不暴露,类就没有很好的可测试性,不利于我们做单元测试。这里可以看出测试驱动开发的一个好处,即在开发过程中促使我们去考虑如何让代码为测试提供方便。毕竟,若我们不考虑测试性,那么我们的测试用例便写不下去了。针对MyCellJumper这个类,我认为暴露一个只读的指向导航控制器的属性是可以的,我们不用担心它会被无意地修改,也满足了我们的测试需求。
    【Green:在MyCellJumper类的初始化方法里面,把传入的导航控制器付给它的navigationController属性,这个属性是weak, readonly修饰的】
    MyCellJumper.h文件:

    #import "JumperProtocol.h"
    
    @interface MyCellJumper : NSObject <JumperProtocol>
    
    @property (nonatomic, readonly, weak) UINavigationController *navigationController;
    
    @end
    

    MyCellJumper.m文件:

    - (instancetype)initWithNavigationController:(UINavigationController *)navVC{
        NSAssert(navVC, @"导航控制器不能为nil");
        _navigationController = navVC;
        return nil;
    }
    

    满足了【tc 5.6,tc 5.7】的初始化方法还不能用,再添加一个测试用例让它变成真正的初始化方法
    【Red:tc 5.8,MyCellJumper类的初始化方法要返回一个MyCellJumper对象,对象的navigationController属性应该引用一个导航控制器对象】
    MyJumperTests.m文件:

    /**
     tc 5.8
     */
    - (void)test_InitMethod_ShouldReturnASelfTypeInstance_And_Property_navigationController_ShouldReferANavigationControllerInstanceAfterInit{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        id obj = [[MyCellJumper alloc] initWithNavigationController:navVC];
        XCTAssertTrue([obj isKindOfClass:[MyCellJumper class]]);
        MyCellJumper *jumper = obj;
        XCTAssertTrue([jumper.navigationController isKindOfClass:[UINavigationController class]]);
    }
    

    【Green,修改MyCellJumper的初始化方法】
    MyCellJumper.m文件:

    - (instancetype)initWithNavigationController:(UINavigationController *)navVC{
        NSAssert(navVC, @"导航控制器不能为nil");
        if (self = [super init]) {
            _navigationController = navVC;
        }
    
        return self;
    }
    
    

    对MyCellJumper的初始化方法,我们已经用测试用例覆盖得差不多了,在接下去开发跳转方法之前,先对测试代码执行一个Refactor流程,因为发现了大部分MyJumperTests.m里面的测试用例的新建一个MyCellJumper对象的代码可以重用,因此把这部分代码提取到setUp方法去执行。
    【Refactor:提取各个测试用例的可重用代码到setUp方法,用类成员变量self.jumper对象代替一些测试用例里面的局部变量jumper对象】
    MyJumperTests.m文件:

    @interface MyJumperTests : XCTestCase
    
    @property (nonatomic, strong) UINavigationController *navVC;
    @property (nonatomic, strong) MyCellJumper *jumper;
    
    @end
    
    @implementation MyJumperTests
    
    - (void)setUp {
        [super setUp];
        self.navVC = [[UINavigationController alloc] init];
        self.jumper = [[MyCellJumper alloc] initWithNavigationController:self.navVC];
    }
    
    - (void)tearDown {
        self.navVC = nil;
        self.jumper = nil;
        [super tearDown];
    }
    

    重构后,重新运行所有MyJumperTests.m里面的测试用例,仍然全部通过,说明重构没问题。

    接下来将针对跳转类真正处理跳转逻辑的核心方法做测试驱动开发。将跳转逻辑封装进独立的类来处理,将让这部分逻辑变得非常有利于做单元测试。通过给这个类传参,再观察它能否对可能情形的参数做出正确的处理,产生正确的目的控制器对象,并执行了导航控制器的push方法,我们就能用单元测试用例充分覆盖到所有需要测试的逻辑。

    现在继续往MyJumperTests.m里面添加测试用例,并继续修改MyCellJumper类让这些测试用例通过。

    首先测试跳转方法对异常情况的处理,当传入nil和非MyModel类型的参数时它不执行任跳转。

    如何验证不执行跳转?其实就是验证导航控制器没有调用push方法。所以这里要用到文章【四】里面创建的FakeNavigationViewController来代替真实的导航控制器来跟跳转类交互,因为唯有在假导航控制器对象里面,我们经过了可测试处理后,才能感知push方法是否执行了。其实这里也不能说它是假对象,比较它是真导航控制器的子类,能执行真正的push方法,更准确的说法是它是一个可测试性的导航控制器对象。无论是用假对象,或可测试对象来替换产品代码里面原有的对象,都是用了同样的测试技术,将被测对象与其依赖的对象隔离开来,用我们设计好的假对象来替换这些依赖的对象,然后我们就可以通过感知到假对象与被测对象交互过程中发生的变化来测试被测对象的外资行为。至于怎么创建假对象来实现这种隔离,一般有通过接口隔离,通过子类替换,通过方法替换等技术方法。这里使用的就是通过子类替换的方法。

    【tc 5.9,保证跳转方法传nil时不跳转】
    【tc 5.10,保证跳转方法传非MyModel类型参数时不跳转】
    MyJumperTests.m文件:

    /**
     tc 5.9
     */
    - (void)test_Method_ToControllerWithData_DoNotPushWithNil{
        __block NSString *calledMethod;
        FakeNavigationViewController *nav = [[FakeNavigationViewController alloc] init];
        nav.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            calledMethod = methodName;
        };
        MyCellJumper *jumper = [[MyCellJumper alloc] initWithNavigationController:nav];
        [jumper toControllerWithData:nil];
        XCTAssertNil(calledMethod);
    }
    
    /**
     tc 5.10
     */
    - (void)test_Method_ToControllerWithData_DoNotPushWithNotMyModelTypeData{
        __block NSString *calledMethod;
        FakeNavigationViewController *nav = [[FakeNavigationViewController alloc] init];
        nav.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            calledMethod = methodName;
        };
        MyCellJumper *jumper = [[MyCellJumper alloc] initWithNavigationController:nav];
        NSObject *otherPara = [[NSObject alloc] init];
        [jumper toControllerWithData:otherPara];
        XCTAssertNil(calledMethod);
    }
    

    不需要对现有MyCellJumper代码做任何改动,这两个测试用例也会通过,因为跳转方法还什么都没做呢。

    然后测试跳转方法在传入MyModel类型参数时能否实现正确的跳转。
    【Red:tc 5.11,测试当model数据类型为A类型时,要跳转到A类型指定控制器】
    MyJumperTests.m文件:

    /**
     tc 5.11
     */
    - (void)test_JumpToATypeViewController_WithATypeData{
        __block NSString *calledMethod;
        __block UIViewController *controller;
        FakeNavigationViewController *nav = [[FakeNavigationViewController alloc] init];
        nav.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            calledMethod = methodName;
            controller = parameters[[FakeNavigationViewController pushControllerParaKey]];
        };
        MyCellJumper *jumper = [[MyCellJumper alloc] initWithNavigationController:nav];
        NSObject *otherPara = [[NSObject alloc] init];
        [jumper toControllerWithData:otherPara];
        XCTAssertTrue([calledMethod isEqualToString:[FakeNavigationViewController pushMethodName]]);
        XCTAssertTrue([controller isKindOfClass:[ATypeViewController class]]);
    }
    

    【Green:往跳转方法里面实现对A类型数据的跳转逻辑】
    MyCellJumper.m文件:

    - (void)toControllerWithData:(id)data{
        MyModel *model = data;
        if (model.type == ModelTypeA) {
            ATypeViewController *vc = [[ATypeViewController alloc] init];
            [self.navigationController pushViewController:vc animated:YES];
        }
    }
    

    运行MyJumperTests.m里面所有测试用例,【tc 5.11】通过了,但是【tc 5.9,tc 5.10】失败了。

    image.png

    虽然前面【tc 5.9,tc 5.10】一开始不用做任何代码修改它们就通过了,感觉没什么用处,但此刻它们起到了捕获bugs的作用,它们分别揭示了当前跳转方法的实现的两个问题:1,传参为nil时也能满足model.type == ModelTypeA的条件;2,传参为非nil非MyModel类型数据时将会因为unrecognized selector问题发生崩溃。
    我们继续完善跳转方法的实现,修复着两个bugs。
    MyCellJumper.m文件:

    - (void)toControllerWithData:(id)data{
        if (!data || ![data isKindOfClass:[MyModel class]]) {
            return;
        }
        MyModel *model = data;
        if (model.type == ModelTypeA) {
            ATypeViewController *vc = [[ATypeViewController alloc] init];
            [self.navigationController pushViewController:vc animated:YES];
        }
    }
    

    终于,现在我们让所有测试用例都Green了,这感觉真棒!
    接下来还有对B类型、C类型数据的跳转的测试用例需要添加,不过在进一步测试之前,我们又发现了这是可以进行一次Refactor流程的好时机,因为【tc 5.9,tc 5.10,tc 5.11】之间有不少冗余代码可以清理。
    【Refactor:清理测试用例冗余代码,让它们更简洁】
    将冗余代码放入setUp文件。
    MyJumperTests.m文件:

    @interface MyJumperTests : XCTestCase
    
    @property (nonatomic, strong) UINavigationController *navVC;
    @property (nonatomic, strong) FakeNavigationViewController *fakeNavVC;
    @property (nonatomic, strong) MyCellJumper *jumper;
    @property (nonatomic, strong) MyCellJumper *jumperWithFakeNavVC;
    @property (nonatomic, strong) UIViewController *pushedController;
    @property (nonatomic, strong) NSString *pushMethod;
    
    @end
    
    @implementation MyJumperTests
    
    - (void)setUp {
        [super setUp];
        // 依赖于可测试导航栏控制器的jumper
        self.fakeNavVC = [[FakeNavigationViewController alloc] init];
        __weak typeof(self) wSelf = self;
        self.fakeNavVC.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            __strong typeof(self) sSelf = wSelf;
            sSelf.pushMethod = methodName;
            sSelf.pushedController = parameters[[FakeNavigationViewController pushControllerParaKey]];
        };
        self.jumperWithFakeNavVC = [[MyCellJumper alloc] initWithNavigationController:self.fakeNavVC];
        // 正常的jumper
        self.navVC = [[UINavigationController alloc] init];
        self.jumper = [[MyCellJumper alloc] initWithNavigationController:self.navVC];
    }
    
    - (void)tearDown {
        self.navVC = nil;
        self.jumper = nil;
        self.pushedController = nil;
        self.pushMethod = nil;
        [super tearDown];
    }
    
    

    【tc 5.9,tc 5.10,tc 5.11】由原来的一长串代码变成很少的几行代码,而且可以预期,接下来新增的两个数据类型跳转的测试用例也仍然是几行代码。
    MyJumperTests.m文件:

    /**
     tc 5.9
     */
    - (void)test_Method_ToControllerWithData_DoNotPushWithNil{
        [self.jumperWithFakeNavVC toControllerWithData:nil];
        XCTAssertNil(self.pushMethod);
    }
    
    /**
     tc 5.10
     */
    - (void)test_Method_ToControllerWithData_DoNotPushWithNotMyModelTypeData{
        NSObject *otherPara = [[NSObject alloc] init];
        [self.jumperWithFakeNavVC toControllerWithData:otherPara];
        XCTAssertNil(self.pushMethod);
    }
    
    /**
     tc 5.11
     */
    - (void)test_JumpToATypeViewController_WithATypeData{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeA;
        [self.jumperWithFakeNavVC toControllerWithData:model];
        XCTAssertTrue([self.pushMethod isEqualToString:[FakeNavigationViewController pushMethodName]]);
        XCTAssertTrue([self.pushedController isKindOfClass:[ATypeViewController class]]);
    }
    

    这次Refactor效果不错,在很好地减少了测试代码的同时,让测试用例的测试意图表达得更简洁直观了。

    现在开始添加对B、C类型,和其他类型的数据的跳转逻辑,完成我们的跳转方法的测试开发。
    【Red:tc 5.12,测试保证B类型数据跳转到B类型指定控制器】
    MyJumperTests.m文件:

    
    /**
     tc 5.12
     */
    - (void)test_JumpToBTypeViewController_WithBTypeData{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeB;
        [self.jumperWithFakeNavVC toControllerWithData:model];
        XCTAssertTrue([self.pushMethod isEqualToString:[FakeNavigationViewController pushMethodName]]);
        XCTAssertTrue([self.pushedController isKindOfClass:[BTypeViewController class]]);
    }
    
    /**
     tc 5.13
     */
    - (void)test_JumpToCTypeViewController_WithCTypeData{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeC;
        [self.jumperWithFakeNavVC toControllerWithData:model];
        XCTAssertTrue([self.pushMethod isEqualToString:[FakeNavigationViewController pushMethodName]]);
        XCTAssertTrue([self.pushedController isKindOfClass:[CTypeViewController class]]);
    }
    
    /**
     tc 5.14
     */
    - (void)test_DoNotPushWhenMyModelTypeDataWithOtherTypeValue{
        MyModel *model = [[MyModel alloc] init];
        model.type = 100;
        [self.jumperWithFakeNavVC toControllerWithData:model];
        XCTAssertNil(self.pushMethod);
        XCTAssertNil(self.pushedController);
    }
    

    【Green:新建BTypeViewController、CTypeViewController类,修改跳转方法实现】
    MyCellJumper.m文件:

    - (void)toControllerWithData:(id)data{
        if (!data || ![data isKindOfClass:[MyModel class]]) {
            return;
        }
        MyModel *model = data;
        UIViewController *vc;
        switch (model.type) {
            case ModelTypeA:
                vc = [[ATypeViewController alloc] init];
                break;
            case ModelTypeB:
                vc = [[BTypeViewController alloc] init];
                break;
            case ModelTypeC:
                vc = [[CTypeViewController alloc] init];
                break;
            default:{
                return;
            }
                break;
        }
        [self.navigationController pushViewController:vc animated:YES];
    }
    

    至此,跳转方法已经测试开发完成,同时,这个cell的专门跳转类也已经开发测试完成,下一步,就是要在控制器里面使用它,看能不能达到我们将控制器与跳转逻辑解耦的目的。

    三,用跳转类在控制器里面实现cell的跳转逻辑。

    首先要修改数据源代理类MyTableViewDataSource的cellTapBlock,让它传递一个id类型的参数用来给跳转类MyCellJumper接收。原来有一个相关的测试用例【tc 4.6】,它当前测试的是cell被tapped后是否将cell的row通过cellTapBlock传递了出去,我们现在修改让它传递cell的数据模型。

    【Red:tc 4.6,修改为表格数据源代理类在cell被点击时应该要将cell对应的数据model传递给外界】
    这是对数据源代理类的修改,所以测试用例放在它对应的测试类里面。
    MyTableViewDataSourceTests.m文件:

    /**
     tc 4.6
     */
    - (void)test_CellTapBlockReceiveDataOfTappedCell{
        self.dataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
        __block id model;
        self.dataSource.cellTapBlock = ^(id dataModel){
            model = dataModel;
        };
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:1 inSection:0];
        [self.dataSource tableView:self.theTableView didSelectRowAtIndexPath:indexPath];
        XCTAssertNotNil(model);
        XCTAssertTrue([model isKindOfClass:[MyModel class]]);
        MyModel *cellModel = model;
        XCTAssertTrue([cellModel.someId isEqualToString:@"0002"]);
        XCTAssertTrue([cellModel.title isEqualToString:@"Type B Title"]);
        XCTAssertTrue(cellModel.type == ModelTypeB);
    }
    
    

    显而易见的,我发现另一个测试用例【tc 4.5】也得做响应的修改,把cellTapBlock的参数改为id类型。
    MyTableViewDataSourceTests.m文件:

    /**
     tc 4.5
     */
    - (void)test_ExecuteCellTapBlockIfCellSelectedMethodCalled{
        __block BOOL called = NO;
        self.dataSource.cellTapBlock = ^(id dataModel){
            called = YES;
        };
        NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
        [self.dataSource tableView:self.theTableView didSelectRowAtIndexPath:indexPath];
        XCTAssertTrue(called);
    }
    

    做完这些改动,我这次没有全部运行一次所有测试用例,我就先去改产品代码了。
    【Green:修改数据源代理类与控制器之间的交互协议,修改数据源代理类的cell选择代理方法的实现】
    在MyDataSourceProtocol.h文件里面修改cellTapBlock的参数:

    @protocol MyDataSourceProtocol <NSObject>
    
    @optional
    @property (nonatomic, strong) NSArray *theDataArray;
    @property (nonatomic, copy) void(^updateBlock)();
    @property (nonatomic, copy) void(^cellTapBlock)(id dataModel);
    
    @end
    

    在MyTableViewDataSource.m文件里面修改cell选择的代理方法的实现:

    - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath{
        if (self.cellTapBlock) {
            MyModel *model = [[MyModel alloc] init];
            NSDictionary *data = self.theDataArray[indexPath.row];
            model.someId = data[@"someId"];
            model.title = data[@"title"];
            model.type = [data[@"type"] integerValue];
            self.cellTapBlock(model);
        }
    }
    

    然后,执行一次整个工程的所有测试用例,以上的两个测试用例重新通过了,但是发现了控制器的测试用例里面有一个失败了。


    image.png

    反正我们也要开始改控制器的代码了,就把这个测试用例作为我们这次对控制器修改的第一个Red流程吧。只不过,它的命名和实现都要修改一番,以表达我们新的测试意图。原来我们的做法是通过让控制器调用数据源代理类的cell选择代理方法,然后检测控制器的导航控制器属性(通过用Fake替换真实的导航栏控制器的方法来感知)是否拿到了正确的要被pushed的控制器对象,是否执行了push方法,来验证cell的选择事件是否导致了正确的push行为。现在我们不用这么做了,因为,我们已经在MyJumperTests.m里面对跳转类的跳转逻辑进行了单元测试覆盖,而且都测试通过,说明了MyJumper类是可靠的,在控制器这边的跳转逻辑测试,我们只需要测试在cell被选择后,控制器的theJumper对象是否执行了跳转方法,以及它的跳转方法是否拿到了正确的参数即可。那么如何感知theJumper对象是否执行了方法,拿到了正确的参数?我们是不是继续像前面做法一样通过FakeNavigationViewController来感知它是否执行了push和拿到了要push的控制器对象?不,因为现在我们要测的是控制器,跳转逻辑部分是MyJumper类对象直接与控制器打交道,导航控制器对象如何被操作属于MyJumper的实现细节了,它离我们的测试目标比较远,我们不应该让它做感知对象。而应该找一个对象,替换直接与控制器打交道的MyJumper类对象,来作为对控制器行为的感知对象。只要想到要通过替换对象的方式来做测试,通常就想到要创建一个假对象或者一个可测试的对象,这种对象的创建方法要么是通过创建子类,要么是通过实现协议,这里因为theJumper属性与控制器之间是通过协议来交互的,所以,我们创建一个跟theJumper属性实现同样协议的对象来替换真实的MyJumper类对象,作为控制器的theJumper属性,然后在测试用例里面使用它来感知控制器是否正确地使用了它。
    【Red:tc 4.8,修改控制器的这个测试用例为在数据源代理类对象在响应了cell的选择代理方法后,跳转类对象是否调用了跳转方法】
    MyViewControllerTests.m文件:

    /**
     tc 4.8
     */
    - (void)test_SelectACellTheJumperCallJumpMethod{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        FakeMyJumper *jumper = [[FakeMyJumper alloc] initWithNavigationController:navVC];
        __block NSString *name;
        jumper.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            name = methodName;
        };
        self.theController.theJumper = jumper;
        self.theController.theDataSource = self.theDataSource;
        [self.theController viewDidLoad];
        [self.theDataSource tableView:self.theTableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
        XCTAssertTrue([name isEqualToString:[FakeMyJumper jumpMethodName]]);
    }
    

    【Green:创建FakeMyJumper类,修改控制器viewDidLoad里面处理跳转的逻辑】
    FakeMyJumper要替换MyJumper类,就得实现跟它一样实现JumperProtocol协议;同时要具备可测试性就得导入NSObject+TestingHelper类别,在要检测的方法里面,执行callMethodBlock。
    FakeMyJumper.h文件:

    #import "JumperProtocol.h"
    #import "NSObject+TestingHelper.h"
    
    @interface FakeMyJumper : NSObject <JumperProtocol>
    
    /**
     获取常亮的方法名字符串,方便用来做检测对比。
    
     @return <#return value description#>
     */
    + (NSString *)jumpMethodName;
    
    @end
    

    FakeMyJumper.m文件:

    #import "FakeMyJumper.h"
    
    @implementation FakeMyJumper
    
    #pragma mark - JumperProtocol
    
    - (void)toControllerWithData:(id)data{
        // 调用了本方法,外界就能检测到
        if (self.callMethodBlock) {
            self.callMethodBlock([FakeMyJumper jumpMethodName], nil);
        }
    }
    
    - (instancetype)initWithNavigationController:(UINavigationController *)navVC{
        return self;
    }
    
    + (NSString *)jumpMethodName{
        return @"toControllerWithData:";
    }
    
    @end
    

    控制器的viewDidLoad方法里面处理跳转的逻辑将由原来的这种硬编码:
    MyViewController.m文件:

    - (void)viewDidLoad {
        //  其他代码。。。
        //  cell跳转逻辑
        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:YES];
            }
        };
        //  其他代码。。。
    }
    

    变成这样的依赖协议的简洁代码:
    MyViewController.m文件:

    - (void)viewDidLoad {
        //  其他代码。。。
        //  cell跳转逻辑
        self.theDataSource.cellTapBlock = ^(id dateModel) {
            __strong typeof(self) sSelf = wSelf;
            if (sSelf.theJumper) {
                [sSelf.theJumper toControllerWithData:dateModel];
            }
        };
        //  其他代码。。。
    }
    

    【Red:tc 5.15,当选择A类型的cell时,跳转类对象获得A类型的model数据】
    MyViewControllerTests.m文件:

    /**
     tc 5.15
     */
    - (void)test_SelectATypeCellPassATypeModelToTheJumper{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        FakeMyJumper *jumper = [[FakeMyJumper alloc] initWithNavigationController:navVC];
        __block NSString *name;
        __block id model;
        jumper.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            name = methodName;
            model = parameters[[FakeMyJumper modelKey]];
        };
        self.theController.theJumper = jumper;
        self.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
        self.theController.theDataSource = self.theDataSource;
        [self.theController viewDidLoad];
        [self.theDataSource tableView:self.theTableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
        XCTAssertTrue([name isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([model isKindOfClass:[MyModel class]]);
        MyModel *dataModel = model;
        XCTAssertTrue(dataModel.type == ModelTypeA);
    }
    

    【Green:修改FakeMyJumper,让它可以将接收的参数通过block传递出来;修改控制器,去掉#import "ATypeViewController.h"
    FakeMyJumper.h文件,添加获取参数字典key的方法:

    /**
     从参数字典里面获取model对象的key
    
     @return <#return value description#>
     */
    + (NSString *)modelKey;
    

    FakeMyJumper.m文件,调用方法时,把参数传入block:

    - (void)toControllerWithData:(id)data{
        // 调用了本方法,外界就能检测到
        if (self.callMethodBlock) {
            self.callMethodBlock([FakeMyJumper jumpMethodName], @{[FakeMyJumper modelKey]:data});
        }
    }
    + (NSString *)modelKey{
        return @"dataModel";
    }
    

    重新运行所有测试,全部通过。继续添加对B, C类型cell的跳转测试。
    【Red:tc 5.16,当选择B类型的cell时,跳转类对象获得B类型的model数据】
    MyViewControllerTests.m文件:

    /**
     tc 5.16
     */
    - (void)test_SelectBTypeCellPassBTypeModelToTheJumper{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        FakeMyJumper *jumper = [[FakeMyJumper alloc] initWithNavigationController:navVC];
        __block NSString *name;
        __block id model;
        jumper.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            name = methodName;
            model = parameters[[FakeMyJumper modelKey]];
        };
        self.theController.theJumper = jumper;
        self.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"}];
        self.theController.theDataSource = self.theDataSource;
        [self.theController viewDidLoad];
        [self.theDataSource tableView:self.theTableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:1 inSection:0]];
        XCTAssertTrue([name isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([model isKindOfClass:[MyModel class]]);
        MyModel *dataModel = model;
        XCTAssertTrue(dataModel.type == ModelTypeB);
    }
    
    /**
     tc 5.17
     */
    - (void)test_SelectCTypeCellPassCTypeModelToTheJumper{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        FakeMyJumper *jumper = [[FakeMyJumper alloc] initWithNavigationController:navVC];
        __block NSString *name;
        __block id model;
        jumper.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            name = methodName;
            model = parameters[[FakeMyJumper modelKey]];
        };
        self.theController.theJumper = jumper;
        self.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"},@{@"type":@2,@"title":@"Type C Title",@"someId":@"0003"}];
        self.theController.theDataSource = self.theDataSource;
        [self.theController viewDidLoad];
        [self.theDataSource tableView:self.theTableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:2 inSection:0]];
        XCTAssertTrue([name isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([model isKindOfClass:[MyModel class]]);
        MyModel *dataModel = model;
        XCTAssertTrue(dataModel.type == ModelTypeC);
    }
    

    【Green:不用修改任何产品代码,这两个测试用例就通过了】

    留意到【tc 5.15,tc 5.16,tc 5.17】有很多冗余的代码,所以又到了可以执行Refactor流程的点。
    【Refactor:整理测试代码,消除冗余】
    提取公共方法,将测试用例常用的变量作为测试类的成员变量。
    MyViewControllerTests.m文件:

    @interface MyViewControllerTests : XCTestCase
    
    @property (nonatomic, strong) UITableView *theTableView;
    @property (nonatomic, strong) MyTableViewDataSource *theDataSource;
    @property (nonatomic, strong) MyViewController *theController;
    @property (nonatomic, strong) FakeMyJumper *fakeJumper;
    @property (nonatomic, copy) NSString *cellJumpMethod;
    @property (nonatomic, strong) id dataPassedToJumper;
    
    @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;
        self.fakeJumper = nil;
        self.cellJumpMethod = nil;
        self.dataPassedToJumper = nil;
        [super tearDown];
    }
    
    /**
     选择一种数据类型的cell
    
     @param type <#type description#>
     */
    - (void)selectCellWithDataType:(ModelType)type{
        UINavigationController *navVC = [[UINavigationController alloc] init];
        self.fakeJumper = [[FakeMyJumper alloc] initWithNavigationController:navVC];
        __weak typeof(self) wSelf = self;
        self.fakeJumper.callMethodBlock = ^(NSString *methodName, NSDictionary *parameters) {
            __strong typeof(self) sSelf = wSelf;
            sSelf.cellJumpMethod = methodName;
            sSelf.dataPassedToJumper = parameters[[FakeMyJumper modelKey]];
        };
        self.theController.theJumper = self.fakeJumper;
        self.theDataSource.theDataArray = @[@{@"type":@0,@"title":@"Type A Title",@"someId":@"0001"},@{@"type":@1,@"title":@"Type B Title",@"someId":@"0002"},@{@"type":@2,@"title":@"Type C Title",@"someId":@"0003"}];
        self.theController.theDataSource = self.theDataSource;
        [self.theController viewDidLoad];
        NSInteger row = 0;
        if (type == ModelTypeA) {
            row = 0;
        }else if (type == ModelTypeB){
            row = 1;
        }else if (type == ModelTypeC){
            row = 2;
        }
        [self.theDataSource tableView:self.theTableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:0]];
    }
    
    

    【tc4.8,tc 5.15,tc 5.16,tc 5.17】几个测试用例将变得很简洁,而且测试意图也更明显:
    MyViewControllerTests.m文件:

    /**
     tc 4.8
     */
    - (void)test_SelectACellTheJumperCallJumpMethod{
        [self selectCellWithDataType:ModelTypeC];
        XCTAssertTrue([self.cellJumpMethod isEqualToString:[FakeMyJumper jumpMethodName]]);
    }
    
    /**
     tc 5.15
     */
    - (void)test_SelectATypeCellPassATypeModelToTheJumper{
        [self selectCellWithDataType:ModelTypeA];
        XCTAssertTrue([self.cellJumpMethod isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([self.dataPassedToJumper isKindOfClass:[MyModel class]]);
        MyModel *dataModel = self.dataPassedToJumper;
        XCTAssertTrue(dataModel.type == ModelTypeA);
    }
    
    /**
     tc 5.16
     */
    - (void)test_SelectBTypeCellPassBTypeModelToTheJumper{
        [self selectCellWithDataType:ModelTypeB];
        XCTAssertTrue([self.cellJumpMethod isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([self.dataPassedToJumper isKindOfClass:[MyModel class]]);
        MyModel *dataModel = self.dataPassedToJumper;
        XCTAssertTrue(dataModel.type == ModelTypeB);
    }
    
    /**
     tc 5.17
     */
    - (void)test_SelectCTypeCellPassCTypeModelToTheJumper{
        [self selectCellWithDataType:ModelTypeC];
        XCTAssertTrue([self.cellJumpMethod isEqualToString:[FakeMyJumper jumpMethodName]]);
        XCTAssertTrue([self.dataPassedToJumper isKindOfClass:[MyModel class]]);
        MyModel *dataModel = self.dataPassedToJumper;
        XCTAssertTrue(dataModel.type == ModelTypeC);
    }
    

    运行全部测试用例,没有一个失败,说明这次重构没问题。

    这篇文章到这里就结束了,现在产品代码的控制器里面没有关联MyModel类,没有关联ATypeViewController,BTypeViewController,CTypeViewController类,但是却能够根据选择不同的cell,执行不同的跳转。所以,到现在为止,我们的控制器已经跟我们模块专有的cell的跳转逻辑解耦了,它里面不会存有跟模块相关的专门的cell跳转的业务逻辑代码了,我实现了篇头所说的我想要的重构方案。这种重构方案不仅让产品代码具备更好的设计性,而且让产品重要的业务逻辑变得更容易测试。

    待续。。。。
    demo:
    https://github.com/zard0/TDDListModuleDemo.git

    相关文章

      网友评论

          本文标题:iOS尝试用测试驱动的方法开发一个列表模块【五】

          本文链接:https://www.haomeiwen.com/subject/muhwrxtx.html