测试驱动开发并不是一个很新鲜的概念了。在我最开始学习程序编写时,最喜欢干的事情就是编写一段代码,然后运行观察结果是否正确。我所学习第一门语言是c语言,用的最多的是在算法设计上,那时候最常做的事情就是编写了一段代码,如何编译运行,查看结果是否正确,很多时候,还得自己想很多特殊的(比如说零值,边界值)测试数据来检测所写代码、算法是否正确。那个时候,感觉还好,比较输出只是只是控制台的一个简单的数字或者字符。在学习iOS开发中,很多时候也是要测试的,这种输出是必须在点击一系列按钮之后才能在屏幕上显示出来的东西。测试的时候,往往是用模拟器一次一次的从头开始启动app,然后定位到自己所在模块的程序,做一系列的点击操作,然后查看结果是否符合自己预期。
这种行为无疑是对美好生命和绚丽青春的巨大浪费。于是有很多资深工程师们发现,我们是可以在代码中构造一个类似的场景,然后在代码中调用我们之前想要检查的代码,并将运行结果和设想结果在程序中进行比较,如果一致,则说明我们的代码没有问题。比如说下面的代码:
int a = 3, b = 4; int c = a + b; if (c == a + b){ //结果正确 } else{ //结果错误 }
当测试足够全面、具有代表性的时候,我们就可以肯定这个代码是没有问题的,至少,问题不是出自这块代码。我们做出某些条件和假设,并以其为条件使用到被测试中的代码去,比较预期结果与运行结果是否相等,这就是软件测试中的基本方法。
首先什么是单元测试?维基百科中的解释是:
在计算机编程中,单元测试(又称为模块测试, Unit Testing)是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书(en:Specification)要求的工作目标,没有程序错误;虽然单元测试不是什么必须的,但也不坏,这牵涉到项目管理的政策决定。
在XCode中使用XCTest
在XCode7中新建一个工程的时候,会默认带一个用于单元测试的target,其名字为工程名加Test后缀,并且文件名也以Test结尾。你会发现已经有了一个默认的测试用例
注意到画勾的地方,Include Unit Test就是包含单元测试的意思。打开工厂目录,你会发现有如下文件:
其中,ZYMusicPlayerTests文件夹目录下的文件就是我们的单元测试文件。
新建一个工程的时候,会默认带一个用于单元测试的target,其名字为工程名加Tests后缀,并且文件名也以Test结尾。你会发现已经有了一个默认的测试用例,其中有四个方法:
#import <XCTest/XCTest.h> @interface ZYMusicPlayerTests : XCTestCase @end @implementation ZYMusicPlayerTests - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. } - (void)tearDown { // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } - (void)testExample { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. // XCTFail(@"no implementation for app",__PRETTY_FUNCTION__); } - (void)testPerformanceExample { // This is an example of a performance test case. [self measureBlock:^{ }]; } @end
四个方法分别是:setUp, tearDown, testExample, testPerformanceExample。其中testExample方法左侧有一个播放按钮,点击它就会对这个方法进行测试,而在整个文件的@implemenation那行也有个同样的按钮,点击后会对当前测试用例的所有方法进行测试,也可通过Command+U快捷键来触发。这个测试用例类没有头文件,因为测试用例不需要给外部暴漏接口。按照苹果官方的文档,建立一个测试用例的过程应该是这样的:
- 建立一个
XCTestCase
的子类 - 实现测试方法
- 选择性的定义一些实例变量来存储fixture的状态
- 通过重写
setUp
方法选择性的实例化fixture - 通过重写
tearDown
方法来在测试后清除
测试方法没有参数和返回值,用test作为前缀,比如:
- (void)testPlayingMusic
会自动被XCTest
架构识别为测试用例,每个XCTestCase
的子类中的defaultTestSuite
都是一个XCTestSuite
,它包含了这些测试用例。
测试方法的实现经常包含断言,必须通过验证才能通过测试,举个例子:
下面是使用时的所有断言测试:
XCTFail(format…) 生成一个失败的测试; XCTAssertNil(a1, format...)为空判断,a1为空时通过,反之不通过; XCTAssertNotNil(a1, format…)不为空判断,a1不为空时通过,反之不通过; XCTAssert(expression, format...)当expression求值为TRUE时通过; XCTAssertTrue(expression, format...)当expression求值为TRUE时通过; XCTAssertFalse(expression, format...)当expression求值为False时通过; XCTAssertEqualObjects(a1, a2, format...)判断相等,[a1 isEqual:a2]值为TRUE时通过,其中一个不为空时,不通过; XCTAssertNotEqualObjects(a1, a2, format...)判断不等,[a1 isEqual:a2]值为False时通过; XCTAssertEqual(a1, a2, format...)判断相等(当a1和a2是 C语言标量、结构体或联合体时使用,实际测试发现NSString也可以); XCTAssertNotEqual(a1, a2, format...)判断不等(当a1和a2是 C语言标量、结构体或联合体时使用); XCTAssertEqualWithAccuracy(a1, a2, accuracy, format...)判断相等,(double或float类型)提供一个误差范围,当在误差范围(+/-accuracy)以内相等时通过测试; XCTAssertNotEqualWithAccuracy(a1, a2, accuracy, format...) 判断不等,(double或float类型)提供一个误差范围,当在误差范围以内不等时通过测试; XCTAssertThrows(expression, format...)异常测试,当expression发生异常时通过;反之不通过;(很变态) XCTAssertThrowsSpecific(expression, specificException, format...) 异常测试,当expression发生specificException异常时通过;反之发生其他异常或不发生异常均不通过; XCTAssertThrowsSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression发生具体异常、具体异常名称的异常时通过测试,反之不通过; XCTAssertNoThrow(expression, format…)异常测试,当expression没有发生异常时通过测试; XCTAssertNoThrowSpecific(expression, specificException, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过; XCTAssertNoThrowSpecificNamed(expression, specificException, exception_name, format...)异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过 特别注意下XCTAssertEqualObjects和XCTAssertEqual。 XCTAssertEqualObjects(a1, a2, format...)的判断条件是[a1 isEqual:a2]是否返回一个YES。 XCTAssertEqual(a1, a2, format...)的判断条件是a1 == a2是否返回一个YES。 对于后者,如果a1和a2都是基本数据类型变量,那么只有a1 == a2才会返回YES。例如
参考自:http://yulingtianxia.com/blog/2014/04/28/iosdan-yuan-ce-shi-xctest/
有一部分已亲测。
在使用XCTest的时候,一般是所需要测试的那个类的类名+Tests,如ZYAudioManagerTests就是为了测试ZYAudioManager类。并且这个类要继承自XCTestCase类,或者它的子类,如:
@interface ZYAudioManagerTests : XCTestCase
下面是一个音乐播放的单例代码与它的测试代码:
#import <Foundation/Foundation.h> #import <AVFoundation/AVFoundation.h> @interface ZYAudioManager : NSObject + (instancetype)defaultManager; //播放音乐 - (AVAudioPlayer *)playingMusic:(NSString *)filename; - (void)pauseMusic:(NSString *)filename; - (void)stopMusic:(NSString *)filename; //播放音效 - (void)playSound:(NSString *)filename; - (void)disposeSound:(NSString *)filename; @end #import "ZYAudioManager.h" @interface ZYAudioManager () @property (nonatomic, strong) NSMutableDictionary *musicPlayers; @property (nonatomic, strong) NSMutableDictionary *soundIDs; @end static ZYAudioManager *_instance = nil; @implementation ZYAudioManager + (instancetype)defaultManager { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _instance = [[self alloc] init]; }); return _instance; } - (instancetype)init { __block ZYAudioManager *temp = self; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ if ((temp = [super init]) != nil) { _musicPlayers = [NSMutableDictionary dictionary]; _soundIDs = [NSMutableDictionary dictionary]; } }); self = temp; return self; } + (instancetype)allocWithZone:(struct _NSZone *)zone { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ _instance = [super allocWithZone:zone]; }); return _instance; } //播放音乐 - (AVAudioPlayer *)playingMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return nil; AVAudioPlayer *player = self.musicPlayers[filename]; //先查询对象是否缓存了 if (!player) { NSURL *url = [[NSBundle mainBundle] URLForResource:filename withExtension:nil]; if (!url) return nil; player = [[AVAudioPlayer alloc] initWithContentsOfURL:url error:nil]; if (![player prepareToPlay]) return nil; self.musicPlayers[filename] = player; //对象是最新创建的,那么对它进行一次缓存 } if (![player isPlaying]) { //如果没有正在播放,那么开始播放,如果正在播放,那么不需要改变什么 [player play]; } return player; } - (void)pauseMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return; AVAudioPlayer *player = self.musicPlayers[filename]; if ([player isPlaying]) { [player pause]; } } - (void)stopMusic:(NSString *)filename { if (filename == nil || filename.length == 0) return; AVAudioPlayer *player = self.musicPlayers[filename]; [player stop]; [self.musicPlayers removeObjectForKey:filename]; } //播放音效 - (void)playSound:(NSString *)filename { if (!filename) return; //取出对应的音效ID SystemSoundID soundID = (int)[self.soundIDs[filename] unsignedLongValue]; if (!soundID) { NSURL *url = [[NSBundle mainBundle] URLForResource:filename withExtension:nil]; if (!url) return; AudioServicesCreateSystemSoundID((__bridge CFURLRef)(url), &soundID); self.soundIDs[filename] = @(soundID); } // 播放 AudioServicesPlaySystemSound(soundID); } //摧毁音效 - (void)disposeSound:(NSString *)filename { if (!filename) return; SystemSoundID soundID = (int)[self.soundIDs[filename] unsignedLongValue]; if (soundID) { AudioServicesDisposeSystemSoundID(soundID); [self.soundIDs removeObjectForKey:filename]; //音效被摧毁,那么对应的对象应该从缓存中移除 } } @end
测试代码(测试代码,只有.m文件,无.h文件):
#import <XCTest/XCTest.h> #import "ZYAudioManager.h" #import <AVFoundation/AVFoundation.h> @interface ZYAudioManagerTests : XCTestCase @property (nonatomic, strong) AVAudioPlayer *player; @end static NSString *_fileName = @"10405520.mp3"; @implementation ZYAudioManagerTests - (void)setUp { [super setUp]; // Put setup code here. This method is called before the invocation of each test method in the class. } - (void)tearDown { // Put teardown code here. This method is called after the invocation of each test method in the class. [super tearDown]; } - (void)testExample { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } /** * 测试是否为单例,要在并发条件下测试 */ - (void)testAudioManagerSingle { NSMutableArray *managers = [NSMutableArray array]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [[ZYAudioManager alloc] init]; [managers addObject:tempManager]; }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [ZYAudioManager defaultManager]; [managers addObject:tempManager]; }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ZYAudioManager *tempManager = [ZYAudioManager defaultManager]; [managers addObject:tempManager]; }); ZYAudioManager *managerOne = [ZYAudioManager defaultManager]; [managers enumerateObjectsUsingBlock:^(ZYAudioManager *obj, NSUInteger idx, BOOL * _Nonnull stop) { XCTAssertEqual(managerOne, obj, @"ZYAudioManager is not single"); }]; } /** * 测试是否可以正常播放音乐 */ - (void)testPlayingMusic { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; XCTAssertTrue(self.player.playing, @"ZYAudioManager is not PlayingMusic"); } /** * 测试是否可以正常停止音乐 */ - (void)testStopMusic { if (self.player == nil) { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; } if (self.player.playing == NO) [self.player play]; [[ZYAudioManager defaultManager] stopMusic:_fileName]; XCTAssertFalse(self.player.playing, @"ZYAudioManager is not StopMusic"); } /** * 测试是否可以正常暂停音乐 */ - (void)testPauseMusic { if (self.player == nil) { self.player = [[ZYAudioManager defaultManager] playingMusic:_fileName]; } if (self.player.playing == NO) [self.player play]; [[ZYAudioManager defaultManager] pauseMusic:_fileName]; XCTAssertFalse(self.player.playing, @"ZYAudioManager is not pauseMusic"); } @end
Command + U运行测试。
我应该测试什么?
学习完上面的使用方法时,迷茫了,在具体开发中,如果写测试代码,那么我该测试什么?私有方法也需要测试么?
我们不需要去测试私有方法,除此之外要回答“我该测试什么?”这个问题,并没有这么简单,但我依旧希望测试代码可以按照我实际编码时候的想法去测试,那么就是测试就仅仅是调用了我的共有方法。
- (void)testDownloadData;
像这样的测试有一个根本的问题:它不会告诉你应该发生什么,也就是不会告诉你实际的预期是什么。它不清楚需求是什么。
应该测试什么?我不应该关注于测试,而应该关注行为,应该测试行为。
什么是行为?
让我们思考你设计的 app 中的一个对象。它有一个接口定义了其方法和依赖关系。这些方法和依赖,声明了你对象的约定。它们定义了如何与你应用的其他部分交互,以及它的功能是什么。它们定义了对象的行为。
有很多行为,或许是私有的。比如说,我想测试一个继承自UIViewController的ZYNewViewController里面的tableView的dataSource的三个必须实现的数据源方法是否实现?
以往写代码,根据封装的原则,tableView必然是私有的,那么难道为了方便测试,我们就应该将它写成public?
不,有一种可以解决的方案是,写一个公共的TestsProcotol,然后利用委托实现上面的测试。