前言

不写单元测试的程序员是不合格的,为了让自己成为一名合格的程序员,学习如何写单元测试是很有必要的,这里以Xcode集成的测试框架XCTest为例。本文首先会介绍XCTest单元测试的基础用法,然后结合具体的实例分析,最后动手写一个单元测试。

XCTest

基础用法

默认的测试类继承自XCTestCase,当然也可以自定义测试类,添加一些公共的辅助方法。例如AFNetworking的所有测试用例类都有一个共同的父类AFTestCase,它是XCTestCase的子类,AFNetworking所有测试类都是AFTestCase类的子类,这块在后面会具体讲到。需要额外注意的是所有的测试方法都必须以test开头,且不能有参数,不然不会识别为测试方法,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@interface DemoUnitTestsTests : XCTestCase

@end

@implementation DemoUnitTestsTests
// 在每一个测试用例开始前调用,用来初始化相关数据
- (void)setUp {
    [super setUp];
    // Put setup code here. This method is called before the invocation of each test method in the class.
}
// 在测试用例完成后调用,可以用来释放变量等结尾操作
- (void)tearDown {
    // Put teardown code here. This method is called after the invocation of each test method in the class.
    [super tearDown];
}
// 测试方法
- (void)testExample {
    // This is an example of a functional test case.
    // Use XCTAssert and related functions to verify your tests produce the correct results.
}
// 性能测试方法,通过测试block中方法执行的时间,比对设定的标准值和偏差觉得是否可以通过测试
- (void)testPerformanceExample {
    // This is an example of a performance test case.
    [self measureBlock:^{
        // Put the code you want to measure the time of here.
    }];

}

@end
断言

XCTest的断言具体可查阅XCTestAssertions.h文件,这里还是做个简单的总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//通用断言
XCTFail(format…)
//为空判断,a1为空时通过,反之不通过;
XCTAssertNil(a1, format...)
//不为空判断,a1不为空时通过,反之不通过;
XCTAssertNotNil(a1, format…)
//当expression求值为TRUE时通过;
XCTAssert(expression, format...)
//当expression求值为TRUE时通过;
XCTAssertTrue(expression, format...)
//当expression求值为False时通过;
XCTAssertFalse(expression, format...)
//判断相等,[a1 isEqual:a2]值为TRUE时通过,其中一个不为空时,不通过;
XCTAssertEqualObjects(a1, a2, format...)
//判断不等,[a1 isEqual:a2]值为False时通过;
XCTAssertNotEqualObjects(a1, a2, format...)
//判断相等(当a1和a2是 C语言标量、结构体或联合体时使用,实际测试发现NSString也可以);
XCTAssertEqual(a1, a2, format...)
//判断不等(当a1和a2是 C语言标量、结构体或联合体时使用);
XCTAssertNotEqual(a1, a2, format...)
//判断相等,(double或float类型)提供一个误差范围,当在误差范围(+/-accuracy)以内相等时通过测试;
XCTAssertEqualWithAccuracy(a1, a2, accuracy, format...)
//判断不等,(double或float类型)提供一个误差范围,当在误差范围以内不等时通过测试;
XCTAssertNotEqualWithAccuracy(a1, a2, accuracy, format...)
//异常测试,当expression发生异常时通过,反之不通过;
XCTAssertThrows(expression, format...)
//异常测试,当expression发生specificException异常时通过;反之发生其他异常或不发生异常均不通过
XCTAssertThrowsSpecific(expression, specificException, format...)
//异常测试,当expression发生具体异常、具体异常名称的异常时通过测试,反之不通过;
XCTAssertThrowsSpecificNamed(expression, specificException, exception_name, format...)
//异常测试,当expression没有发生异常时通过测试;
XCTAssertNoThrow(expression, format…)
//异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过;
XCTAssertNoThrowSpecific(expression, specificException, format...)
//异常测试,当expression没有发生具体异常、具体异常名称的异常时通过测试,反之不通过
XCTAssertNoThrowSpecificNamed(expression, specificException, exception_name, format...)

当然在有些特殊情况下直接使用这些断言,会让代码看起来很臃肿,比如:

1
XCTAssertTrue([string isKindOfClass:[NSString class]] && ([[NSUUID alloc] initWithUUIDString:string] != nil), @"'%@' is not a valid UUID string", string);

我们可以自定义断言宏来解决这个问题:

1
#define AssertIsValidUUIDString(a1) \ do { \ NSUUID *_u = ([a1 isKindOfClass:[NSString class]] ? [[NSUUID alloc] initWithUUIDString:(a1)] : nil); \ if (_u == nil) { \ XCTFail(@"'%@' is not a valid UUID string", a1); \ } \ } while (0)

使用时只需要调用AssertIsValidUUIDString(string)即可,更多的封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define assertTrue(expr) XCTAssertTrue((expr), @"")

#define assertFalse(expr) XCTAssertFalse((expr), @"")

#define assertNil(a1) XCTAssertNil((a1), @"")

#define assertNotNil(a1) XCTAssertNotNil((a1), @"")

#define assertEqual(a1, a2) XCTAssertEqual((a1), (a2), @"")

#define assertEqualObjects(a1, a2) XCTAssertEqualObjects((a1), (a2), @"")

#define assertNotEqual(a1, a2) XCTAssertNotEqual((a1), (a2), @"")

#define assertNotEqualObjects(a1, a2) XCTAssertNotEqualObjects((a1), (a2), @"")

#define assertAccuracy(a1, a2, acc) XCTAssertEqualWithAccuracy((a1),(a2),(acc))
期望

期望实际上是异步测试,当测试异步方法时,因为结果并不是立刻获得,所以我们可以设置一个期望,期望是有时间限定的的,fulfill表示满足期望。
例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (void)testAsynExample { 
XCTestExpectation *exp = [self expectationWithDescription:@"这里可以是操作出错的原因描述。。。"];
NSOperationQueue *queue = [[NSOperationQueue alloc]init];
[queue addOperationWithBlock:^{
//模拟这个异步操作需要2秒后才能获取结果,比如一个异步网络请求
sleep(2);
//模拟获取的异步操作后,获取结果,判断异步方法的结果是否正确
XCTAssertEqual(@"a", @"a");
//如果断言没问题,就调用fulfill宣布测试满足
[exp fulfill];
}];
//设置延迟多少秒后,如果没有满足测试条件就报错
[self waitForExpectationsWithTimeout:3 handler:^(NSError * _Nullable error) {
if (error) {
NSLog(@"Timeout Error: %@", error);
}
}];
}

异步测试除了使用 expectationWithDescription以外,还可以使用 expectationForPredicateexpectationForNotification,具体的可以看看这里

实例分析

这里以AFNetworking为例,前面提到了AFNetworking的所有测试用例类都有一个共同的父类AFTestCase,它也是XCTestCase的子类。在这个类中,添加了一些熟悉和公共方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#import <XCTest/XCTest.h>

extern NSString * const AFNetworkingTestsBaseURLString;

@interface AFTestCase : XCTestCase

/**
 *  默认 https://httpbin.org/ 一个http库测试工具
 */

@property (nonatomic, strong, readonly) NSURL *baseURL;
@property (nonatomic, assign) NSTimeInterval networkTimeout;

- (void)waitForExpectationsWithCommonTimeoutUsingHandler:(XCWaitCompletionHandler)handler;

@end

这里有两个属性,一个方法,baseURL不用说是测试地址。networkTimeout是网络请求超时时间,waitForExpectationsWithCommonTimeoutUsingHandler是超时后的方法捕获回调,那么什么时候调用这个方法呢,举个例子:
在Xcode 6之前的版本里面并没有内置XCTest,想使用异步测试的只能是在主线程的RunLoop里面使用一个while循环,然后一直等待响应或者直到timeout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)testAsync {
NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:5.0];
__block BOOL responseHasArrived = NO;

[self requestUrl:@"http://httpbin.com"
completionHandler:^(NSString *info) {

responseHasArrived = YES;
XCTAssert(info.length > 0);
}];

while (responseHasArrived == NO && ([timeoutDate timeIntervalSinceNow] > 0)) {
// 启动runloop,设置RunLoop最大时间(假无限循环),执行完毕是否退出
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, YES);
}

if (responseHasArrived == NO) {
XCTFail(@"Test timed out");
}
}

while循环在主线程里面每隔0.01秒会跑一次,直到有响应或者5秒之后超出响应时间限制才会跳出。
而使用XCTest的测试期望来实现这个,测试框架就会预计它在之后的某一时刻被实现。最终的程序完成代码块中的测试代码会调用XCTestExpection类中的fulfill方法来实现期望。这一方法替代了我们之前例子里面使用responseHasArrived作为Flag的方式,这时我们让测试框架等待(有时限)测试期望通过XCTestCase的waitForExpectationsWithTimeout:handler:方法实现。如果完成处理的代码在指定时限里执行并调用了fulfill方法,那么就说明所有的测试期望在此期间都已经被实现。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)testAsync {
XCTestExpectation *expectation =
[self expectationWithDescription:@"High Expectations"];
[self.pageLoader requestUrl:@"http://httpbin.com"
completionHandler:^(NSString *info) {

XCTAssert(info.length > 0);
[expectation fulfill];
}];

[self waitForExpectationsWithTimeout:5.0 handler:^(NSError *error) {
if (error) {
NSLog(@"Timeout Error: %@", error);
}
}];
}

在最后的代码段里面使用[expectation fulfill]来告知此次测试所期望的部分已经确切实现过了。然后用waitForExpectationsWithTimeout:handler方法等待响应,这段会在接受响应之后执行或者超时之后也会执行。

实战

还是以AFNetworking为例,写一个测试网络请求的测试用例,这里用cocoapods导入AFNetworking,需要注意的是此时AFNetworking在单元测试里无法使用,需要手动配置路径,步骤为:

  • 1.复制Target(App) - Build Setting - Header Search Paths 的路径。
  • 2.粘贴到Target(UnitTests) - Build Setting - Header - Search Paths里。
  • 3.复制Target(App) - Build Setting - User-Defined - PODS_ROOT整条。
  • 4.到Target(UnitTests) - Build Setting - User-Defined新建一条PODS_ROOT。

大部分网络请求都是异步操作,但是我们需要在主线程中获取到网络请求成功还是失败的信息。由于测试方法主线程执行完就会结束,所以需要设置一下,查看异步返回结果。这里我们使用期望在方法结束前设置等待,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-(void)testRequest{
    
    XCTestExpectation *expectation =[self expectationWithDescription:@"没有满足期望"];
    AFHTTPSessionManager *sessionManager = [AFHTTPSessionManager manager];
    sessionManager.responseSerializer = [AFHTTPResponseSerializer serializer];
    [sessionManager GET:@"http://www.weather.com.cn/adat/sk/101110101.html" parameters:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        
        NSLog(@"responseObject:%@", [NSJSONSerialization JSONObjectWithData:responseObject options:1 error:nil]);
        XCTAssertNotNil(responseObject, @"返回出错");
        [expectation fulfill];
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        XCTAssertNil(error, @"请求出错");
    }];
    // 设置5秒的超时时间
    [self waitForExpectationsWithTimeout:5.0 handler:^(NSError *error) {
        if (error) {
            NSLog(@"Timeout Error: %@", error);
        }
    }];
}

相关的Demo在这里

Reference

XCTest测试实战

iOS单元测试

iOS单元测试(作用及入门提升)