1、序言
iOS的测试可以分为单元测试与UI测试,优秀的测试可以帮助我们快速的检查代码问题,从而写出稳定性强的高质量代码
- 所有测试用例都以
test开头,包括自建的用例 - 做测试我们主要遵循下边3步:
备数据-->调方法-->做断言 Command + U执行所有测试用例、菱形箭头 执行该方法中测试用例
代码覆盖率
- 默认是关闭的代码覆盖率的,需要先开启
- 运行测试,完毕后会显示代码覆盖率
- 你还可以借助苹果提供的命令行工具
xccov来生成代码覆盖率报告;值得一提的是,xccov还能输出 JSON 格式的报告
2、单元测试(UITest)
2.1、逻辑测试
- 首先我们在ViewController中准备一段要被测试的方法:
- 然后按三部曲测试正确性:
- 点击方法前菱形中的 播放按钮 执行测试,正确的会SUCCESS,错误的会FAILD并抛出
XCTAssertEqual中预设的错误信息:(我们将预期值210改成错的200看一下)
2.2、异步测试
- 准备异步方法
@implementation ViewController - (void)loadData:(void (^)(id))dataBlock { dispatch_async(dispatch_get_global_queue(0, 0), ^{ [NSThread sleepForTimeInterval:2]; NSString *dataStr = @"loadData"; dispatch_async(dispatch_get_main_queue(), ^{ dataBlock(dataStr); }); }); } @end - 自建一个异步测试用例,
XCTestExpectation设置期望,waitForExpectationsWithTimeout设置异步等待时间,fulfill履行期望,将期望应用到整个异步方法- (void)testAsync { self.vc = [ViewController new]; // 设置期望 XCTestExpectation *ec = [self expectationWithDescription:@"没有达到期望"]; // 调异步方法 [self.vc loadData:^(id data) { // 做断言 XCTAssertNotNil(data); // 将期望应用在整个异步 [ec fulfill]; }]; // 设置容许等待时长 [self waitForExpectationsWithTimeout:2 handler:^(NSError * _Nullable error) { NSLog(@"error = %@",error); }]; } - 异步返回用时超过预设时间则会FAILD
XCTWaiter
代理方式来处理异常情况:
- (void)testAsync {
self.vc = [ViewController new];
XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
XCTestExpectation *ec = [[XCTestExpectation alloc] initWithDescription:@"没有达到期望"];
[self.vc loadData:^(id data) {
XCTAssertNotNil(data);
[ec fulfill];
}];
XCTWaiterResult result = [waiter waitForExpectations:@[ec] timeout:3 enforceOrder:NO];
XCTAssert(result == XCTWaiterResultCompleted, @"failure: %ld", result);
}
XCTWaiterDelegate:如果委托是XCTestCase实例,下方代理被调用时会报告为测试失败:
// 如果有期望超时,则调用。
- (void)waiter:(XCTWaiter *)waiter didTimeoutWithUnfulfilledExpectations:(NSArray<XCTestExpectation *> *)unfulfilledExpectations;
// 当履行的期望被强制要求按顺序履行,但期望以错误的顺序被履行,则调用。
- (void)waiter:(XCTWaiter *)waiter fulfillmentDidViolateOrderingConstraintsForExpectation:(XCTestExpectation *)expectation requiredExpectation:(XCTestExpectation *)requiredExpectation;
// 当某个期望被标记为被倒置,则调用。
- (void)waiter:(XCTWaiter *)waiter didFulfillInvertedExpectation:(XCTestExpectation *)expectation;
// 当 waiter 在 fullfill 和超时之前被打断,则调用。
- (void)nestedWaiter:(XCTWaiter *)waiter wasInterruptedByTimedOutWaiter:(XCTWaiter *)outerWaiter;
2.3、性能测试
2.3.1、常规测试
- 准备压力测试方法
@implementation ViewController - (void)openCamera { for (int i = 0; i < 1000; i++) { NSLog(@"测试全部执行完毕耗时"); } } @end - 调用压力测试方法
- (void)testPerformanceExample { //这是一个性能测试用例示例 [self measureBlock:^{ //把你想测量的时间的代码放在这里 self.vc = [ViewController new]; [self.vc openCamera]; }]; } - 查看耗时,并可设置基准线
- 可以看到平均用时
- 可设置基准线,超过允许的误差会飘红
- 可看到平均分布情况,每次测越一致说明性能稳定性越好
2.3.2、局部性能测试
- 增加方法,该方法可以作为待测试方法的前置条件
@implementation ViewController - (void)countNum { for (int i = 0; i < 1000; i++) { _num++; NSLog(@"countNum = %d",_num); } } - (void)openCamera { for (int i = 0; i < 1000; i++) { NSLog(@"测试全部执行完毕耗时%d",_num); } } @end - 自定义性能测试用例,使用
measureMetrics方法来进行局部性能测试,参数@[XCTPerformanceMetric_WallClockTime](枚举值只有 XCTPerformanceMetric_WallClockTime 这一个),使用startMeasuring与stopMeasuring包裹待测试内容 - 我们再将countNum方法也纳入测试,可以看到耗时大幅增加,直接超出预设的0.5秒要求而报错
3、UI测试
- 什么时候需要使用 UI 测试:
- 单元测试无法覆盖时的补充方案
- 单元测试更精准
- UI 测试覆盖面的更全
- UI 测试的步骤:
- 与要测试或与逻辑有关的 UI 进行互动
- 验证 UIelements 属性和状态
3.1、Record UI Test
可以将你操作手机的行为记录下来,并且转换成代码,帮助你快速生成 UI 测试代码,但智能程度有限,经常需要额外修改,但这也能为我们提供很大帮助;开启方式:选中 UI 测试类,你能在下方看到一个小红点,点击小红点开始录制你的交互
3.2、测试相关类
3.2.1、XCUIApplication
XCUIApplication可以返回一个应用程序实例,然后你就可以通过测试代码启动应用程序
// 返回 UI 测试 Target 设置中选中的 Target Application 的实例
- (instancetype)init;
// 根据 bundleId 返回一个应用程序实例
- (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier;
// 启动应用程序
- (void)launch;
// 将应用程序唤醒至前台,在多程序联合测试下会用到
- (void)activate;
// 结束一个正在运行的应用程序
- (void)terminate;
3.2.2、XCUIElement
应用程序中的 UI 控件,控件类型多样,可能是Button,Cell,Window等等;该类实例有很多模拟交互的方法,如tap模拟用户点击事件,swipe模拟滑动事件,typeText:模拟用户输入内容
- 一个页面中控件以树状结构存放,我们可以通过 Accessibility identifer、label、title 等方式来定位对应的控件
// 需要勾选 Accessibility Enabled,并且在 Label 一栏填入 myBtn XCUIElement *myBtn = app.buttons[@"myBtn"]; // 模拟用户点击按钮 [myBtn tap]; // firstMatch 返回第一个符合的控件 XCUIElement *textView = app.textViews.firstMatch; // 模拟用户在 textView 输入内容 [textView typeText:@"input string"];
3.2.3、XCUIElementQuery
所有满足筛选条件的集合,如app.buttons返回包含了当前所有的button的集合 XCUIElementQuery
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
// id为"login"的NavigationBar中的element
XCUIElement *element = [[app.otherElements containingType:XCUIElementTypeNavigationBar identifier:@"login"] childrenMatchingType:XCUIElementTypeOther].element;
// element中的所有Button
XCUIElementQuery *btnQueue = [element childrenMatchingType:XCUIElementTypeButton];
// 所有Button中的第一个
XCUIElement *myBtn = [btnQueue elementBoundByIndex:0];
XCUIElementQuery 常见定位元素的方法:
-
count:匹配的数量;
// 当 navigationBars 的 count 等于 1 时,你可以直接定位到 navigationBar
app.navigationBars.element -
subscripting:通过 id 来定位
table.staticTexts["Groceries"]
-
index:通过元素的下标来定位
table.staticTexts.elementAtIndex(0)
3.3、UI测试流程
- 新建一个 UI 测试 Target
- 使用 Record UI Test 或 手写代码,
定位 UI 元素,并且模拟用户交互事件 - 加入
XCTAssert等断言逻辑,验证测试是否通过let app = XCUIApplication() // 启动 app app.launch() // 定位元素 let myBtn = app.buttons["myBtn"] // 模拟用户交互事件 myBtn.tap() // 验证测试是否通过 XCTTAssertionEqual(app.tables.cells.count, 1)
3.4、示例
// 测试主流程
- (void)testMainFlow {
// 启动 app
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
// 添加笔记
[self addRecordWithApp:app msg:@"今天天气真好!🌞"];
[self addRecordWithApp:app msg:@"今天詹姆斯特别给力,带领球队走向胜利。✌️"];
while (app.cells.count > 0) {
// 删除笔记
[self deleteFirstRecordWithApp:app];
}
}
/**
添加笔记
@param app app 实例
@param msg 笔记内容
*/
- (void)addRecordWithApp:(XCUIApplication *)app msg:(NSString *)msg {
// 暂存当前 cell 数量
NSInteger cellsCount = app.cells.count;
// 设置一个预期 判断 app.cells 的 count 属性会等于 cellsCount+1, 等待直至失败,如果符合则不再等待
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"count == %d",cellsCount+1];
[self expectationForPredicate:predicate evaluatedWithObject:app.cells handler:nil];
// 定位导航栏+号按钮,点击进入添加笔记页面
XCUIElement *addButton = app.navigationBars[@"Record List"].buttons[@"Add"];
[addButton tap];
// 测试 未输入任何内容点击保存
[app.navigationBars[@"Write Anything"].buttons[@"Save"] tap];
// 定位文本输入框 输入内容
XCUIElement *textView = app.textViews.firstMatch;
[textView typeText:msg];
// 保存
[app.navigationBars[@"Write Anything"].buttons[@"Save"] tap];
// 等待预期
[self waitShortTimeForExpectations];
}
/**
删除最近一个笔记
@param app app 实例
*/
- (void)deleteFirstRecordWithApp:(XCUIApplication *)app {
NSInteger cellsCount = app.cells.count;
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"count == %d",cellsCount-1];
// 设置一个预期 判断 app.cells 的 count 属性会等于 cellsCount-1, 等待直至失败,如果符合则不再等待
[self expectationForPredicate:predicate evaluatedWithObject:app.cells handler:nil];
// 定位到 cell 元素
XCUIElement *firstCell = app.cells.firstMatch;
// 左滑出现删除按钮
[firstCell swipeLeft];
// 定位删除按钮
XCUIElement *deleteButton = [app.buttons matchingIdentifier:@"Delete"].firstMatch;
// 点击删除按钮
if (deleteButton.exists) {
[deleteButton tap];
}
// 等待预期
[self waitShortTimeForExpectations];
}
在上面的逻辑中涉及到异步的请求,我们可以通过利用expectationForPredicate:evaluatedWithObject:handler:方法监听app.cells的count属性,当满足NSPredicate条件时,expectation相当于自动fullfill;如果一直不满足条件,会一直等待直至超时,除此之外还可以用通知和 KVO 的方式实现
4、拓展
4.1、多应用联合测试
多应用联合测试时,依赖XCUIApplication类的以下 2 个方法:
- initWithBundleIdentifier:
- activate
前者可以根据 BundleId 获取其他 App 的实例,让我们可以启动其他 App;后者可以让 App 从后台切换至前台,在多应用间切换;简单实现代码如下:
// 返回 UI 测试 Target 设置中选中的 Target Application 的实例
XCUIApplication *ttApp = [[XCUIApplication alloc] init];
// 使用 BundleId 获得另外一个 App 实例
XCUIApplication *anotherApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"Another.App.BundleId"];
// 先启动我们的主 App
[ttApp launch];
// 做一系列测试
// 启动另一个 App
[anotherApp launch];
// 做一系列测试
// 回到我们的主 App (在 App 未启动的情况下调 activate 会让 App 启动)
[ttApp activate];
4.2、逻辑复杂场景下的 Activities
在一些逻辑比较复杂的测试中,我们可以借助XCTContext类来帮我们把测试逻辑分割成多个小的测试模块;比如说我们有一个业务,关联多个模块,这个时候我们可以用类似下面的代码来处理:
// 模块 1
[XCTContext runActivityNamed:@"step1" block:^(id<XCTActivity> _Nonnull activity) {
XCTestExpectation *expect1 = [self expectationWithDescription:@"asyncTest1"];
[TTFakeNetworkingInstance requestWithService:apiRecordSave completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
[expect1 fulfill];
}];
}];
// 模块 2
[XCTContext runActivityNamed:@"step2" block:^(id<XCTActivity> _Nonnull activity) {
XCTestExpectation *expect2 = [self expectationWithDescription:@"asyncTest2"];
[TTFakeNetworkingInstance requestWithService:apiRecordDelete completionHandler:^(NSDictionary *response) {
XCTAssertTrue([response[@"code"] isEqualToString:@"200"]);
[expect2 fulfill];
}];
}];
[self waitShortTimeForExpectations];
如果测试成功,可以在 Report 导航栏看到成功信息,它会按照你设置的模块分别展示测试结果
如果测试失败,你可以看到哪些模块是成功的,和在哪些模块中失败了
除此之外,你还可以尝试多层嵌套,activity 里面嵌套 activity
4.3、截屏
在 UI 测试中有 2 种类型支持通过代码截屏,分别是XCUIElement和XCUIScreen
// 获取一个截屏对象
XCUIScreenshot *screenshot = [app screenshot];
// 实例化一个附件对象 并传入截屏对象
XCTAttachment *attachment = [XCTAttachment attachmentWithScreenshot:screenshot];
// 附件的存储策略 如果选择 XCTAttachmentLifetimeDeleteOnSuccess 则测试成功的情况会被删除
attachment.lifetime = XCTAttachmentLifetimeKeepAlways;
// 设置一个名字 方便区分
attachment.name = @"MyScreenshot";
[self addAttachment:attachment];
在测试结束后,可以在 Report 导航栏中查看截图:
除此之外 Xcode 提供了自动截图的功能,可以帮助我们在每一个交互操作之后自动截图;此功能会产生大量截图,需要谨慎使用,一般情况最好勾选Delete when each test succeeds,需要在 Edit Scheme --> Test --> Options 中开启
所以你可以根据你的需求选择适当的截图策略
4.4、跳过部分测试
在 Xcode 10 中新增功能,在 Edit Scheme -> Test -> Info -> Tests 中可以通过取消勾选,来选择跳过部分测试用例;在 target 的 Options 选项中,Automatically includes new tests,选项是默认勾选的,新建的测试文件会自动添加进去
4.5、测试用例的执行顺序
默认情况下,测试用例执行的顺序是按字母顺序来执行的,按固定顺序执行可能会使一些隐式的依赖关系无法被发现。现在有了随机的执行顺序,就可以挖掘出那些隐式的依赖关系;可以在 Edit Scheme -> Test -> Info -> Tests -> Options 中开启该功能
4.6、并行测试
并行测试可以同时进行多个测试,从而节省大量时间。在测试时会启动多个模拟器,模拟器之间的数据都是隔离的,可以在 Edit Scheme -> Test -> Info -> Tests -> Options 中开启该功能
对于并行测试的一些建议:
- 某个测试用例需要消耗大量时间的类,可以拆分成多个类并行测试,从而节省时间
- 你需要清楚哪些测试在并行执行时是不安全的,避免并行执行这些测试
- 性能测试的可以统一放在一个 Bundle 中,禁用并行执行
5、OCMock
依赖注入,通过模拟实现单一变量原则,控制变量;原理类似KVO的动态子类
ocmock用来虚拟类及方法的调用。正常情况可以不需要此mock,但在特殊情况下可以进行mock以跳过某些步骤
- 安装
source 'https://github.com/CocoaPods/Specs.git' target 'MockDemoTests' do pod 'OCMock' #在test target下使用 end - 生成Mock对象
- OCMClassMock
优先调用stub实例方法,未找到调用stub类方法,不调用原来方法
- OCMPartialMock
优先调用stub实例方法,不能调用stub类方法,否则调用原来的实例方法,不能调用原来类方法,不满足条件无法验证通过
- OCMStrictClassMock
只能调用stub方法,否则OCMVerifyAll(mockA)会抛出异常
- OCMClassMock
- 置换方法
调用该方法不会走具体的实现,直接使用return值替换
id xxxClass = [OCMock mockForClass[XXX class]]; [OCMStub([xxxClass method:[OCMArg any])andReturn(@"")]; - 验证方法的调用
OCMVerify([mock someMethod]); - 添加预期
OCMExpect([mock handleLoadSuccessWithPerson:[OCMArg any]]);