版本
Xcode 11.5
目录
- 概念
- 准备工作
- Unit Tests
- 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及模板代码.
如果新建工程时没有勾选那两项单元测试, 我们也可以后期添加之. 点击TARGET添加按钮:
然后找到那两个单元测试:
添加后就可看到测试代码块:
这里Unit Tests的代码块文件夹名为工程名+Tests; 而UI Tests的代码块文件夹名为工程名+UITests.
并且每个文件夹下都各自默认创建了一个测试类plist文件, 测试类只有.m文件而没有.h文件, 因为单元测试不需要外部来调用, 我们所有的测试工作都在.m文件里面完成.
一个测试类 (.m文件) 里面可以写很多个测试用例. 但如果测试用例太多, 我们可以创建多个测试类以便于分类管理这些测试用例. 比如有专门用于测试算法函数的, 有专门用于测试各种网络请求功能的, 有专门用于测试工具类各功能是否正常的等等.
新建测试类:
例如:
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别的对象的时候, 系统会提示找不到头文件:
这时候我们可以在单元测试的TARGET-->Build settings-->Header Search Paths中添加需要的头文件. 例如:
4. UI Tests
先来看看我们想要达到的效果:
建立两个VC: VC1和VC2. 在VC1里输入账号和密码然后点击登录, 跳转到VC2, 接着点击VC2的Back按钮返回到VC1. 如此循环一万次, 测试我们的登录API有无问题.
在UI Tests的测试用例里, 把光标扔进测试用例代码区, 然后点击小红圈开始录制App界面.
我们每对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];
}
}
注意:
- 有些地方使用sleep等待界面加载出来, 不然在屏幕中找不到改控件会报错;
- 中文登录按钮被自动生成@"\U767b\U5f55", 我们可以手动加入四个0或者直接写成中文.