iOS 单元测试 - TDD

TDD

测试驱动开发(Test Driven Development,缩写TDD),它的基本思想就是在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考如何对这个功能进行测试,并完成相应的测试用例,然后编写相关代码满足这些测试,然后循环添加这些功能,直至开发结束。

TDD 的优点

  • 开发完成即完工。传统的编码方式很难知道什么时候编码结束了,TDD模式下开发人员可以明确自己的编码工作已经结束了。
  • 代码大部分保持在高质量状态。
  • 减少文档和代码之间的差别。

开发过程:

  • 明确当前要完成的功能。可以记录成一个 TODO 列表。
  • 快速完成针对此功能的测试用例编写。
  • 测试代码编译不通过。
  • 编写对应的功能代码。
  • 测试通过。
  • 对代码进行重构,并保证测试通过。
  • 循环完成所有功能的开发。

第1次迭代 用户场景

Example.png

用户场景

  • 作为用户,我要看到文章列表。
  • 作为用户,我要切换分类,看到不同的文章。
  • 作为用户,当我点击更多时,我要看到更多的分类。
  • 作为用户,我可以刷新最新的数据。
  • 作为用户,我可以获取更多的数据。
  • 作为用户,我要看到上方的轮播图。
  • 作为用户,我点击没一个文章,要进入文章详情。
  • 作为用户,我点击轮播图,要进入文章详情。

初步构建模块结构图

TDD-case.png

TDD - Model

我们看到列表的每个条目都会显示 title和 content。
那么我们就可以先编写测试用例。

- (void)testInitial {
    ArticalModel * artical = [ArticalModel new];
}

编译,显然不会通过,因为我们没有 ArticalModel 这个类,所以在我们的项目的代码里创建一个 ArticalModel 这个类。再编译,通过。此时不管时我们的测试的代码,还是项目的代码,都没有可以重构的。

接下来,我们看到列表里有title ,content, 日期和图片,对应到我们ArticalModel 里应该也有。接下来编写我们的测试用例。

- (void)testInitial_with_infomation {
    ArticalModel * artical = [ArticalModel new];
    artical.title = @"";
    artical.name = @"";
    artical.date = @"";
    artical.imageUrl = @"";
    XCTAssertNotNil(artical,"artical shouldNot be nil");
}

编译,失败。因为我们的Artical 里没有title,name等属性。好,那我们为 Artical 添加这些属性。

@interface ArticalModel : NSObject
@property(nonatomic , strong) NSString* title;
@property(nonatomic , strong) NSString* name;
@property(nonatomic , strong) NSString* date;
@property(nonatomic , strong) NSString* imageUrl;
@end

ok,编译通过,那看看我们的测试。测试通过了。
我们的最简易的模型先到这里。接下来,管理模型的是ArticalManager ,新建 ArticalManagerTest 测试类,同样测试初始化代码。

- (void)testInitialManager{
    ArticalManager * manager = [ArticalManager new];
    XCTAssertNotNil(manager ,@"manager should not be nil");
}

编译出错,同样,新建ArticalManager 类并导入。
articalManager 里是放 ArticalModel 的,所以接下来测试获取所有 AricalModel。

- (void)testGetArticalsNotNil{
    ArticalManager * manager = [ArticalManager new];
    id articals = manager.articals;
    XCTAssertNotNil(articals,@"articals should not be nil");
}

同样,编译报错,因为我们的ArticalManager 里没有articals属性,我们给ArticalManager 加上artical 属性。

@interface ArticalManager : NSObject
@property(nonatomic , strong) NSArray* articals;
@end

编译成功,但是测试没有通过,显然,因为我们的articals 没有初始化,默认为空。我们默认情况下应该返回一个空数组,再为ArticalManager 里实现 articals 的懒加载方法。

@implementation ArticalManager
-(NSArray*)articals{
    if (!_articals) {
        _articals = @[];
    }
    return _articals;
}
@end

再次编译。测试通过,这时看看测试代码发现有地方可以重构,两个测试方法里都创建了ArticalManager 新的实例

- (void)testInitialManager{
    ArticalManager * manager = [ArticalManager new];
    XCTAssertNotNil(manager ,@"manager should not be nil");
}
- (void)testGetArticalsNotNil{
    ArticalManager * manager = [ArticalManager new];
    id articals = manager.articals;
    XCTAssertNotNil(articals,@"articals should not be nil");
}

我们可以把 manager 抽出来,变为测试类的一个属性,初始化方法放在 setUp 里,代码就变成这样

- (void)setUp {
    [super setUp];
    self.manager = [ArticalManager new];
}
- (void)testInitialManager{
    XCTAssertNotNil(self.manager ,@"manager should not be nil");
}
- (void)testGetArticalsNotNil{
    id articals = self.manager.articals;
    XCTAssertNotNil(articals,@"articals should not be nil");
}

接下来,我们的ArticalManager 应该可以添加一个 文章的模型。

- (void)testAddAnArticalModel{
    ArticalModel* artical = [ArticalModel new];
    [self.manager addArticalModel:artical];
    XCTAssertEqual(1,self.manager.articals.count,@"should have  one artical");
}

编译报错。那我们为 ArticalManager 添加 addArticalModel 的接口,并完成它的实现。

-(void)addArticalModel:(ArticalModel*)artical{
    [self.articals addObject : artical];
}

编译报错,self.articals 没有addObject方法,因为articals 是 NSArray 类型的,我们把它定义成NSMutableArray再看看,修改如下

@property(nonatomic , strong) NSMutableArray* articals;
-(NSMutableArray*)articals{
    if (!_articals) {
        _articals =[ @[] mutableCopy ];
    }
    return _articals;
}

编译,测试通过,可以正常添加ArticalModel。再取出来测试一下是不是我们添加的 ArticalModel

- (void)testAddAnArticalModel{
    ArticalModel* artical = [ArticalModel new];
    [self.manager addArticalModel:artical];
    XCTAssertEqual(1,self.manager.articals.count,@"should have  one artical");
    artical.title = @"titleTest";
    XCTAssertEqual(@"titleTest",[self.manager.articals[0] title],@"artical title should be equal");
}

果然测试通过,那要是我们随便添加一个模型试试看

- (void)testAddIlegalModel{
    NSObject* illegalModel = [NSObject new];
    [self.manager addArticalModel: illegalModel];
    XCTAssertEqual(1,self.manager.articals.count,@"should have  one artical");
}

通过了,但这不应该是一个成功的测试,或者说上一步断言编写不正确,那我们修改测试用例,

- (void)testAddIlegalModel{
    NSObject* illegalModel = [NSObject new];
    [self.manager addArticalModel: illegalModel];
    XCTAssertEqual(0,self.manager.articals.count,@"should have no articals");
}

测试不通过,因为非法的模型已经被加到 articals 数组里去了,然而,不是什么对象我们都可以给它添加进我们的ArticalManager 的,我们的ArticalManager 要为 TableView 服务,所以要严格控制 ArticalManager 数组内的元素。

如何控制呢,可以在添加前判断一下被添加的对象是不是 Artical 类。但是这种代码可扩展性低,将来如果tableView 需要显示其它类型(比如 公告)的cell,而数据格式不完全一样,公告就不该被加进来。

第二种方法可以以继承的方式实现,比如我们把ArticalModel 作为基类,将来扩展的 BroadCastModel 可以继承 ArticalModel ,因此在添加进 Articals 数组的时候我们直接使用基类的指针判读类型。

第三种方法使用interface 的方式,对应于 OC 的protocal ,协议(接口)相比于类,耦合度更低。假设我还有一种模型 MessageModel 需要显示,其数据模型和 BroadCastModel 相似 ,即需要被加进去 articals 数组,用第二种方法实现让 MessageModel 继承 BroadCastModel ,同样可以达到需求 , 但是如果日后业务需求增加,需要更多的显示种类,一昧的使用继承的方式将导致整棵模型树层级越来越深。假设有需求,需要改变你的层级树中某一个Model 的属性或者私有方法,那如果继承了它的子类用到了该属性或方法,就要相应的去修改。当我需要测试 MessageModel 时,需要纵向依赖BroadCastModel 和 ArticalModel ,一旦业务抽离,在另一个模块或者项目中使用 MessageModel 时,需要将这里的继承树连根拔起。所以,如果你的继承关系层级达到了三层或者更多,就应该停下来思考一下设计是否合理。 采用 interface 或 protocal 的方式,是横向依赖,不管是ArticalModel 或者 MessageModel ,我要做tableView 的数据源,就要实现 TableViewModelCellProtocal 的方法, 这样就能减少耦合度。其实这解决的是如何在 tableView 中显示不同的数据格式的数据。

因为我们这边只是测试的实例,我们就采用最简单的第一种方法,在 addArticalModel 中添加如下代码

if (![artical isKindOfClass:[ArticalModel class]]) {
    return;
}

接下来,考虑到我们的数据是从网络回来,肯定不是一条记录一条记录的添加,那我们就编写批量数据的测试

- (void)testAddArticalArrToManager{
    NSArray * articalArr = nil;
    [self.manager.articals addArticalArrs : articalArr];
    XCTAssertEqual(0,[self.manager.articals count],@"articals should be eqmpty");
}

编译失败,我们为 ArticalManager 添加该接口

-(void)addArticalArrs: (NSArray <NSObject * >* )aricalArr{   
}

编译测试均通过,接下来我们看一下添加一个真实的模型数组,

-(void)testAddRealArticalArrToManager{
    ArticalModel * articalModel = [ArticalModel new];
    articalModel.name = @"artical_one";
    NSArray * articalArr = @[articalModel];
    [self.manager addArticalArrs : articalArr];
    XCTAssertEqual(1,[self.manager.articals count],@"articals should be eqmpty");
}

测试不通过,原因是addArticalArrs 方法里没有实现,我们将添加它的实现如下,

[self.articals addObjectsFromArray: articalArr];

测试全都pass ,我们再看看我们添加的是不是正确的模型,添加如下代码

XCTAssertEqual(articalModel.name , [self.manager.articals[0] name],@"they should be same name");

果然是同一个,测试通过,再试试添加非法的Array,我们设置的断言是不应该被加入数组。

-(void)testAddIllegalArrToManager{
    NSArray * articalArr = @[@"1",@"haha",@(2)];
    [self.manager addArticalArrs : articalArr];
    XCTAssertEqual(0,self.manager.articals.count,@"aricals should not be added");
}

测试失败,结果是0 != 3 ,我们修改代码来满足我们的测试,我们希望它只要有一个元素不合格,就都不能插入。

-(void)addArticalArrs: (NSArray <ArticalModel *>* )articalArr{
    __block BOOL illegal = false;
    [articalArr enumerateObjectsUsingBlock:^(NSObject* obj, NSUInteger idx, BOOL *stop){
        if (![obj isKindOfClass:[ArticalModel class]]) {
            illegal = true;
            *stop = YES;
        }
    }];
    if (!illegal) {
       [self.articals addObjectsFromArray: articalArr];
    }
}

好了这次通过了,看看有没有什么可以重构的,我们可以把判断元素合格提取出来,将来如果需要更换判断方式,比如采用协议来验证,就可以很方便的修改

#pragma mark privateMethod
-(BOOL)isItemVaild:(NSObject*)obj{
    return  [artical isKindOfClass:[ArticalModel class]];
}

提取出私有方法,isItemValid ,现在ArticalManager 代码如下

-(void)addArticalModel:(ArticalModel*)artical{
    if([self isItemVaild: artical]){
        [self.articals addObject:artical];
    };
}
-(void)addArticalArrs: (NSArray <ArticalModel *>* )articalArr{
    __block BOOL illegal = false;
    [articalArr enumerateObjectsUsingBlock:^(NSObject* obj, NSUInteger idx, BOOL *stop){
        if(![self isItemVaild: obj]){
            illegal = YES,*stop = YES;
        }
    }];
    if (!illegal) {
       [self.articals addObjectsFromArray: articalArr];
    }
}
#pragma mark privateMethod
-(BOOL)isItemVaild:(NSObject*)obj{
    return  [obj isKindOfClass:[ArticalModel class]];
}

接下来还有清空 artical 数组的测试己,编写,过程是一样的,假设该过程我们已经完成,现在回看我们的 ArticalManager 还有没有什么可以修改的。
articals 作为 MSMutableArray 暴露在头文件,这是相当危险的,也就是说可以让外部的类随意修改元素个数或articals 实例。所以我们将头文件的 articals 改为外界只读的。在ArticalManger.h 文件里声明articals 为NSArray,并且只读,.m 文件里使用 _articals 变量,该变量在对象创建时实例化。

.h 
@property(nonatomic , strong , readonly) NSArray* articals;
.m
@interface ArticalManager(){
    NSMutableArray*  _articals;
}
@end
@implementation ArticalManager
-(instancetype)init{
    _articals = @[].mutableCopy;
    return self;
}
-(void)addArticalModel:(ArticalModel*)artical{
    if([self isItemVaild: artical]){
        [_articals addObject:artical];
    };
}
...

大概 Model 层就是这样子的,测试的思路大概也就是这样,然而这些测试还只是冰山一角。完整写下来,测试代码大概是功能代码的2~3 倍。

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

推荐阅读更多精彩内容