最近,上线的版本中改动了一个接口,当时没有注意导致了线上严重的Crash.很难受。
哎,不得的不好好学习一下单元测试了。
大家一起学习,一起进步。
大体下面东西分2块。
1.教程
这部分 我也是翻译看了国外网站的文章
https://www.raywenderlich.com/150073/ios-unit-testing-and-ui-testing-tutorial
没有照着上面翻译,完全根据心情来。如果同学,看不下去。可取直接去看原文
自备梯子,语言环境Swift3.0
2 实践
这部分我会抽取一些开源库的测试和我实际项目中的测试来展示
手把手的教程
开始
首先下载教程项目项目 BullsEye 和HalfTunes
BullsEye 是一个模拟靶心的游戏
HalfTunes是从itunes搜索歌曲的项目
在Xcode中单元测试
我们首先打开BullsEye项目,xocde左边导航栏第5个就是测试的栏目,我们可以通过commond+5快速选择。
首先我们在新建项目的时候有选项让我们是否选择创建单元测试,如果你已经选中了,这部完全可以跳过。 教程给的项目是没有预先创建的。
按照第一张图,我们可以创建测试的模板。
我们在系统生成的类中能获取的信息就是
测试的父类 : XCTestCase
开始的方法: setup()
测试结束走的方法:teardown()
3种运行测试的方法
1.运行全部测试用例 Product\Test 或者 Command-U
2 和 3 看图
点击上面的按钮符号 就可以 运行 该类下的所有或者个别方法的测试用例
当运行成功的时候 会变成balabala(你还是自己看图吧)
在运行完测试用例的时候在 testPerformanceExample()
方法中有个测试时间统计 你点击 会出现上图出现的效果
使用XCTAssert 去测试models
在这个进行之前,我建议同一门去看下项目的代码 熟悉 代码,这样事半功倍。
打开BullsEyeTests.swift
文件
添加上图一样的代码
首先gameUnderTest
是一个类级别的SUT(System Under Test)对象,很多测试都会根据这个SUT对象进行
这边作者强调要在teardown()
中的方法中释放SUT对象
一个测试方法的名字必须是test
开头,测试的时候遵循以下几个描述
1.在given中,设置所有需要的值:在例子中 你创建了一个guess值,所以你可以明确与targetValue的差值。
2在when中,执行测试的代码:调用gameUnderTest.check(_:)
3在then中,断言你期望的结果(在项目的例子中,gameUnderTest.scoreRound
是100-5 )和带有失败的结果信息
运行测试,如果成功 测试标记会变成绿色
调试一个测试用例
我们平时debug的时候都会打断点,在测试的时候会有点区别 看下图
如果我们在 given 的时候给了错误的 预给值,就会直接到断言这一步
这地方测试model的时候,我们一半都用来测试数据的正确性
使用 XCTestExpectation 来测试异步操作
我们打开第二个教程项目HalfTunes
当然还是单元测试,上面我们都讲过了怎么新建,这边就不说了。
1.首先我们引入需要测试的项目
@testable import HalfTunes
- 在setup 和 teardown中 创建和释放SUT对象
var sessionUnderTest: URLSession!
override func setUp() {
super.setUp()
sessionUnderTest = URLSession(configuration: URLSessionConfiguration.default)
}
override func tearDown() {
sessionUnderTest = nil
super.tearDown()
}
3.添加异步测试用例
// Asynchronous test: success fast, failure slow
func testValidCallToiTunesGetsHTTPStatusCode200() {
// given
let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
// 1
let promise = expectation(description: "Status code: 200")
// when
let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
// then
if let error = error {
XCTFail("Error: \(error.localizedDescription)")
return
} else if let statusCode = (response as? HTTPURLResponse)?.statusCode {
if statusCode == 200 {
// 2
promise.fulfill()
} else {
XCTFail("Status code: \(statusCode)")
}
}
}
dataTask.resume()
// 3
waitForExpectations(timeout: 5, handler: nil)
}
这个测试用例会发送一个有效的查询到itunes并且返回一个200的的状态码。和平时写的网络请求差不多,只是添加了以下几行代码
1.expectation(_:)
返回一个XCTestExpectation对象。一般接受变量可以这样命名promise expectation future
上面用了promise。description参数,是你期望得到的描述。
2 当在闭包中得到你想要的结果就可以调用promise.fulfill()
3 waitForExpectations(_:handler:)
这个方法会让测试持续的运行,知道所有的 expectation 都被fulfill() 或者到达超时时间。
===
上面的方法是进行网络请求,如果网络请求成功的话。一点问题都没有。但是如果网络请求不成功的话,就意味着你没有调用promise.fulfill()
。这样的后果就是: 一直等待超时
所谓为了更好的完成测试需要代码改进成这样
// Asynchronous test: faster fail
func testCallToiTunesCompletes() {
// given
let url = URL(string: "https://itune.apple.com/search?media=music&entity=song&term=abba")
// 1
let promise = expectation(description: "Completion handler invoked")
var statusCode: Int?
var responseError: Error?
// when
let dataTask = sessionUnderTest.dataTask(with: url!) { data, response, error in
statusCode = (response as? HTTPURLResponse)?.statusCode
responseError = error
// 2
promise.fulfill()
}
dataTask.resume()
// 3
waitForExpectations(timeout: 5, handler: nil)
// then
XCTAssertNil(responseError)
XCTAssertEqual(statusCode, 200)
}
只要闭包回调就直接调用promise.fulfill()
之后再用断言在后面判断
模拟对象和交互
异步测试会让确认你给一步api一个正确的输入,获取你想测试网络请求返回之后代码是否工作正常,或者是否正常更新了数据库。
大多的app都会和系统的库进行交互,这些对象我们不能控制而且特使这些对象的交互会非常的慢以及不可重复。这是你就可以通过输入存根或假的交互通过更新模拟对象。
这边说的不是很好理解,具体看代码
进行虚拟的输入
在下面的测试用例中,你会检查updateSearchResults(_:)
是否正确的解析了网络返回的数据。SUT是VC而且你要虚拟网络回话以及准备好预下载的数据。
var controllerUnderTest: SearchViewController!
override func setUp() {
super.setUp()
controllerUnderTest = UIStoryboard(name: "Main",
bundle: nil).instantiateInitialViewController() as! SearchViewController!
}
override func tearDown() {
controllerUnderTest = nil
super.tearDown()
}
这边SUT是VC 是因为SearchViewController.swift是一个臃肿的VC,如果将网络模块移到单独的地方,就会坚守这些问题。让测试更简单。
模拟网络请求: DHURLSessionMock.swift 已经定义好了
模拟数据:https://itunes.apple.com/search?media=music&entity=song&term=abba&limit=3 自己下
然后在setup()
中
let testBundle = Bundle(for: type(of: self))
let path = testBundle.path(forResource: "abbaData", ofType: "json")
let data = try? Data(contentsOf: URL(fileURLWithPath: path!), options: .alwaysMapped)
let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let urlResponse = HTTPURLResponse(url: url!, statusCode: 200, httpVersion: nil, headerFields: nil)
let sessionMock = URLSessionMock(data: data, response: urlResponse, error: nil)
controllerUnderTest.defaultSession = sessionMock
测试用例
// Fake URLSession with DHURLSession protocol and stubs
func test_UpdateSearchResults_ParsesData() {
// given
let promise = expectation(description: "Status code: 200")
// when
XCTAssertEqual(controllerUnderTest?.searchResults.count, 0, "searchResults should be empty before the data task runs")
let url = URL(string: "https://itunes.apple.com/search?media=music&entity=song&term=abba")
let dataTask = controllerUnderTest?.defaultSession.dataTask(with: url!) {
data, response, error in
// if HTTP request is successful, call updateSearchResults(_:) which parses the response data into Tracks
if let error = error {
print(error.localizedDescription)
} else if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
promise.fulfill()
self.controllerUnderTest?.updateSearchResults(data)
}
}
}
dataTask?.resume()
waitForExpectations(timeout: 5, handler: nil)
// then
XCTAssertEqual(controllerUnderTest?.searchResults.count, 3, "Didn't parse 3 items from fake response")
}
当我们准备的假数据进行解析的时候完成的时候,我们用断言来判断searchResults.count
是否为3 来判断是否解析成功。
UI测试
Xcode7开始介绍了UI测试,让你通过记录与UI的交互来创建一个测试用例。UI测试作用通过寻找一个UI对象的查询和异步事件,然后发送他们给这些对象。api让你可以检查UI对象和状态去比较是否与你期望的状态不同。
我们打开BullsEye项目
我们在在选择 slider和type的时候上面文本和滑动条会有不同的状态。
我们下面的测试用例就是确保选择不同类型的时候 文本和滑动条处于正确的状态。
1 像单元测试一样新建一个UI测试的target
2
声明属性
var app: XCUIApplication!
将setup方法中的XCUIApplication().launch()
替换
app = XCUIApplication()
app.launch()
3 写测试用例
测试用例怎么写 ,这个我们可以用
来记录我们的UI操作
假如我们点击了滑动条和上面的文本,就会自动生成操作代码
let app = XCUIApplication()
app.buttons["Slide"].tap()
app.staticTexts["Get as close as you can to: "].tap()
app 我们已经声明成全局的 所以第一行去掉。
第 2 3 行记录我们的点击事件,可是我们只需要滑动条和文本对象。去掉tap()
然后 我们第一步given就完成了
// given
let slideButton = app.segmentedControls.buttons["Slide"]
let typeButton = app.segmentedControls.buttons["Type"]
let slideLabel = app.staticTexts["Get as close as you can to: "]
let typeLabel = app.staticTexts["Guess where the slider is: "]
when and then
下面的代码也很好理解,这样我们就完成了一个简单的UI测试
// then
if slideButton.isSelected {
XCTAssertTrue(slideLabel.exists)
XCTAssertFalse(typeLabel.exists)
typeButton.tap()
XCTAssertTrue(typeLabel.exists)
XCTAssertFalse(slideLabel.exists)
} else if typeButton.isSelected {
XCTAssertTrue(typeLabel.exists)
XCTAssertFalse(slideLabel.exists)
slideButton.tap()
XCTAssertTrue(slideLabel.exists)
XCTAssertFalse(typeLabel.exists)
}
性能测试
性能测试很简单
只要将测试代码放进去
就可以看到性能分析
测试覆盖率
按照下面的图一步一步
1
设置
2
查看测试率
3
文件中绿色代表测试 红色代表没测试
实践
我们来来看MB的测试用例
从hud的创建和隐藏 每一步都会根据断言去判断
- (void)testNonAnimatedConvenienceHUDPresentation {
UIViewController *rootViewController = UIApplication.sharedApplication.keyWindow.rootViewController;
UIView *rootView = rootViewController.view;
//获得一个hud
MBProgressHUD *hud = [MBProgressHUD showHUDAddedTo:rootView animated:NO];
//测试是否创建成功
XCTAssertNotNil(hud, @"A HUD should be created.");
//测试hud 是否可见
XCTAssertEqualObjects(hud.superview, rootView, @"The hud should be added to the view."); \
XCTAssertEqual(hud.alpha, 1.f, @"The HUD should be visible."); \
XCTAssertFalse(hud.hidden, @"The HUD should be visible."); \
XCTAssertEqual(hud.bezelView.alpha, 1.f, @"The HUD should be visible.");
XCTAssertFalse([hud.bezelView.layer.animationKeys containsObject:@"opacity"], @"The opacity should NOT be animated.");
XCTAssertEqualObjects([MBProgressHUD HUDForView:rootView], hud, @"The HUD should be found via the convenience operation.");
XCTAssertTrue([MBProgressHUD hideHUDForView:rootView animated:NO], @"The HUD should be found and removed.");
MBTestHUDIsHidenAndRemoved(hud, rootView);
XCTAssertFalse([MBProgressHUD hideHUDForView:rootView animated:NO], @"A subsequent HUD hide operation should fail.");
}
Alamofire
简单的网络请求测试
class RequestInitializationTestCase: BaseTestCase {
func testRequestClassMethodWithMethodAndURL() {
// Given
let urlString = "https://httpbin.org/"
// When
let request = Alamofire.request(urlString)
// Then
XCTAssertNotNil(request.request)
XCTAssertEqual(request.request?.httpMethod, "GET")
XCTAssertEqual(request.request?.url?.absoluteString, urlString)
XCTAssertNil(request.response)
}
我自己项目里的登录接口测试
- (void)testLogin {
// This is an example of a functional test case.
//1 given
NSString *phone = @"XXXXX";
NSString *password = @"XXXXXX";
__block NSDictionary * reponse ;
XCTestExpectation *promise = [[XCTestExpectation alloc] init];
//when
[IGONetworkingManager requestUserLoginWithPhone:phone password:password view:nil response:^(id data, NSError *error) {
reponse = (NSDictionary *)data;
[promise fulfill];
}];
[self waitForExpectationsWithTimeout:8 handler:nil];
//then
XCTAssertNil(reponse);
XCTAssertEqual(reponse[@"code"], @"1");
//==数据返回成功,解析
// given
User *user = [User currentUser];
// then
XCTAssertNil(user.userID);
XCTAssertNil(user.userToken);
XCTAssertNil(user.regID);
}