iOS开发之进阶篇(6)—— 单元测试(Unit Tests 和 UI Tests)

版本

Xcode 11.5

目录

  1. 概念
  2. 准备工作
  3. Unit Tests
  4. UI Tests

1. 概念

1.1 单元测试

单元测试是指对软件中的最小可测试单元进行检查和验证. Xcode中有两种单元测试 (Unit Tests 和 UI Tests), Unit Tests 用于测试功能模块; UI Tests用于测试UI交互.

  • Unit Tests 用于测试功能模块, 这些功能模块应尽量单一, 避免与其他功能耦合. 比如测试一个比大小的函数, 一个请求网络的功能等等.
  • UI Tests 用于UI交互. 它可以通过编写代码或者是记录开发者的手动操作过程并代码化, 来实现自动点击某个按钮、视图, 或者自动输入文字等功能.

1.2 测试用例

指我们用于测试某个功能或者UI交互的测试代码.

1.3 断言

断言主要作用是可以让开发者比较便捷的捕获一个错误, 让程序崩溃, 同时报出错误提示. 如果某个断言不通过, 程序将报错, 并定格在断言所在行.
断言只在debug模式下起作用, 在release模式下将被忽略.
一些常用的断言:

XCTAssertNil(expression, ...) expression为空时通过, ...可填入报错信息
XCTAssertNotNil(expression, ...)  expression不为空时通过
XCTAssert(expression, ...)  expression为true时通过
XCTAssertTrue(expression, ...)  expression为true时通过
XCTAssertFalse(expression, ...)  expression为false时通过
XCTAssertEqual(expression1, expression2, ...)  expression1 = expression2 时通过
XCTAssertEqualObjects(expression1, expression2, ...)  expression1 = expression2 时通过
XCTAssertNotEqualObjects(expression1, expression2, ...)  expression1 != expression2 时通过

2. 准备工作

新建工程, 并勾选 Include Unit Tests 和 Include UI Tests, 然后系统将会自动创建单元测试target及模板代码.

create.png

如果新建工程时没有勾选那两项单元测试, 我们也可以后期添加之. 点击TARGET添加按钮:

create2.png

然后找到那两个单元测试:

create3.png

添加后就可看到测试代码块:

tests.png

这里Unit Tests的代码块文件夹名为工程名+Tests; 而UI Tests的代码块文件夹名为工程名+UITests.
并且每个文件夹下都各自默认创建了一个测试类plist文件, 测试类只有.m文件而没有.h文件, 因为单元测试不需要外部来调用, 我们所有的测试工作都在.m文件里面完成.
一个测试类 (.m文件) 里面可以写很多个测试用例. 但如果测试用例太多, 我们可以创建多个测试类以便于分类管理这些测试用例. 比如有专门用于测试算法函数的, 有专门用于测试各种网络请求功能的, 有专门用于测试工具类各功能是否正常的等等.
新建测试类:

create4.png

例如:

ms.png

3. Unit Tests

测试类继承于XCTestCase, 并且系统一开始就给出了如下示例代码:

- (void)setUp {
    // 测试用例开始前执行 (初始化)
}

- (void)tearDown {
    // 测试用例结束后执行 (清理工作)
}

- (void)testExample {
    // 测试用例
}

- (void)testPerformanceExample {
    // 性能测试
    [self measureBlock:^{
        // 性能测试对象 (代码段)
    }];
}

我们的测试用例必须以test开头, 这样才会被系统识别为测试用例. 测试用例方法前面有一个菱形小框, 点击这个将会执行该测试用例, 测试通过则打V, 不通过则打X. 我们也可以⌘U来测试当前类所有的用例.

示例1
测试一个简单的比大小函数, 看测试结果是否正确.

#import "KKAlgorithm.h"

// 最大值
- (void)testMaxValue {
    
    int value1 = 5;
    int value2 = 10;
    int maxInt = [KKAlgorithm maxValueWithValue1:value1 value2:value2];
        
    XCTAssertEqual(maxInt, value2);
//    XCTAssertEqual(maxInt, value1, @"返回最大值错误!");
}

maxInt=value2=10, 测试通过. 如果把value2改为value1, 则测试不通过, 程序报错并定格在XCTAssertEqual所在行.

KKAlgorithm.h

@interface KKAlgorithm : NSObject

// 获取最大值
+ (int)maxValueWithValue1:(int)value1 value2:(int)value2;

@end

KKAlgorithm.m

// 获取最大值
+ (int)maxValueWithValue1:(int)value1 value2:(int)value2 {
    
    return value1 > value2 ? value1 : value2;
}

示例2
请求网络. 因为请求网络是异步的, 我们需要等到网络返回才会判断测试结果. 所以本例中会引入测试等待的方法.
等待方法:

    XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
    XCTestExpectation *expectation1 = [[XCTestExpectation alloc] initWithDescription:@"请求网络1"];
    XCTestExpectation *expectation2 = [[XCTestExpectation alloc] initWithDescription:@"请求网络2"];
    // 异步模拟请求网络1
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [expectation1 fulfill];     // 满足期望
    });
    // 异步模拟请求网络2
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0*NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        [expectation2 fulfill];     // 满足期望
    });
    // 等待10秒, 如果expectation1和expectation2都满足期望, 则继续往下执行
    [waiter waitForExpectations:@[expectation1, expectation2] timeout:10.0];

XCTestExpectation测试期望, 相当于等待的条件.
XCTWaiter用于发动等待, 可以设置等待多个测试期望. 有代理方法, 但这里没有使用到故不详解.

请求百度首页数据的demo:

#import "KKHttp.h"

@interface KKTestsDemoTests : XCTestCase  {
    
    XCTestExpectation *_expectation;
}

// 网络测试
- (void)testHttp {
    
    XCTWaiter *waiter = [[XCTWaiter alloc] initWithDelegate:self];
    _expectation = [[XCTestExpectation alloc] initWithDescription:@"请求百度首页数据"];
    
    // 发起网络请求
    KKHttp *http = [[KKHttp alloc] init];
    http.delegate = self;
    [http fetchBaidu];
    
    // 等待10秒, 如果expectation满足期望, 则继续往下执行
    [waiter waitForExpectations:@[_expectation] timeout:10.0];
}

// 代理回调: 网络返回
- (void)http:(KKHttp *)http receiveData:(NSData *)data error:(NSError *)error {
    
    XCTAssertNotNil(data, @"网络无响应");
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    NSLog(@"dataStr:%@", dataStr);  // XML数据, 这里没进行解析
    
    [_expectation fulfill];     // 结束等待
}

因为测试希望XCTestExpectation在代理回调中满足, 所以期望对象设为全局变量XCTestExpectation *_expectation;.

KKHttp.h

#import 

NS_ASSUME_NONNULL_BEGIN

@class KKHttp;
@protocol KKHttpDelegate 
@optional
- (void)http:(KKHttp *)http receiveData:(nullable NSData *)data error:(nullable NSError *)error;
@end

@interface KKHttp : NSObject

@property (nonatomic, weak) id  delegate;

- (void)fetchBaidu;

@end

NS_ASSUME_NONNULL_END

KKHttp.m

#import "KKHttp.h"

@implementation KKHttp

- (void)fetchBaidu {
    
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"https://www.baidu.com"] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10.0];
    NSURLSession *session = [NSURLSession sharedSession];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if ([self.delegate respondsToSelector:@selector(http:receiveData:error:)]) {
            [self.delegate http:self receiveData:data error:error];
        }
    }];
    [task resume];
}

@end

示例3
实例2的等待方法毕竟很麻烦, 如果测试用例多了, 会产生很多重复代码. 下面讨论用通知的方法来等待, 然后把通知做成宏的形式, 方便调用.
宏前传:

// 异步测试
- (void)testAlbumAuthorization {
       
    // 异步任务
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"222");
        // 发送通知: 结束等待
        [[NSNotificationCenter defaultCenter] postNotificationName:@"KKExpectationNotification" object:nil];
    });
    
    // 等待
    [self expectationForNotification:@"KKExpectationNotification" object:nil handler:nil];
    [self waitForExpectationsWithTimeout:5.0 handler:nil];
    NSLog(@"111");
}

注意
expectationForNotification:object:handler:和waitForExpectationsWithTimeout:handler:要比postNotificationName:object:先执行. 也就是说, 要保证先打印111再打印222, 不然达不到我们预期效果.

我们把等待和结束等待写成宏的形式, 方便别的测试用例调用:

#define WAIT \
    [self expectationForNotification:@"KKExpectationNotification" object:nil handler:nil]; \
    [self waitForExpectationsWithTimeout:5.0 handler:nil];

#define NOTIFY \
    [[NSNotificationCenter defaultCenter] postNotificationName:@"KKExpectationNotification" object:nil];

// 异步测试
- (void)testAlbumAuthorization {
       
    // 异步任务
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"222");
        // 发送通知: 结束等待
        NOTIFY
    });
    
    // 等待
    WAIT
    NSLog(@"111");
}

ps
有时候在测试对象中import别的对象的时候, 系统会提示找不到头文件:

Nofound.png

这时候我们可以在单元测试的TARGET-->Build settings-->Header Search Paths中添加需要的头文件. 例如:

headers.png

4. UI Tests

先来看看我们想要达到的效果:

run.gif

建立两个VC: VC1和VC2. 在VC1里输入账号和密码然后点击登录, 跳转到VC2, 接着点击VC2的Back按钮返回到VC1. 如此循环一万次, 测试我们的登录API有无问题.

在UI Tests的测试用例里, 把光标扔进测试用例代码区, 然后点击小红圈开始录制App界面.

record.png

我们每对App屏幕互动一次, 代码区就会自动生成对应代码:

- (void)testExample {
    
    // 运行App
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];

    
    XCUIApplication *app = [[XCUIApplication alloc] init];
    XCUIElement *element = [[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element;
    [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
    
    XCUIElement *aKey = app/*@START_MENU_TOKEN@*/.keys[@"a"]/*[[".keyboards.keys[@\"a\"]",".keys[@\"a\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
    [aKey tap];
    [aKey tap];
    [aKey tap];
    [aKey tap];
    [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
    
    XCUIElement *bKey = app/*@START_MENU_TOKEN@*/.keys[@"b"]/*[[".keyboards.keys[@\"b\"]",".keys[@\"b\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
    [bKey tap];
    [bKey tap];
    [bKey tap];
    [bKey tap];
    [app/*@START_MENU_TOKEN@*/.staticTexts[@"\U767b\U5f55"]/*[[".buttons[@\"\\U767b\\U5f55\"].staticTexts[@\"\\U767b\\U5f55\"]",".staticTexts[@\"\\U767b\\U5f55\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/ tap];
    [app.buttons[@"Back"] tap];
    
}

当然这些代码是raw的, 我们需要稍微做些修改才行:

- (void)testExample {
    
    // 运行App
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];

    for (int i=0; i<10; i++) {
        
        // 界面中的元素 (控件)
        XCUIElement *element = [[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element;
        
        // 找到第一个输入框, 并点击
        [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:0] tap];
        
        sleep(1);   // 等待键盘弹出
        
        // 点击键盘
        XCUIElement *aKey = app/*@START_MENU_TOKEN@*/.keys[@"a"]/*[[".keyboards.keys[@\"a\"]",".keys[@\"a\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
        [aKey tap];
        
        // 找到第二个输入框, 并点击
        [[[element childrenMatchingType:XCUIElementTypeTextField] elementBoundByIndex:1] tap];
        
        // 点击键盘
        XCUIElement *bKey = app/*@START_MENU_TOKEN@*/.keys[@"b"]/*[[".keyboards.keys[@\"b\"]",".keys[@\"b\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/;
        [bKey tap];
        
        // 找到登录按钮, 并点击
        [app.staticTexts[@"\U0000767b\U00005f55"] tap];
//        [app.staticTexts[@"登录"] tap];
        
        sleep(2);   // 等待加载VC2
        
        // 这时候已经跳转到VC2了
        // 找到Back按钮, 并点击
        [app.buttons[@"Back"] tap];
    }
}

注意:

  1. 有些地方使用sleep等待界面加载出来, 不然在屏幕中找不到改控件会报错;
  2. 中文登录按钮被自动生成@"\U767b\U5f55", 我们可以手动加入四个0或者直接写成中文.

你可能感兴趣的:(iOS开发之进阶篇(6)—— 单元测试(Unit Tests 和 UI Tests))