iOS-测试

1,437 阅读11分钟

1、序言

iOS的测试可以分为单元测试UI测试,优秀的测试可以帮助我们快速的检查代码问题,从而写出稳定性强的高质量代码 image.png

  • 所有测试用例都以test开头,包括自建的用例
  • 做测试我们主要遵循下边3步:备数据 --> 调方法 --> 做断言
  • Command + U执行所有测试用例、菱形箭头 执行该方法中测试用例

代码覆盖率

  1. 默认是关闭的代码覆盖率的,需要先开启 image.png
  2. 运行测试,完毕后会显示代码覆盖率 image.png
  3. 你还可以借助苹果提供的命令行工具xccov来生成代码覆盖率报告;值得一提的是,xccov还能输出 JSON 格式的报告

2、单元测试(UITest)

2.1、逻辑测试

  1. 首先我们在ViewController中准备一段要被测试的方法: image.png
  • 然后按三部曲测试正确性: image.png
  • 点击方法前菱形中的 播放按钮 执行测试,正确的会SUCCESS,错误的会FAILD并抛出XCTAssertEqual中预设的错误信息:(我们将预期值210改成错的200看一下) image.png

2.2、异步测试

  1. 准备异步方法
    @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
    
  2. 自建一个异步测试用例,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);
        }];
    }
    
  3. 异步返回用时超过预设时间则会FAILD image.png
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、常规测试
  1. 准备压力测试方法
    @implementation ViewController
    - (void)openCamera {
        for (int i = 0; i < 1000; i++) {
            NSLog(@"测试全部执行完毕耗时");
        }
    }
    @end
    
  2. 调用压力测试方法
    - (void)testPerformanceExample {
        //这是一个性能测试用例示例
        [self measureBlock:^{
            //把你想测量的时间的代码放在这里
            self.vc = [ViewController new];
    
            [self.vc openCamera];
        }];
    }
    
  3. 查看耗时,并可设置基准线 image.png
    • 可以看到平均用时
    • 可设置基准线,超过允许的误差会飘红
    • 可看到平均分布情况,每次测越一致说明性能稳定性越好
2.3.2、局部性能测试
  1. 增加方法,该方法可以作为待测试方法的前置条件
     @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
    
  2. 自定义性能测试用例,使用measureMetrics方法来进行局部性能测试,参数@[XCTPerformanceMetric_WallClockTime](枚举值只有 XCTPerformanceMetric_WallClockTime 这一个),使用startMeasuringstopMeasuring包裹待测试内容 image.png
  3. 我们再将countNum方法也纳入测试,可以看到耗时大幅增加,直接超出预设的0.5秒要求而报错 image.png

3、UI测试

  • 什么时候需要使用 UI 测试:
    • 单元测试无法覆盖时的补充方案
    • 单元测试更精准
    • UI 测试覆盖面的更全
  • UI 测试的步骤:
    1. 与要测试或与逻辑有关的 UI 进行互动
    2. 验证 UIelements 属性和状态

3.1、Record UI Test

可以将你操作手机的行为记录下来,并且转换成代码,帮助你快速生成 UI 测试代码,但智能程度有限,经常需要额外修改,但这也能为我们提供很大帮助;开启方式:选中 UI 测试类,你能在下方看到一个小红点,点击小红点开始录制你的交互 image.png

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 控件,控件类型多样,可能是ButtonCellWindow等等;该类实例有很多模拟交互的方法,如tap模拟用户点击事件,swipe模拟滑动事件,typeText:模拟用户输入内容

  • 一个页面中控件以树状结构存放,我们可以通过 Accessibility identiferlabeltitle 等方式来定位对应的控件
    // 需要勾选 Accessibility Enabled,并且在 Label 一栏填入 myBtn
    XCUIElement *myBtn = app.buttons[@"myBtn"];
    // 模拟用户点击按钮
    [myBtn tap];
    
    // firstMatch 返回第一个符合的控件 
    XCUIElement *textView = app.textViews.firstMatch; 
    // 模拟用户在 textView 输入内容 
    [textView typeText:@"input string"];
    
    image.png
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测试流程

  1. 新建一个 UI 测试 Target
  2. 使用 Record UI Test手写代码定位 UI 元素,并且模拟用户交互事件
  3. 加入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.cellscount属性,当满足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 种类型支持通过代码截屏,分别是XCUIElementXCUIScreen

// 获取一个截屏对象
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以跳过某些步骤

  1. 安装
    source 'https://github.com/CocoaPods/Specs.git' 
        target 'MockDemoTests' do
            pod 'OCMock' #在test target下使用
        end
    
  2. 生成Mock对象
    • OCMClassMock

      优先调用stub实例方法,未找到调用stub类方法,不调用原来方法

    • OCMPartialMock

      优先调用stub实例方法,不能调用stub类方法,否则调用原来的实例方法,不能调用原来类方法,不满足条件无法验证通过

    • OCMStrictClassMock

      只能调用stub方法,否则OCMVerifyAll(mockA)会抛出异常

  3. 置换方法

    调用该方法不会走具体的实现,直接使用return值替换

    id xxxClass = [OCMock mockForClass[XXX class]];
    [OCMStub([xxxClass method:[OCMArg any])andReturn(@"")];
    
  4. 验证方法的调用
    OCMVerify([mock someMethod]);
    
  5. 添加预期
    OCMExpect([mock handleLoadSuccessWithPerson:[OCMArg any]]);