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
61SpecBegin(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
60describe(@"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
3XCUIApplication *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
4xctool -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 的单元格
而对于不继承UIControl
的UIBarButtonItem
,最好使用 runtime 的方法调用绑定在其之上的 action:1
objc_msgSend(_viewController.barButton.target, _viewController.barButton.action, _viewController.barButton);
这样可以避免使用performSelector:
而引发的的 ARC 警告。
外观测试
外观测试作为 UI 测试的另一个主要方面,提供了更为直观的测试思路。利用 FaceBook 提供的框架 FBSnapShotTestCase 可以方便地对项目进行截图测试。该框架可以对 UIView 或 CALayer 的内容截图,在运行测试时与已有的存档界面图片比较,感知界面的改变,以此判断是否通过测试。整个过程高度自动化,是 UI 外观测试的一大利器。