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源码了。
待续。。。。。