测试驱动的 iOS 开发-简介与技巧
在项目的进展过程中,随时都可能出现 bug。产生 bug 的时间和发现 bug 的时间相隔越久,修复 bug 的成本会越来越大。所以,现在的软件项目管理方法力争在产品开发的全过程中对每个部件都进行持续的测试。
程序中的 bug 并非总是可以通过一般性的尝试就可以找到,所有系统测试员需要预设一定的测试目标。测试员根据测试目标分为:
-
渗透测试员(penetration tester)
:通过向程序中输入恶意数据、打乱操作顺序或者破坏程序运行环境来寻找安全漏洞。 -
可用性测试员(usability tester)
:观察用户如何使用应用程序,记录下让用户操作错误,耗时太久或不知道如何操作的情景,从而提出修改意见。可用性测试中的一个常用技巧就是A/B 测试(A/B Testing)
。
测试驱动开发
极限编程迷恋者们所采用的开发方式就是测试先行(test first)
或者测试驱动开发
,顾名思义,即在写产品代码之前先写测试用例。
如果一次写好所有的测试用例,然后回过头来写全部的产品代码,那么你得把应用程序中需要解决的每个问题都思考两遍,而且这两次之间会隔很久。所以测试驱动开发者不会一次写好所有的测试用例。不过他们还是不会在某个用例写好之前就去写对应的产品代码。
测试驱动的优势:
-
快速反馈
:在给应用程序加入新功能或者重构之后,能够得到快速的反馈。 -
描述预期
:一个好的测试用例同时也应该是一份好的文档。对于一些模棱两可的情况,我们可以通过写测试用例来指定哪一种情况符合我们的预期,这种指定就以文档的形式保留了下来。
测试的原则
在制定测试方案时,有以下原则:
- 测试的目标是证明产品能够正常运行,而不是发现 bug。软件测试是在做
“质量保证”
,而不是“质量介入”
。 - 合适的软件测试方案既要保证不影响软件开发的进度,又要保证在工程开销许可的范围内进行一定程度的检查,以确保产品能够正常运行。
测试驱动的过程
测试驱动开发分为三个阶段:先写一个不能成功执行的测试用例,再编写实现代码使测试用例成功执行,然后在不改变程序行为的情况下对代码进行重构。
单元测试
单元测试得名于其所测试的代码“单元”,在面向对象软件开发中,这样的“单元”通常指的就是类。对于复杂的受测代码应该赋予更高的权重。代码的复杂度
与代码中包含的循环与分支数相关,或者说,与代码的执行路径数有关。
单元测试将应用程序的代码隔离成一个个孤立的小部分,以此来追查程序中的问题,这样很容易就能定位到导致测试用例执行失败的代码。
单元测试的特征
一个设计良好的测试用例有以下特征:
-
可重复性
:不论何时,在何种平台上运行,只要受测代码是正确的,那么测试用例就能成功运行,否则就必然运行失败。运行测试的计算机配置、与测试同时运行的其他程序、数据库等外部软件或者磁盘文件系统的内容等因素都不应该影响测试结果。 -
自我测试(self-testing)
:每个测试用例都能够自行判断其后置条件(postcondition)是否得到满足,并依次来反馈测试的执行是否成功。 -
单一前置条件
:每个测试用例应该只设定某一种使用情景(scenario)所需的前置条件(precondition),然后判断应用程序的代码能否在此种情景下正确的执行。 -
独立性
:针对应用程序中的每一个类,都应该有一个对应的单元测试类,用于测试应用程序类中的各个方法。尽管应用程序的类之间可以有彼此的依赖关系,但是单元测试中的类之间则不应该存在这种依赖关系。单元测试中的每一个类只能依赖于它要检测的那个受测类,这样就可以保证只要出错,则错误一定出在受测试的类中。 -
原子性(atomic)
:测试用例要么执行成功,要么执行失败,不会存在中间状态。
XCode 中的测试框架
OCUnit
测试框架是在 XCode 2.1 版本时被 Apple 集成在开发环境当中的。XCTest
是 Xcode5 中新引入的一个测试框架,它的许多的功能都类似于之前的 OCUnit
,是上一代测试框架 OCUnit
的更现代化实现。XCTest
提供了与 Xcode 更好的集成并且奠定了未来改进 Xcode 测试能力的基础。
在单元测试的测试类中,相似的测试方法之间可能会出现重复的代码,因此 XCTest
单元测试框架建立了“测试固件”(test fixture)
, 来为每一组测试用例提供相同的运行环境。将使用相同运行环境的的测试用例抽取到一个测试固件中,每个测试用例都会运行在这个测试固件的某一份副本当中。采用这种方法之后,每个测试都会有自己的运行环境,而不受其他测试的干扰。
在 XCTest
框架中,测试固件通过继承 XCTestCase
类而搭建的,而且需要提供两个方法:用于配置测试固件运行环境的 - setUp
方法和用于在测试运行之后清理运行环境的 -tearDown
方法。 XCTest
会在每个测试方法执行之前自动调用 -setUp
方法,以保证每个测试都会有自己的运行环境,同时也会在测试方法执行结束之后自动调用 -tearDown
方法进行清理。
测试方法
的声明必须为无返回类型且无参数类型,并且方法名称要以小写的 test
开头。只有这样写的测试方法才能被 XCTest 框架发现。测试方法不需要在类的接口当中定义,因为 XCTest 是通过 Objective-C 语言的运行时库(runtime library)来查找测试方法的。
在测试方法中可以使用 XCTest
框架提供的很多宏,用来判断受测类是否符合测试条件。例如:XCTAssertNil
用来判断是否应该为空,XCTAssertThrows
用来判断是否应该抛出异常。这里宏都是以 XCT
开头的。
在写测试方法名称时应该尽量使用一个非常长的名字,因为它可以完整地揭示出方法的意图,便于阅读。
编写好测试用例之后,按下快捷键 Cmd + U
,或者选择 Product 菜单下的 Test 菜单项,XCode 就会编译应用程序代码,然后就会在 iOS 模拟器中运行测试用例。
单元测试技巧
下面介绍编写测试用例的几种测试技巧。实例中的完整代码可以通过 GitHub下载查看。
-
直接调用
当测试用例的目的时确保受测类
必须包含指定的属性
或方法
时,可以在测试用例中直接调用对应的属性或方法。事例:检测 person 类是否有 birthday 属性,并且可以被设置。
- (void)testPersonHasBirthday { NSDate *birthday = [NSDate date]; person.birthday = birthday; XCTAssertEqualObjects(person.birthday, birthday, @"Person needs to provide its birthday.");
}
```
-
运行时
可以通过运行时机制来获取受测类的信息。事例:检测 FYFViewController 类是否包含 tableView 属性。
- (void)testViewControllerHasATableViewProperty { objc_property_t tableViewProperty = class_getProperty([FYFViewController class], "tableView"); XCTAssertTrue(tableViewProperty != NULL, @"FYFViewController need a table view"); }
-
伪造对象(Fake Object)
在测试过程中,可以构造一个特殊的对象,用来提供输入值,并观察输出值。这种模式叫做伪造对象(Fake Object)
或仿造对象”(Mock Object)
。伪造对象可以下以下情况下使用:- 可以替换程序运行过程中的特殊的类,比如构造复杂的类,网络请求的中的相关类,构造的伪造对象只需要有我们需要的行为即可;
- 可以更为容易地观察应用程序代码的执行过程和结果,例如:要检测受测对象的某个方法是否被调用过,可以伪造一个受测类的子类对象,重写受测方法,在伪造对象的受测方法代码中记录该方法是否被调用过,调用时的参数是否恰当等等。
- 在测试固件当中可以定义一个伪造对象,用避免对应用程序中的其他类产生到依赖关系;
- 对于暂时不想实现的应用程序类,可以在测试固件中创造一个仿造对象使用;
-
网络请求
对于应用程序中需要获取网络数据的情况,采取的方法是复制一份数据,然后保存在本地,而不是每次运行测试都通过调用 API 来获取数据。这样就可以避免由于无法联网而带来的测试问题,而且保证每次运行测试时使用数据的一致性。测试用例的失败只应该由含有 bug 的代码造成,而不是应该受网络等因素的干扰。事例:
在测试用例当中保存一个从网络请求下来的数据:static NSString *personJSON = @"{" @"\"name\":\"fuyoufang\"" @"}";
在需要使用网络数据时,使用保存下来的字符串:
- (void)testPersonCreatedFromJSON { person = [FYFPerson personWithJSON:personJSON error:nil]; XCTAssertEqualObjects(person.name, @"fuyoufang", @"Person created frome json be setted wrong name."); }
-
私有信息
当测试用例需要使用受测类的私有信息时,可以建立一个具有数据探查能力的子类,在子类中添加方法,暴露私有信息,这样就可以在不破坏产品代码的前提下完成测试。也可以通过键值对来访问私有信息。 -
分类
测试受测类在某种情况下是否执行了某种操作,可以在测试类中建立受测类的分类,覆盖掉原来的方法,来方便测试。 -
替换父类方法
检测子类是否调用了父类中的方法,一种可行的方法是:用包含检测代码的方法将父类的对应方法实现替换掉,这样的话,在检测代码中就可以检验子类是否通过 super 关键字调用了超类的实现方法。我们可以使用 Object-C 语言运行时机制的功能,将某类的某个方法实现用另一个方法替换掉。事例:FYFManager 为 FYFPerson 的子类,测试在 FYFManager 调用 - beginWork: 的方法时是否调用了父类的方法:
首先在 FYFManager 的测试用例当中建立 FYFPerson 的 Test 分类。static const char *FYFManagerTestsBeginWorkKey = "FYFManagerTestsBeginWorkKey"; @interface FYFPerson (Test) @property (nonatomic, readonly) NSNumber *didCallSuperBeginWork; @end @implementation FYFPerson (Test) - (void)managerTests_beginWork:(NSNotification *)notification { objc_setAssociatedObject(self, FYFManagerTestsBeginWorkKey, [NSNumber numberWithBool:YES], OBJC_ASSOCIATION_RETAIN); } - (NSNumber *)didCallSuperBeginWork { return objc_getAssociatedObject(self, FYFManagerTestsBeginWorkKey); } @end
测试用例如下:
- (void)testManagerCallSuperBeginWork { // 将类中的方法和分类中的方法对换 Method testMethod = class_getInstanceMethod([FYFPerson class], @selector(managerTests_beginWork:)); Method realMethod = class_getInstanceMethod([FYFPerson class], @selector(beginWork:)); method_exchangeImplementations(realMethod, testMethod); [manager beginWork:nil]; XCTAssertNotNil(manager.didCallSuperBeginWork, @"manager should call super -beginWork:"); // 调整回来 method_exchangeImplementations(realMethod, testMethod); }
-
接收和关闭通知
在测试一个类是否正确的开启或关闭接收指定的通知时,并不需要异步操作,只需要在测试用例中发送指定通知,测试受测类的相关方法是否被调用。
事例:测试在发送通知之后,person 类是否在执行了 -beginWork: 方法。首先新增一个 FYFPerson 类的分类,替换掉原来的方法。static const char *beginWorkNotificationKey = "FYFPersonBeginWorkNotificationKey"; @implementation FYFPerson (Tests) - (NSNotification *)receiveBeginWorkNotification { return objc_getAssociatedObject(self, beginWorkNotificationKey); } - (void)beginWork:(NSNotification *)notification { objc_setAssociatedObject(self, beginWorkNotificationKey, notification, OBJC_ASSOCIATION_RETAIN); } @end
在测试用例中发送通知,然后检测受测方法是否被调用:
- (void)testPersonBeginWorkWhenGetNotifacation { objc_removeAssociatedObjects(person); [[NSNotificationCenter defaultCenter] postNotificationName:FYFPersonGetNotificationName object:nil]; XCTAssertNotNil(person.receiveBeginWorkNotification, @"person should get a notifacation."); objc_removeAssociatedObjects(person); }
-
协议
有时受测类的特定属性需要符合相关协议,在设置此类属性时就需要符合相关协议。在测试用例中,可以使用NULL
来测试受测类有没有对没有符合协议的情况做处理。
例如:person 类的 delegate 属性需要符合 FYFPersonDelegate 协议。- (void)testNonConformingObjectCannotBeDelegate { XCTAssertThrows(person.delegate = (id <FYFPersonDelegate>)[NSNull null], @"NSNull should not be used as the delegate as doesn't " @"comform to the delegate protocol"); }
-
-compare: 方法
在测试受测类的 -compare: 方法时,需要该方法的实现是否符合了对称性。
事例:person 类通过 birthday 判断大小。- (void)testEarlierBirthdayPersonComesAfterLater { person.birthday = [NSDate distantFuture]; FYFPerson *otherPerson = [[FYFPerson alloc] initWithName:@"someOne"]; otherPerson.birthday = [NSDate distantPast]; XCTAssertEqual([person compare:otherPerson], NSOrderedAscending, @""); XCTAssertEqual([otherPerson compare:person], NSOrderedDescending, @""); }
其他技巧
- XCode:将警告视为错误
再 XCode 中找到项目的 Build Settings 属性设置界面,再其中寻找名为 Warnings as Errors 的设定值,将其设为 Yes。
本文为本人阅读《测试驱动的 iOS 开发》之后的摘抄和记录。