心辰·Dev

iOS 应用 UI 测试开发概览

UI 测试是 iOS 开发中很常见的环节,一般可以分为两类:行为测试和外观测试。本文主要根据几个测试框架来介绍 iOS 平台上行为测试的方案。

行为驱动开发

UI 测试在整个测试开发体系中处于最上层的位置。而依次向下有针对软件 Service 层的服务测试和更细粒度的单元测试(Unit Test)。在单元测试中,测试某个对象的行为方式来确保其按预期运行,引申出行为驱动开发(BDD)的概念。

DSL 语法

BDD 有一套 DSL(Domain-specific Language) 来描述需求。针对一个Car类应该这样写测试:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
SpecBegin(Car)
// 声明测试类
describe(@"Car", ^{
// 声明一组用例
__block Car *car;

// 运行于所有同级块和嵌套块之前的块
beforeEach(^{
car = [Car new];
});

// 运行于所有同级块和嵌套块之后的块
afterEach(^{
car = nil;
});

// it 声明单一实际测试
it(@"should be red", ^{
expect(car.color).to.equal([UIColor redColor]);
});

describe(@"when it is started", ^{

beforeEach(^{
[car start];
});

it(@"should have engine running", ^{
expect(car.engine.running).to.beTruthy();
});
});

describe(@"move to", ^{
// context 提供基于不同状态的期望
context(@"when the engine is running", ^{

beforeEach(^{
car.engine.running = YES;
[car moveTo:CGPointMake(42,0)];
});

it(@"should move to given position", ^{
expect(car.position).to.equal(CGPointMake(42, 0));
});
});

context(@"when the engine is not running", ^{

beforeEach(^{
car.engine.running = NO;
[car moveTo:CGPointMake(42,0)];
});

it(@"should not move to given position", ^{
expect(car.engine.running).to.beTruthy();
});
});
});
});
SpecEnd
// 测试类结束

测试举例

下文是一个测试 UIViewController 的例子。该控制器负责登录页面,包含两个文本框和一个登录按钮。为了使控制器更轻量,把负责登录的组件抽象为SignInManager的单独的类。该类声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface SignInViewController : UIViewController

@property(nonatomic, readwrite) IBOutlet UIButton *signInButton;
@property(nonatomic, readwrite) IBOutlet UITextField *usernameTextField;
@property(nonatomic, readwrite) IBOutlet UITextField *passwordTextField;
@property(nonatomic, readwrite) IBOutlet UILabel *fillInBothFieldsLabel;

@property(nonatomic, readonly) SignInManager *signInManager;

- (instancetype)initWithSignInManager:(SignInManager *)signInManager;

- (IBAction)didTapSignInButton:(UIButton *)signInButton;

@end

可以根据上面声明中的信息进行测试,但行为驱动开发中,不应过多的依赖这些类的实现。好的实践是,应该有一个方法去获取指定的按钮,而不用关心按钮如何定义,想触发按钮对应的方法,只需要调用[self.signInButton sendActionsForControlEvents: UIControlEventTouchUpInside]方法。这样就不用暴露任何内部属性,将SignInViewController声明进行简化:

1
2
3
4
5
6
7
@interface SignInViewController : UIViewController

@property(nonatomic, readonly) SignInManager *signInManager;


- (instancetype)initWithSignInManager:(SignInManager *)signInManager;

@end

针对点击 Login Btn 的行为,具体的测试描述如下:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
describe(@"view", ^{

__block UIView *view;

beforeEach(^{
view = [signInViewController view];
});

describe(@"login button", ^{

__block UITextField *usernameTextField;
__block UITextField *passwordTextField;
__block UIButton *signInButton;

beforeEach(^{
signInButton = [view specsFindButtonWithTitle:@"Sign In"];
usernameTextField = [view specsFindTextFieldWithPlaceholder:@"Username"];
passwordTextField = [view specsFindTextFieldWithPlaceholder:@"Password"];
});

context(@"when login and password are present", ^{
beforeEach(^{
usernameTextField.text = @"Fixture Username";
passwordTextField.text = @"Fixture Password";

[signInButton sendActionsForControlEvents: UIControlEventTouchUpInside];
});

it(@"should tell the sign in manager to sign in with given username and password", ^{
[verify(mockSignInManager) signInWithUsername:@"Fixture Username" password:@"Fixture Password"];
});
});

context(@"when login or password are not present", ^{
beforeEach(^{
usernameTextField.text = @"Fixture Username";
passwordTextField.text = nil;

[signInButton sendActionsForControlEvents: UIControlEventTouchUpInside];
});

it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});

context(@"when neither login or password are present", ^{
beforeEach(^{
usernameTextField.text = nil;
passwordTextField.text = nil;

[signInButton sendActionsForControlEvents: UIControlEventTouchUpInside];
});

it(@"should tell the sign in manager to sign in with given username and password", ^{
[verifyCount(mockSignInManager, never()) signInWithUsername:anything() password:anything()];
});
});
});
});

相关框架使用

一些第三方库提供了更方便的支持,如 Specta 框架,可以辅以 Expecta 匹配框架和 OCMock 置换框架使用。
Specta 框架是轻量级的 BDD DSL 的实现,大部分关键字的使用方法与上文的 DSL 相同;Expecta 是代替 XCTest 框架中的匹配类如XCTAssertEqualObjects / XCTAssertNil的一套匹配框架,用于验证对象间的相等性;OCMock 框架则负责创建模拟对象,尽量少地实例化依赖对象,使测试更快更简洁。
使用 OCMock 更容易验证对象间的交互是否与期望一致。一般在创建真实对象所消耗资源较大、真实对象包含的数据较难控制等的情况下使用,如下例子所示,测试网络请求成功返回后,视图是否响应数据源变化:

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
- (void)testDisplaysTweetsRetrievedFromConnection {
Controller *controller = [[Controller alloc] init];
// mock 一个网络连接
id mockConnection = OCMClassMock([TwitterConnection class]);
controller.connection = mockConnection;

Tweet *testTweet = /* create a tweet somehow */;
NSArray *tweetArray = [NSArray arrayWithObject:testTweet];
// stub 返回模拟的网络数据 tweetArray
OCMStub([mockConnection retrieveTweetsForSearchTerm:[OCMArg any]]).andReturn(tweetArray);

id mockView = OCMClassMock([TweetView class]);
controller.tweetView = mockView;

[controller updateTweetView];

// verify addTweet: 是否被调用,即验证 updateTweetView 是否被调用
OCMVerify([mockView addTweet:[OCMArg any]]);
}

- (void)updateTweetView {
NSArray *tweets = [connection fetchTweets];
if (tweets != nil) {
for (Tweet t in tweets)
[tweetView addTweet:t];
} else {
/* handle error cases */
}
}

利用 XCTest 测试

在移动应用的 UI 测试实践中可以借鉴行为驱动开发的思想。对 iOS 应用进行测试开发,最简便的方法是使用苹果官方的测试框架 XCTest. 它在 XCode 中集成,有很好的文档支持、XCode 界面支持(代码行数栏中的菱形按钮)、导航栏支持(测试用例按组展示)、快捷键支持等。它既包括了适用于单元测试的组件,也对更高层次的 UI 测试提供支持。

  • 测试用例的命名规范:

    1
    2
    3
    - (void)testClassSomeMethod {
    // test code
    }

    想要跳过该测试用例,只需在方法名开头加入DISABLED:

    1
    - (void)DISABLED_testClassSomeMethod;
  • 把共用代码片段整理至一个公共类中,为所有测试用例服务。

  • 测试异步代码时,可以使用waitForExpectationsWithTimeout: handler:方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    - (void)testThatItAppendsAString {
    NSString *s1 = @"Foo";
    XCTestExpectation *expectation = [self expectationWithDescription:@"Handler called"];
    [s1 appendString:@"Bar" resultHandler:^(NSString *result){
    [expectation fulfill];
    XCTAssertEqualObjects(result, @"FooBar");
    }]
    [self waitForExpectationsWithTimeout:0.1 handler:nil];
    }
  • 为确保所有线程代码都运行完毕才进行下一步操作,可以使用 dispatch_group_t。有一定时效的等待组内任务运行结束用如下代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    - (BOOL)waitForGroupToBeEmptyWithTimeout:(NSTimeInterval)timeout {
    NSDate *const end = [[NSDate date] dateByAddingTimeInterval:timeout];

    __block BOOL didComplete = NO;
    dispatch_group_notify(self.requestGroup, dispatch_get_main_queue(), ^{
    didComplete = YES;
    });
    while ((!didComplete) && (0. < [end timeIntervalSinceNow])) {
    // 等待 didComplete 为 YES
    if (! [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.002]]) {
    [NSThread sleepForTimeInterval:0.002];
    }
    }
    return didComplete;
    }

    为更简单的调用该方法,如下形式定义一个宏:

    1
    2
    3
    4
    5
    6
    #define WaitForAllGroupsToBeEmpty(timeout) \
    do { \
    if (! [self waitForGroupToBeEmptyWithTimeout:timeout]) { \
    XCTFail(@"Timed out waiting for groups to empty."); \
    } \
    } while (0)
  • 针对 UI 测试,XCTest 有一套解决方案,关键类如 XCUIElement / XCUIApplication等,可以非常方便地根据 Accessibility 取到界面上的控件,并模仿用户行为。如模拟界面上按钮点击的测试代码如下:

    1
    2
    3
    XCUIApplication *app = [[XCUIApplication alloc] init];
    XCUIElement *button = app.buttons[@"点吧"];
    [button tap];

利用 KIF 框架测试

在 XCTest 框架的基础上,KIF 框架提供了一套基于 Accessibility 属性的测试方案。使用 KIF 框架使开发测试人员站在用户的角度,考虑用户行为编写用例。可以通过 Cocoapods 方便地把 KIF 集成到项目中,也可以手动把 KIF 工程加为项目的子工程。

框架配置

手动加入 KIF 框架并开始使用 KIF 测试,要进行以下步骤的配置:

  • 在创建 test target 时,应选中 iOS Unit Testing Bundle 并命名,如”XX_Test”。创建完 test target 要把自动生成的 “XX_Tests.m/h” 删除。
  • 选中 test target, 对其进行配置。首先在Build Phases标签页的Link Binary With Libraries区域加入libKIF.a静态库和CoreGraphics.framework/QuartzCore.framework两个系统库。
  • Build Setting标签页的Other Linker Flags区域加入”-framework IOKit”,以满足 KIF 对IOKit.framework的依赖。
  • Build Setting标签页的Other Linker Flags区域加入”-ObjC”,使静态库libKIF.a有更多的动态性。
框架使用

框架中有两个重要的类,一个是KIFTestCase,它作为XCTestCase的子类,加载测试用例类并执行测试。在每个测试中,另一个类KIFUITestActor去模拟用户的诸如点击、输入文字、等待等行为。还以上文中的登录控制器为例,创建 TestCase 如下:

1
2
3
4
5
#import <KIF/KIF.h>

@interface LoginTests : KIFTestCase

@end

在 LoginTests 实现文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#import "LoginTests.h"
#import "KIFUITestActor+EXAdditions.h"

@implementation LoginTests

- (void)beforeEach {
// 先导航至登录页面
[tester navigateToLoginPage];
}

- (void)afterEach {

}

- (void)testSuccessfulLogin {
[tester enterText:@"Fixture Username" intoViewWithAccessibilityLabel:@"Username"];
[tester enterText:@"Fixture Password" intoViewWithAccessibilityLabel:@"Password"];
[tester tapViewWithAccessibilityLabel:@"Sign In"];

// Verify that the login succeeded
[tester waitForTappableViewWithAccessibilityLabel:@"Welcome"];
}

@end

以上代码就模拟了用户的输入账户、密码并点击登录的行为。在KIFUITestActor的拓展KIFUITestActor+EXAdditions中可以实现更多的动作如navigateToLoginPage
KIF 框架还可以与其他测试框架协同使用,只要是在 XCTestCase 或其子类中,就可以运行KIFUITestActor中定义的模拟行为测试。
结合 FaceBook 的编译工具 xctool, 还可以编写脚本来自动定时运行 UI 测试。在脚本中可用以下命令启动模拟器测试:

1
2
3
4
xctool -workspace YourWorkspace.xcworkspace \
-scheme YourScheme \
-sdk iphonesimulator \
run-tests

利用原生方法测试

上文中提到过触发某个按钮相关联的 actions 的方法sendActionsForControlEvents,但并不是所有的控件都有原生方法来实现这个效果。针对 TableView 可以选择调用 TableView 的代理实现的代理方法,来进行单元格点击模拟,如:

1
[_viewController.tableView.delegate tableView:_viewController.tableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];  // 点击 section 为 0 row 为 0 的单元格

而对于不继承UIControlUIBarButtonItem,最好使用 runtime 的方法调用绑定在其之上的 action:

1
objc_msgSend(_viewController.barButton.target, _viewController.barButton.action, _viewController.barButton);

这样可以避免使用performSelector:而引发的的 ARC 警告。

外观测试

外观测试作为 UI 测试的另一个主要方面,提供了更为直观的测试思路。利用 FaceBook 提供的框架 FBSnapShotTestCase 可以方便地对项目进行截图测试。该框架可以对 UIView 或 CALayer 的内容截图,在运行测试时与已有的存档界面图片比较,感知界面的改变,以此判断是否通过测试。整个过程高度自动化,是 UI 外观测试的一大利器。