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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值