iOS单元测试详解
简介
-
测试目的:模拟多种可能性,减少错误,增强健壮性,提高稳定性。
-
测试种类:在iOS中的通常分为单元测试和UI测试。
- 单元测试(Unit Test):用来保证每一个类正常工作。
- UI测试(UI Test):从业务层的角度保证各个业务可以正常工作。
-
测试框架:除了XCode自带的测试框架XCTest,还有以下列出的三方框架。
XCTest:同时支持单元测试和UI测试。
单元测试:
- Kiwi 老牌测试框架
- specta 另一个BDD优秀框架
- Quick 三个项目中Star最多,支持OC和Swift,优先推荐。
UI测试:
- KIF 基于XCTest的测试框架,调用私有API来控制UI,测试用例用Objective C或Swift编写。
- appium 基于Client – Server的测试框架。App相当于一个Server,测试代码相当于Client,通过发送JSON来操作APP,测试语言可以是任意的,支持android和iOS。
本博文讲述使用框架XCTest及Xcode工具进行单元测试,编写测试代码,以及如何让你的代码更容易单元测试(可能是重构代码的不归路)。
参考链接
官方文档讲述进行单元测试及UI测试详细信息,对如何进行测试,及写可测试代码有简单说明。
单元测试基础的单元测试。
自动化测试讲述UI测试及测试三方框架的使用,及写可测试代码策略。
更轻量的ViewController为测试ViewController做铺垫。
测试ViewController测试ViewController及相应的辅助测试工具。
XCTest测试
1. 测试基础
-
测试就是的您编写的测试代码,可以执行您的应用程序和库代码,根据一系列期望进行判断(使用断言XCTAssert),导致通过或失败的结果。对于性能测量测试,参考标准可能是期望一组代码运行到完成的最长时间。
-
所有软件都是使用组合构建的;也就是说,较小的组件被布置在一起以形成具有更大功能的更大,更高级别的组件,直到满足项目的目标和要求。良好的测试实践是进行测试,涵盖此组合的各个层次的功能。 XCTest允许您为任何级别的组件编写测试。您可以定义什么构成组件进行测试 - 它可以是一个类中的方法或一组完成重要目的的方法。测试组件的行为应该是完全确定的;测试通过或失败。
-
项目组件的测试设计的基础是测试驱动开发,它是编写代码的一种风格,您可以在编写被测试代码之前编写测试逻辑。这种开发方法使您可以在实现代码之前对需求和边缘案例进行整理编写。编写测试后,您将开发您的被测试代码,目的是通过测试。 当您修复错误时,您将添加确认错误已修复的测试。
-
性能测试
XCTest提供API来测量基于时间的性能,测试必须有一个评估基准, 评估基准是测试方法的十次运行中的平均时间与每次运行的标准偏差的组合。 低于评估基准或从运行到运行的变化太大的测试报告为失败,否则就是成功。
注意:首次执行性能测量测试时,XCTest始终报告失败,因为未设置评估基准。
- UI测试:模拟用户操作,进而从业务层面测试。
UI测试的工作原理是通过查询应用程序的UI对象,合成事件并将其发送到这些对象,并提供丰富的api,使您能够检查UI对象的属性和状态,将其与预期状态进行比较。UI测试使您能够查找应用程序的UI并与其进行交互,以验证UI元素的属性和状态。
UI测试包括UI录制,是帮助你快速编写UI测试的好方法。
-
应用程序和库测试
应用程序测试:测试检查应用程序中代码的正确行为,例如计算器应用程序的算术运算示例。库测试:检查动态库和框架中代码的正确行为,而不管它们在应用程序的运行时间中的使用情况。 你可以创建单元测试去构建库测试。
2. 测试从哪开始
-
在创建单元测试时,专注于测试您的代码的最基本的基础,Model类和与Controller进行交互的方法。你的应用程序中很有可能有Model,View,Controller这些类,首先编写测试来覆盖所有的Model类时,确定Model或者更基础的类是经过很好的测试,然后再开始为Controller类编写测试,这些测试开始接触应用程序的复杂部分,例如,连接到网络。
-
在创建UI测试时,首先考虑最常见的工作流程。想想用户在开始使用应用程序时使用什么以及在该过程中立即执行什么。使用UI录制功能是将用户操作序列捕获到UI测试方法中的好方法。
3. 新建测试Target 及 测试类
-
新建工程选择单元测试和UI测试
step1. 新建工程选择单元测试和UI测试
左侧Project navigation区和右侧Project editor区中对应新建项目中的单元测试和UI测试的资源。
step2. Project navigation & Project editor
新建的测试Target默认包含一个测试类,这个类是XCTestCase的子类,且这个类只有.m文件。
step3. -
选择Project editor新建测试
step1. Project editor中新建单元测试
选择需要测试的Target,新建单元测试
step2. 选择需要测试的Target-
选择Test navigation新建测试,在这里可以选择新建测试Target 或者测试类。你可以根据你的意愿新建任意多个测试Target,每个测试Target下可以新建任意多个测试类。
step1.Test navigation新建测试或者测试类
-
Test navigation中分层显示出项目中包含的测试Target,测试类,以及测试方法。
Test navigation
4. 编写测试方法
测试方法是以前缀test开头的测试类的实例方法,不需要参数,返回void,例如-(void)testExample()。测试方法在您的项目中执行代码,使用XCTest框架提供的断言来呈现Xcode显示的测试结果。如果该代码不产生预期结果,则使用一组断言(XCTAssert)API报告失败。例如,函数的返回值可能会与预期值进行比较。
- 断言的最后一个参数为失败结果的描述字符串,该参数可选,可以不传。一个测试方法可以包括多个断言。 如果其中包含的任何断言报告失败,则Xcode将指示该测试方法失败。使用断言需要的注意是断言的参数类型,可能是BOOL 类型,对象类型,基础数据类型,可能是个表达式等。
-(void)testUnconditionalFail {
//1. Unconditional Fail:无条件失败当直接到达特定的代码分支指示失败时使用。
XCTFail(@"无条件失败....");
//2.Boolean Tests
BOOL a = NO;
XCTAssert(a,@"失败时提示:a == false");
XCTAssertTrue(a,@"失败时提示:a == false");
XCTAssertFalse(a,@"失败时提示:a == true");
//3.基础数据类型
NSInteger b = 1;
NSInteger c = 1;
NSInteger d = 2;
XCTAssertEqual(b, c, @"失败时提示:b!= c");
XCTAssertGreaterThan(d, c,@"失败时提示:d < c");
XCTAssertEqualWithAccuracy(c, d, 1,@"失败时提示:c和d的误差的绝对值大于1");
//4.对象类型
NSString *nameA = @"nameA";
NSString *nameB = @"nameB";
XCTAssertEqualObjects(nameA, nameB,@"失败时提示:nameA != nameB");
XCTAssertNil(nameA,@"失败时提示:nameA != nil");
//5. Exception Tests
NSArray *array = @[];
XCTAssertThrows(array[0],@"失败时提示:array[0]没有抛出异常");
XCTAssertNoThrow(array[0],@"失败时提示:array[0]抛出异常");
XCTAssertThrowsSpecific(array[0], NSException,@"失败时提示:array[0]没有抛出NSException异常");
XCTAssertThrowsSpecificNamed(array[0], NSException,@"NSRangeException",@"失败时提示:array[0]没有抛出名为NSRangeException的NSException异常");
}
- 当Xcode运行测试时,它会独立地调用每个测试方法。因此,每个方法必须准备和清理任何辅助变量。如果此代码适用于类中的所有测试方法,则可以将其添加到setUp和tearDown的实例方法中, 在每个测试方法之前和之后调用。
//
-(void)setUp {
[super setUp];
// 初始化代码放在这里. 在调用这个类的每个测试方法之前都要调用.
}
//
-(void)tearDown {
// 销毁代码放在这里. 在调用这个类的每个测试方法之后都要调用.
[super tearDown];
}
您可以选择添加到setUp和tearDown的类方法中,在类中的所有测试方法之前和之后运行。
//
+(void)setUp {
[super setUp];
// 初始化代码放在这里. 在调用这个类的所有测试方法之前调用.
}
//
+(void)tearDown {
// 销毁代码放在这里. 在调用这个类的所有测试方法之后调用.
[super tearDown];
}
以下是MZBayTests测试类的基本结构
//
-(void)setUp {
[super setUp];
// 初始化代码放在这里. 在调用这个类的每个测试方法之前都要调用.
}
//
-(void)tearDown {
// 销毁代码放在这里. 在调用这个类的每个测试方法之后都要调用.
[super tearDown];
}
//
-(void)testExample {
//这是一个功能测试用例的例子。
//使用XCTAssert和相关函数来验证您的测试是否产生正确的结果。
}
//
-(void)testPerformanceExample {
//这是一个性能测试用例的例子。
[self measureBlock:^ {
//把你想要测量运行的时间的代码放在这里。
}];
}
单元测试中包含普通测试,性能测试,异步测试。
- 普通测试
- (void)testModelFunc_randomLessThanTen{
Model * model = [[Model alloc] init];
NSInteger num = [model randomLessThanTen];
XCTAssert(num < 10,@"失败时提示: num should less than 10");
}
- 性能测试
-(void)testAdditionPerformance {
[self measureBlock:^{
//把你想要测量运行的时间的代码放在这里。
for (NSInteger index = 0; index < 100000; index ++) {
NSString *str = [@((index+1) % 100) description];
}
}];
}
- 异步测试
- (void)testAsyncFunction{
//创建一个XCTestExpectation对象。
//这个测试只有一个,可以等待多个XCTestExpectation对象。
XCTestExpectation * expectation = [self expectationWithDescription:@"Just a demo expectation,should pass"];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(1);
NSLog(@"Async test");
XCTAssert(YES,"should pass");
//完成相应操作后调用fulfill 这将导致-waitForExpectation
[expectation fulfill];
});
//测试将在此暂停,运行runloop,直到超时调用 或所有的expectations都调用了fulfill方法。
[self waitForExpectationsWithTimeout:0.5 handler:^(NSError *error) {
//Do something when time out关闭文件等操作
}];
}
5. 运行测试及查看测试结果
- 快捷键:Command + U 即Xcode菜单中 Product -> Test
特点:会运行所有测试类中所有的测试方法。 - Test navigation区:点击测试类或者测试方法的右测运行按钮
点击测试类右侧运行按钮:运行此测试类中的所有测试方法。
点击测试方法右侧运行按钮:运行此测试方法。 - Source editor区:点击测试方法或者测试类名字左侧的运行按钮。
- 运行当前鼠标所在测试方法:control + option + command + U
- Test again(运行刚刚运行过的测试方法):control + option + command + G
-
修改MZBayTests类,添加以下三个测试方法。
step1.MZBayTests类中的测试方法
现在选择Command + U运行测试,测试结果可以在Test Navigation和Source editor中直观看到。绿色的钩代表测试成功,红色的叉表示测试失败。普通测试和性能测试都测试成功,异步测试因为超时失败了。
点击Source editor中testAdditionPerformance测试方法左侧灰色图标,进行最大标准差和baseline的设置。还可查看性能测试运行10次代码所花费的时长。
step1.性能测试
输入Max STDDEV(最大标准差)和baseline并保存。
step2.性能测试
在Reports navigation中,选中你要查看的测试即可查看更详细的测试结果信息。
Reports navigation
- Tests 用来查看详细的测试过程
- Coverage 用来查看代码覆盖率
- Logs 用来查看测试的日志
6. 代码覆盖率
Xcode中的代码覆盖是LLVM提供的的测试选项。 当您启用代码覆盖时,LLVM将根据调用方法和函数的频率,对代码收集覆盖数据。 代码覆盖选项可以收集数据以报告正确性和性能的测试,无论是单元测试还是UI测试。
-
在 scheme editor 菜单中选中 Edit Scheme .
选中 Edit Scheme -
选中Test action,启用代码覆盖复选框以收集覆盖率数据。
启用代码覆盖率
注意:代码覆盖率数据收集会导致性能损失。当启动代码覆盖时,它以线性方式影响代码的执行。当严格评估测试的性能时,应该考虑是否启用代码覆盖。
Reports navigator中 Coverage 菜单中可以查看代码覆盖的相关数据。
Coverage用鼠标选中 - [Calculator input:]方法,将显示一个按钮,点击该按钮将带您进入带注解的源代码。
Coverage
Source editor显示文件中每行代码,在测试期间特定部分代码被调用的次数的注解在Source editor右侧绘制。
Coverage
并突出显示未执行的代码。 它突出了需要覆盖的代码领域,而不是已经涵盖的领域。
Coverage
- 代码覆盖率的意义在于告诉我们,运行测试时实际运行什么代码?代码的哪些部分未被测试?换句话说,是否已经设计了足够的测试,以确保正在检查您的所有代码的正确性和性能?
7. 别人家的测试
- FMDB
创建一个基类FMDBTempDBTests
,让它实现创建数据库,及关闭数据库操作。
其他测试类都继承FMDBTempDBTests
基类。按照功能划分创建了多个测试类。
FMDatabaseAdditionsTests
对应着插入数据测试
FMDatabaseQueueTests
对应着多线程下进行数据库操作测试。 - FMDBTempDBTests.h
FMDBTempDBTests.m的实现
FMDBTempDBTests.m
- +setUp: 创建了一个空的数据库,然后调用实现的协议方法[self populateDatabase:db],进行创建表,并插入数据。
- -setUp: 将+setUp创建好的数据库拷贝到每个测试方法都使用的数据库地址,保证每个测试方法使用的数据库数据都是+setUp配置好的初始数据库,没有被别的测试方法污染。
- -tearDown:关闭数据库。
FMDBTempDBTests的子类只要实现populateDatabase:db方法就配置好测试所需要的数据,可以直接写测试代码了。
FMDatabaseQueueTests.m
编写测试代码
-
定义API要求:添加到项目中的每个方法或函数定义需求和结果很重要。对于需求,包括输入和输出范围,抛出的异常以及引发它们的条件以及返回的值的类型。
-
在编写代码时编写测试用例:在设计和编写每个方法或函数时,请编写一个或多个测试用例以确保满足API的要求。
-
检查边界条件。如果方法的参数必须具有特定范围内的值,则测试应传递包含范围的最低和最高值的值。例如,如果一个过程具有可以具有0到100之间的值的整数参数,则该方法的测试代码应该为参数传递值0,50和100。
-
使用负面测试。负面测试确保您的代码适当地响应错误条件。验证您的代码在收到无效或意外输入值时的行为是否正确。还要验证它是否返回错误代码或引发异常。例如,如果一个整数参数的值必须在0到100之间,包括值,则创建传递值为-1和101的测试用例,以确保该过程引发异常或返回错误代码。
-
编写综合测试用例。综合测试结合不同的代码模块来实现一些更复杂的API行为。虽然简单,孤立的测试提供价值,堆叠测试运行复杂的行为,并倾向于捕获更多的问题。这些类型的测试在更现实的条件下模拟您的代码的行为。例如,除了向数组添加对象之外,您还可以创建数组,向其中添加多个对象,使用不同的方法删除其中的一些对象,然后确保剩余对象的集合和数量正确。
-
用测试用例覆盖您的错误修复。每当您修复错误时,请编写一个或多个验证修补程序的测试用例。
-
测试用例分为三部分:
- 配置测试的初始状态
- 对要测试的目标执行代码
- 对测试结果进行断言(成功 or 失败)
-
测试代码结构
当测试用例多了,你会发现测试代码编写和维护也是一个技术活。通常,我们会从几个角度考虑:- 不要测试私有方法(封装是OOP的核心思想之一,不要为了测试破坏封装)
- 对用例分组(功能,业务相似)
- 对单个用例保证测试独立(不受之前测试的影响,不影响之后的测试),这也是测
试是否准确的核心。 - 提取公共的代码和操作,减少copy/paste这类工作。
让你的代码更容易单元测试
-
通常,为了单元测试的准确性,一个方法对于同样的输入,输出是一致的。如果你写了一个没有参数,或者没有返回值的方法。那这个方法就很难测试了。
-
如果项目框架使用的是MVC,
View只做纯粹的展示型工作,把用户交互通过各种方式传递到外部
Model只做数据存储类工作
Controller作为View和Model的枢纽,往往要和很多View和Model进行交互,也是测试的痛点。 -
对Controller瘦身是iOS架构中比较重要的一环。可参考更轻量的ViewController
- 把 UITableViewDataSource 的代码提取出来放到一个单独的类中,你可以单独测试这个类,可以复用,再也不用写第二遍。也适用于其他的protocol,如UICollectionViewDataSource。
- 将业务逻辑移到 Model 中,查找一个用户的目前的优先事项的列表,做为User的一个Category方法,而不是vc中直接写。
- 创建Store类,Store 对象会关心数据加载、缓存和设置数据栈。它也经常被称为服务层或者仓库。
- 把网络请求逻辑移到 Model 层,要在 view controller 中做网络请求的逻辑。取而代之,你应该将它们封装到另一个类中。这样,你的 view controller 就可以在之后通过使用回调(比如一个 completion 的 block)来请求网络了。这样的好处是,缓存和错误控制也可以在这个类里面完成。
- 把View 代码移到 View 层。不应该在 view controller 中构建复杂的 view 层次结构。你可以使用 Interface Builder 或者把 views 封装到一个 UIView 子类当中。
- 通讯:其他在 view controllers 中经常发生的事是与其他 view controllers,model,和 views 之间进行通讯。这当然是 controller 应该做的,但我们还是希望以尽可能少的代码来完成它。
这块内容很空洞,可以参考更轻量的ViewController来做。