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

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

作者: zard0 | 来源:发表于2017-08-09 21:55 被阅读0次

    模块功能需求

    1,从上一个页面,点击一个按钮,push进入模块控制器。
    2,控制器执行viewDidLoad后,开始加载接口数据。
    3,请求不到数据,需要有无数据提示。
    4,请求到数据,则展示列表。
    5,列表有三种数据类型,A,B,C, 形式一样,显示一张图片,和一个标题。同一种数据类型,图片一样,不同数据类型图片不一样,标题是随意的。
    5,点击列表,根据数据类型,跳转到不同页面。

    这是很常见的模块,现在尝试用TDD的方式去实现它。我们暂且先采用MVC的架构去开发,那么要有一个Model类去承接和转换接口数据;要有一个TableView去展示数据;要有一个Controller去负责请求数据、封装数据和提供数据给TableView去展示。

    尝试去开发Model类

    TDD讲究以测试驱动开发,因此写测试用例先于写产品代码。这时候的测试用例可以为我们描述需求。限于篇幅,我这里尽量只写几个我认为重要的测试用例,测试用例写得越多、覆盖得越广其实越好,但谁让我们总是时间有限、精力有限呢。我们的测试要尽量覆盖到我们上面提到的几点需求,其中需求【5】的一部分可以通过测试Model来覆盖,那就是不同类型数据对应不同图片,我们要确保当Model是A,B,C类型时,分别对应图片A,B,C。
    【tc 1.1,测试A类型数据对应A类型图标】

    - (void)testTypeAModelHasAPictureUrl{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeA;
        NSString *picAUrl = @"AUrl";
        XCTAssertTrue([model.picUrl isEqualToString:picAUrl]);
    }
    

    我们得到了第一个测试用例,从它身上我们可以了解到:1,测试用例名字最好写得见名知意,因此,测试用例的名字可能比较长,反正如果想少写些注释,就让方法名来说明测试意图吧。通常我的习惯是,用例名称包含测了什么、期望是什么这两部分内容。2,只要能够保证被测逻辑是正确的,其他的怎么荒谬都无所谓。你看到这个测试用例的picAUrl是什么了吗?它不是一个有效的Url,但是有什么关系呢,这里我们不是测试它的正确性,我们测的是当model的type是ModelTypeA时,model的picUrl应该是对应着某个字符串。3,一个失败的测试用例也是很有用的,它起码能够说明某个需求或功能没有开发。其实,写完这个测试用例后,我的xcode是这样的:

    image.png

    它甚至不能编译通过,因为,我现在还没有定义MyModel这个类!
    但是,我们已经做了一件很有意义的事情了,那就是我们写了一个失败的测试用例。这就是TDD的Red-Green-Refactor流程里面的第一个阶段,Red阶段。现在我们要进入第二个Green阶段,我们要写我们的产品代码,让这个失败的测试用例有失败变成通过,即由Red变成Green。
    MyModel代码:

    #import <Foundation/Foundation.h>
    
    typedef NS_ENUM(NSUInteger, ModelType){
        ModelTypeA = 0,
        ModelTypeB,
        ModelTypeC
    };
    
    @interface MyModel : NSObject
    
    @property (nonatomic, assign) ModelType type;
    @property (nonatomic, copy) NSString *picUrl;
    
    @end
    
    #import "MyModel.h"
    
    @implementation MyModel
    
    - (NSString *)picUrl{
        if (self.type == ModelTypeA) {
            return @"AUrl";
        }
        return nil;
    }
    
    @end
    

    产品代码终于可以让【tc 1.1】通过了,即让它变成Green。单靠这个测试用例,还不足以覆盖完全需求【5】的图片对应数据类型的需求。因为,还有B,C两种类型没测呢,好,我们接下来追加更多的测试用例:
    【tc 1.2,tc 1.3,tc 1.4】

    - (void)testTypeBModelHasBPictureUrl{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeB;
        NSString *picBUrl = @"BUrl";
        XCTAssertTrue([model.picUrl isEqualToString:picBUrl]);
    }
    
    - (void)testTypeCModelHasCPictureUrl{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeC;
        NSString *picCUrl = @"CUrl";
        XCTAssertTrue([model.picUrl isEqualToString:picCUrl]);
    }
    
    - (void)testAPicUrlBPicUrlCPicUrlAreNotEqualToEachOther{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeA;
        NSString *picAUrl = model.picUrl;
        model.type = ModelTypeB;
        NSString *picBUrl = model.picUrl;
        model.type = ModelTypeC;
        NSString *picCUrl = model.picUrl;
        XCTAssertFalse([picAUrl isEqualToString:picBUrl]);
        XCTAssertFalse([picAUrl isEqualToString:picCUrl]);
        XCTAssertFalse([picBUrl isEqualToString:picCUrl]);
    }
    

    然后,先执行它们:

    image.png

    发现了一些有趣的情况。我们当然知道,第一个测试用例的成功,是由于我们我们实现了它要求的功能,第二、三个测试用例的失败是必然的,因为我们没有去实现它们的相应功能,而它们的失败提醒着我们有待完成的工作。关键是第四个测试用例居然通过了,而我们并没有针对它做相应的编码。这其实告诉我们,我们的测试有漏洞,需要完善,因为当model.picUrl都为nil时,第四个测试用例是可以通过的,但这不是我们想要的结果。所以,我们再补充一个测试用例:
    【tc 1.5】

    - (void)testAPicUrlBPicUrlCPicUrlAreNotEqualToNil{
        MyModel *model = [[MyModel alloc] init];
        model.type = ModelTypeA;
        XCTAssertNotNil(model.picUrl);
        model.type = ModelTypeB;
        XCTAssertNotNil(model.picUrl);
        model.type = ModelTypeC;
        XCTAssertNotNil(model.picUrl);
    }
    

    再执行所有测试:


    image.png

    这样我们就放心了,因为【tc 1.5】是【tc 1.4】的漏洞的补充,只要【tc 1.4】和【tc 1.5】都通过就没问题。
    下面,我们执行Green阶段,让以上失败的测试用例都通过,MyModel.m的代码:

    #import "MyModel.h"
    
    @implementation MyModel
    
    - (NSString *)picUrl{
        switch (self.type) {
            case ModelTypeA:
                return @"AUrl";
                break;
            case ModelTypeB:
                return @"BUrl";
                break;
            case ModelTypeC:
                return @"CUrl";
                break;
            default:
                return nil;
                break;
        }
    }
    
    @end
    

    注意到,现在为止,我们已经执行了两次Ren-Green流程,为什么我们还没有执行一次Red-Green-Refactor的完整流程呢?因为第三个流程Refator要看情况的,在没有必要重构代码时,我们当然就不会去重构,所以也就不会有Refactor阶段出现,比如我们写完【tc 1.1】的产品代码,然后跑过了它后,就没有需要重构的代码,所以我们的第一个流程止于Red-Green,并没有达到Red-Green-Refactor。所以实践中,我发现通常是执行了好几次Red-Green流程后,才会执行一次Red-Green-Refactor流程,比如现在就是执行Refactor的时候了。Refactor流程既重构产品代码,也会去重构测试代码。我们现在的测试代码有了一些冗余代码需要提取重用,那就是MyModel的初始化,反正每个tc都用到,我们就把这部分代码挪到setUp方法里面去。
    重构后的测试代码:

    #import <XCTest/XCTest.h>
    #import "MyModel.h"
    
    @interface MyModelTests : XCTestCase
    
    @property (nonatomic, strong) MyModel *model;
    
    @end
    
    @implementation MyModelTests
    
    - (void)setUp {
        [super setUp];
        self.model = [[MyModel alloc] init];
    }
    
    - (void)tearDown {
        self.model = nil;
        [super tearDown];
    }
    
    
    - (void)testTypeAModelHasAPictureUrl{
        self.model.type = ModelTypeA;
        NSString *picAUrl = @"AUrl";
        XCTAssertTrue([self.model.picUrl isEqualToString:picAUrl]);
    }
    
    - (void)testTypeBModelHasBPictureUrl{
        self.model.type = ModelTypeB;
        NSString *picBUrl = @"BUrl";
        XCTAssertTrue([self.model.picUrl isEqualToString:picBUrl]);
    }
    
    - (void)testTypeCModelHasCPictureUrl{
        self.model.type = ModelTypeC;
        NSString *picCUrl = @"CUrl";
        XCTAssertTrue([self.model.picUrl isEqualToString:picCUrl]);
    }
    
    - (void)testAPicUrlBPicUrlCPicUrlAreNotEqualToEachOther{
        self.model.type = ModelTypeA;
        NSString *picAUrl = self.model.picUrl;
        self.model.type = ModelTypeB;
        NSString *picBUrl = self.model.picUrl;
        self.model.type = ModelTypeC;
        NSString *picCUrl = self.model.picUrl;
        XCTAssertFalse([picAUrl isEqualToString:picBUrl]);
        XCTAssertFalse([picAUrl isEqualToString:picCUrl]);
        XCTAssertFalse([picBUrl isEqualToString:picCUrl]);
    }
    
    - (void)testAPicUrlBPicUrlCPicUrlAreNotEqualToNil{
        self.model.type = ModelTypeA;
        XCTAssertNotNil(self.model.picUrl);
        self.model.type = ModelTypeB;
        XCTAssertNotNil(self.model.picUrl);
        self.model.type = ModelTypeC;
        XCTAssertNotNil(self.model.picUrl);
    }
    
    @end
    
    

    重构完成后,记得全部运行一次测试用例,保证它们继续是通过的。
    重构代码有时候是会上瘾的,根本停不下来。
    当我们的测试用例一多了之后,我们可能还会去思考如果更好地组织它们,让它们更好被管理和使用。比如上面的【tc 1.1,tc 1.2, tc 1.3】 能不能合并成下面的【tc 1.6】呢,这样测试用例的数量就少了下来,代码也少了下来,能为我们减少一些管理压力而测试覆盖率还跟原来一样。
    【tc 1.6】

    - (void)testTypeATypeBTypeCModelAllHasTheirOwnPicUrl{
        self.model.type = ModelTypeA;
        XCTAssertTrue([self.model.picUrl isEqualToString:@"AUrl"]);
        self.model.type = ModelTypeB;
        XCTAssertTrue([self.model.picUrl isEqualToString:@"BUrl"]);
        self.model.type = ModelTypeC;
        XCTAssertTrue([self.model.picUrl isEqualToString:@"CUrl"]);
    }
    

    我是不建议这种重构的,原因是它破坏了测试用例的单一功能原则。好的测试用例只测一个单一小功能,为什么要强调这种原则呢,因为当一个测试用例失败时,它应该让你迅速定位到出错的代码,这就是测试用例的又一个重要功能,那就是测试用例应当能够显著地减少我们去debug的时间
    如果用【tc 1.6】去代替【tc 1.1,tc 1.2,tc 1.3】,那么MyModel.m的下面几种代码的修改都会让【tc 1.6】失败。

    情况一:
    - (NSString *)picUrl{
        switch (self.type) {
            case ModelTypeA:
                return @"AUrl";
                break;
            case ModelTypeB:
                return @"AUrl";
                break;
            case ModelTypeC:
                return @"CUrl";
                break;
            default:
                return nil;
                break;
        }
    }
    情况二:
    - (NSString *)picUrl{
        switch (self.type) {
            case ModelTypeA:
                return @"AUrl";
                break;
            case ModelTypeB:
                return @"BUrl";
                break;
            case ModelTypeC:
                return nil;
                break;
            default:
                return nil;
                break;
        }
    }
    情况三:
    - (NSString *)picUrl{
        switch (self.type) {
            case ModelTypeA:
                return @"CUrl";
                break;
            case ModelTypeB:
                return @"BUrl";
                break;
            case ModelTypeC:
                return @"CUrl";
                break;
            default:
                return nil;
                break;
        }
    }
    
    

    每次出错,我们都得查看出错的测试用例代码才知道产品代码出错的地方,如果不用统一集成的这个测试用例,仍然用我们一开始分散的测试用例。由于分散的测试用例的测试粒度是switch分支级别的,比粒度是方法的集中测试用例粒度更小,因此,情况一只会导致【tc 1.2】的失败,情况二只会导致【tc 1.3】的失败,情况三只会导致【tc 1.1】的失败。由于测试用例的名称已经将我们的测试定位和意图表述的比较具体,我们就可以不怎么用进入到测试用例内部去读代码,就大概能猜测出产品代码哪里出了问题。根据测试用例快速定位出错的代码,也就自然而然的不需要我们花更多时间去debug源码了。

    待续。。。。。

    demo:
    https://github.com/zard0/TDDListModuleDemo.git

    相关文章

      网友评论

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

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