学习重点
单元&UI测试的意义
单元&UI测试执行流程探究及优化
单元&UI测试原理初探
1. 单元&UI测试
1.1 什么是单元测试
单元测试是检查每个代码单元(例如类或函数)是否能产生预期的结果,单元测试是独立运行的,不依赖于其他模块或组件。
1.2 什么是UI测试
UI
测试是属于端到端的测试,是从应用程序启动到结束的测试过程,完全按照用户与应用程序交互的方式来复制与应用程序的交互,比单元测试慢的多,运行起来也更消耗资源。
1.3 需要进行测试的内容
&emsp测试应涵盖以下的内容:
-
核心功能:模型类和方法及其与控制器的交互
-
UI
工作流程 -
特殊的边界条件
-
Bug处理
1.4 测试原则(FIRST)
-
Fast
:测试模块应该是快速高效的 -
Independent/Isolated
:测试模块应该是独立、相互不影响的 -
Repeatable
:测试实例应该是可以重复使用的,测试结果应该是相同的 -
Self-validating
:测试应完全自动化。输出结果要么是“成功”,要么是“失败” -
Timely
:理想情况下,应该在编写要测试的生产代码之前编写测试(测试驱动开发)
2. 单元&UI测试执行流程探究
首先,使用Xcode
创建一个iOS
工程,勾选上Include Tests
选项,如下图所示:
在测试代码运行之前,先来抛出几个问题?
-
- 测试代码运行之前需不需要启动
APP
?
- 测试代码运行之前需不需要启动
-
- 需不需要调用
AppDelegate
中的didFinishLaunchingWithOptions
方法?
- 需不需要调用
为了验证这两个问题的确切答案,在测试工程打上如下的几个断点,如下图所示:
接着运行测试工程中TestAppDemoTests.m
文件中的testExample
方法,如下图所示:
首先,可以看到,App
在模拟器中被启动了,并且程序执行到了main.m
文件中设置的断点处,如下图所示:
过掉断点,程序执行到了AppDelegate.m
文件中设置的断点处,如下图所示:
过掉断点,打印了在测试方法中输出的日志信息,如下图所示:
其中:
红框1
:中的时间表示的是测试方法执行的时间红框2
:表示的是测试方法的名字红框3
:表示测试通过并且耗时0.001
秒。
根据以上的运行结果,可以很清楚的看到程序执行测试方法的时候是会启动APP
并且会调用执行didFinishLaunchingWithOptions
方法的,在一些大型项目中,通常会在didFinishLaunchingWithOptions
方法中执行一些耗时的方法,那么这样就不能快速进行测试了,为了解决这个问题,可以选择创建一个FakeAppDelegate
,只要在测试的时候在main
函数中返回这个FakeAppDelegate
对象就可以了,代码如下所示:
//FakeAppDelegate.h文件中代码
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface FakeAppDelegate : UIResponder <UIApplicationDelegate>
@end
NS_ASSUME_NONNULL_END
//FakeAppDelegate.m文件中代码
#import "FakeAppDelegate.h"
@interface FakeAppDelegate ()
@end
@implementation FakeAppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary<UIApplicationLaunchOptionsKey,id> *)launchOptions {
return YES;
}
#pragma mark - UISceneSession lifecycle
- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options {
return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role];
}
- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet<UISceneSession *> *)sceneSessions {
}
@end
//main.m文件中代码
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "FakeAppDelegate.h"
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
BOOL isShouldReturnFakeAppDelegate = NO;
appDelegateClassName = NSStringFromClass(isShouldReturnFakeAppDelegate ? [FakeAppDelegate class] : [AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
复制代码
你也许会注意到isShouldReturnFakeAppDelegate
这个局部变量的值为NO
,这样的话不就没什么作用了吗?这也正是我想抛出的问题,应该如何设置isShouldReturnFakeAppDelegate
的值呢?在运行的过程中,如何知道此时是测试方法的调用还是实际APP
的运行呢?读者不妨来思考一下这个问题,我会在接下来的讨论中分享两种解决方案。
3. 单元&UI测试执行流程优化
3.1 OC工程执行流程优化
3.1.1 使用runtime API
进行判断
首先你应该注意到的是测试工程中类的继承顺序为:TestAppDemoTests
-->XCTestCase
-->XCTest
,那么可以从这里入手,判断XCTest
这个类是否存在就可以了,在main.m
文件中编写如下的代码:
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "FakeAppDelegate.h"
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
BOOL isShouldReturnFakeAppDelegate = NSClassFromString(@"XCTest") != nil;
appDelegateClassName = NSStringFromClass(isShouldReturnFakeAppDelegate ? [FakeAppDelegate class] : [AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
复制代码
然后分别在AppDelegate.m
文件以及FakeAppDelegate.m
文件中打印如下日志信息:
command+r
运行App
,输出日志信息如下图所示:
运行TestAppDemoTests.m
文件中的testExample
方法,输出日志信息如下图所示:
3.1.2 使用环境变量进行判断
在工程Scheme
下的Test
中的Debug
模式下添加环境变量IS_TESTING
,如下图所示:
然后在main.m
文件中编写如下代码,获取环境变量IS_TESTING
的值
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
#import "FakeAppDelegate.h"
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
BOOL isShouldReturnFakeAppDelegate = [[NSProcessInfo processInfo].environment[@"IS_TESTING"] boolValue];;
appDelegateClassName = NSStringFromClass(isShouldReturnFakeAppDelegate ? [FakeAppDelegate class] : [AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
复制代码
command+r
运行App
,输出日志信息如下图所示:
运行TestAppDemoTests.m
文件中的testExample
方法,输出日志信息如下图所示:
3.2 Swift
工程执行流程优化
- 创建一个简单的
Swift
工程TestSwiftDemo
,如下图所示:
- 会发现在
Swift
工程中并没有main.swift
文件
- 创建
main.swift
文件并在这个文件中编写如下代码:
import UIKit
var appDelegateClsName = NSStringFromClass(AppDelegate.self)
//两种判断方式任选其一
//1.根据XCTest是否存在判断是否正在执行测试方法
if NSClassFromString("XCTest") != nil {
appDelegateClsName = NSStringFromClass(FakeAppDelegate.self)
}
//2.根据环境变量判断是否正在执行测试方法
if ProcessInfo.processInfo.environment["IS_TESTING"] == "true" {
appDelegateClsName = NSStringFromClass(FakeAppDelegate.self)
}
let argv = UnsafeMutableRawPointer(CommandLine.unsafeArgv).bindMemory(to: UnsafeMutablePointer<CChar>.self, capacity: Int(CommandLine.argc))
_ = UIApplicationMain(CommandLine.argc, argv, nil, appDelegateClsName)
复制代码
- 在
Test
中配置如下图所示环境变量
- 运行结果如下图所示:
4. 单元&UI测试原理初探
在使用command + u
运行测试工程之后,按照如下图所示的方式打开编译之后产生的应用程序,会发现会多出AutoTestingDemoUITests-Runner
这个应用程序。
而模拟器中也会安装这个应用程序,如下图所示:
进入到AutoTestingDemo
这个APP
包中,查看其Frameworks
中的文件,发现多了以下几个文件:
也就是说,是不是在一个ipa
包中只要包含了这几个动态库,就可以在项目中运行测试代码了呢?而XCTest.framework
又是从哪里拷贝进来的呢?其实是从Xcode
中的以下路径中获取的:
-
XCTest.framework
(模拟器设备):/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/Frameworks/XCTest.framework
-
XCTest.framework
(真机设备):/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework
只要在工程中添加了XCTest.framework
,就可以不创建测试target
也能在工程中进行代码测试了,首先创建一个xcconfig
文件,如下图所示:
接着在这个xcconfig
文件中按照如下的方式进行配置:
//1.设置动态库头文件路径
HEADER_SEARCH_PATHS = $(inherited) "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/Frameworks/XCTest.framework/Headers"
//2.链接动态库
//2.1传统方式
OTHER_LDFLAGS = $(inherited) -F "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/Frameworks" -framework "XCTest"
//2.2
//3.配置rpath 解决 崩溃Reason: image not found
LD_RUNPATH_SEARCH_PATHS = $(inherited) "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/Frameworks"
复制代码
然后主工程引用这个xcconfig
文件,如下图所示:
&emsp创建一个AppTests类,其代码如下所示:
//AppTests.h文件中的代码
#import <XCTest/XCTest.h>
NS_ASSUME_NONNULL_BEGIN
@interface AppTests : XCTestCase
- (void)testExample1;
- (void)testExample2;
@end
NS_ASSUME_NONNULL_END
//AppTests.m文件中的代码
#import "AppTests.h"
@implementation AppTests
- (void)testExample1 {
NSLog(@"-------testExample1-------");
}
- (void)testExample2 {
NSLog(@"-------testExample2-------");
}
@end
复制代码
然后在ViewController.m文件中编写如下所示的代码:
#import "ViewController.h"
#import <XCTest/XCTest.h>
#import "AppTests.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
//管理者 XCTestSuite,用来管理测试用例
XCTestSuite *suite = [XCTestSuite defaultTestSuite];
//测试用例
AppTests *testCase = [AppTests testCaseWithSelector:@selector(testExample1)];
[suite addTest:testCase];
//遍历其中所有的测试用例,调用其所有的测试方法
for (XCTest *test in suite.tests) {
[test runTest];
}
}
@end
复制代码
接着运行程序,然后点击屏幕,日志输出如下图所示:
但以上代码有个问题,当再次点击屏幕的时候,应用程序会直接奔溃,如下图所示:
如果想要多次运行测试用例,可以通过以下的方式进行调用
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
//管理者 XCTestSuite,用来管理测试用例
XCTestSuite *suite = [XCTestSuite testSuiteForTestCaseClass:AppTests.class];
//测试用例
AppTests *testCase = [AppTests new];
[suite addTest:testCase];
//遍历其中所有的测试用例,调用其所有的测试方法
for (XCTest *test in suite.tests) {
[test runTest];
}
}
复制代码
运行程序,点击屏幕,控制台输出信息如下图所示:
未完待续...