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

模块功能需求

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容