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

来源:互联网 发布:喜剧片 知乎 编辑:程序博客网 时间:2024/06/10 23:35

模块功能需求

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

原创粉丝点击