如何对调用异步任务的 UIButton 进行单元测试
How to unit test a UIButton that calls an asynchronous task
我正在尝试测试由 IBAction 触发的登录方法,如果登录失败(如果完成处理程序返回错误),我想呈现一个警报控制器,但登录方法显然是异步的。
loginUser
方法已经被模拟并且总是在主线程上 returns handler(nil, .EmptyData)
像这样:
func loginUser(from url: URL, with username: String, and password: String, completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
DispatchQueue.main.async {
completionHandler(nil, .EmptyData)
}
}
这是 IBAction
@IBAction func onLoginButtonTapped(_ sender: Any) {
guard let url = URL(string: USER_LOGIN_ENDPOINT) else { return }
let username = userNameTextField.text
let password = passwordTextField.text
client.loginUser(from: url, with: username!, and: password!) { (data, error) in
if let error = error {
switch error {
case .EmptyData:
DispatchQueue.main.async {
presentAlertVC()
}
case .CannotDecodeJson:
DispatchQueue.main.async {
presentAlertVC()
}
}
}
}
我的问题是,如何测试处理程序 returns 是否出现 .EmptyData
错误,警报控制器会显示?
这是我在测试中的尝试,集合是测试中的viewController:
func testLoginButtin_ShouldPresentAlertContollerIfErrorIsNotNil() {
sut.onLoginButtonTapped(sut.loginButton)
let alert = sut.presentingViewController
XCTAssertNotNil(alert)
}
对于任何类型的异步调用单元测试,您应该像这样使用expectation
:
// Create an expectation for a async task.
let expectation = XCTestExpectation(description: "async task")
// Perform async call
myAsyncCall() { (data, x, y) in
// In completion callback, make sure you achieve your needs.
// For example check data is not nil
XCTAssertNotNil(data, "No data was downloaded.")
// Fulfill the expectation to indicate that the task has finished successfully.
expectation.fulfill()
}
// Wait until the expectation is fulfilled, with a timeout of 10 seconds or any you need.
wait(for: [expectation], timeout: 10.0)
关于您的案例,您应该从 IBAction 中提取 async call 并编写两个不同的测试。一项用于行动,一项用于呈现警报。
我建议您也为 async call it self 添加额外的测试。 (成功、失败等)
tl;dr 原先找的答案在最后
根据您的描述,我认为您不需要在这部分生产代码中使用 DispatchQueue.main
。由于Client的loginUser(from:with:and:completionHandler:)
做一些异步的事情,然后调用completion handler,所以可以保证completion handler在主线程上被调用。这将在响应在后台解码后发生。
如果这是真的,那么在视图控制器的 onLoginButtonTapped(_)
中,完成处理程序就不需要再次 分派到主 queue。我们已经知道它在主queue上是运行,所以它可以直接调用self.presentAlertVC()
,不需要任何技巧。
这让我们进入测试代码。您伪造的 loginUser
不必在 DispatchQueue.main
上安排任何事情。真版有,假版没有。我们可以消除它,使测试代码更加简单。所有测试代码都可以同步,无需使用 XCTestExpectation。
现在您的假客户端不应使用 hard-coded 值调用完成处理程序。我们希望每个测试都能够配置它想要的。这将使您测试每条路径。如果您还没有使用协议替换假货,让我们介绍一个:
protocol ClientProtocol {
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void)
}
您可能已经在这样做了。如果没有,那么您现在正在创建客户端的 test-specific 子类。遵循协议。因此,在您的视图控制器中,您将拥有多个插座和此客户端:
@IBOutlet private(set) var loginButton: UIButton!
@IBOutlet private(set) var usernameTextField: UITextField!
@IBOutlet private(set) var passwordTextField: UITextField!
var client: ClientProtocol = Client()
将出口设为 private(set)
而不是 private
以便测试可以访问它们。
这是我要编写的测试。耐心等待,我最终会找到你问的那个。首先,让我们测试网点是否已设置。对于我的示例,我假设您正在使用 storyboard-based 视图控制器。
final class ViewControllerTests: XCTestCase {
private var sut: ViewController!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_outlets_shouldBeConnected() {
sut.loadViewIfNeeded()
XCTAssertNotNil(sut.loginButton, "loginButton")
XCTAssertNotNil(sut.usernameTextField, "usernameTextField")
XCTAssertNotNil(sut.passwordTextField, "passwordTextField")
}
}
接下来,我想测试一些你没有提到的东西:当用户点击登录按钮时,它调用 loginUser
传递预期的参数。为此,我们可以传递一个 mock object 来验证 loginUser
是如何被调用的。它将捕获调用计数和所有参数。它有一个验证方法来确认大部分参数。一个单独的方法为测试提供了一种调用完成处理程序的方法。
private class MockClient: ClientProtocol {
private var loginUserCallCount = 0
private var loginUserArgsURL: [URL] = []
private var loginUserArgsUsername: [String] = []
private var loginUserArgsPassword: [String] = []
private var loginUserArgsCompletionHandler: [(DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void] = []
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
loginUserCallCount += 1
loginUserArgsURL.append(url)
loginUserArgsUsername.append(username)
loginUserArgsPassword.append(password)
loginUserArgsCompletionHandler.append(completionHandler)
}
func verifyLoginUser(from url: URL,
with username: String,
and password: String,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertEqual(loginUserCallCount, 1, "call count", file: file, line: line)
XCTAssertEqual(url, loginUserArgsURL.first, "url", file: file, line: line)
XCTAssertEqual(username, loginUserArgsUsername.first, "username", file: file, line: line)
XCTAssertEqual(password, loginUserArgsPassword.first, "password", file: file, line: line)
}
func invokeLoginUserCompletionHandler(profile: DrivetimeUserProfile?,
error: DrivetimeAPIError.LoginError?,
file: StaticString = #file,
line: UInt = #line) {
guard let handler = loginUserArgsCompletionHandler.first else {
XCTFail("No loginUser completion handler captured", file: file, line: line)
return
}
handler(profile, error)
}
}
因为我们要用它来做几个测试,所以我们把它放在测试夹具中:
private var sut: ViewController!
private var mockClient: MockClient! //
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient() //
sut.client = mockClient //
}
override func tearDown() {
sut = nil
mockClient = nil //
super.tearDown()
}
现在我准备编写第一个点击登录按钮调用客户端的测试:
func test_tappingLoginButton_shouldLoginUserWithEnteredUsernameAndPassword() {
sut.loadViewIfNeeded()
sut.usernameTextField.text = "USER"
sut.passwordTextField.text = "PASS"
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.verifyLoginUser(from: URL(string: "https://your.url")!, with: "USER", and: "PASS")
}
请注意,测试并未调用 sut.onLoginButtonTapped(sut.loginButton)
。相反,测试告诉登录按钮处理 .touchUpInside
。动作方法的名称无关紧要,因此可以声明 IBAction private
.
最后,我们回到您最初的问题。给定一些结果传递给完成处理程序,是否显示警报?为此,我们可以使用我的库 MockUIAlertController。将它添加到您的测试目标。它是用 Objective-C 编写的,因此创建一个导入 MockUIAlertController.h
.
的桥接 header
警报验证器捕获有关所显示警报的信息,但实际上并未显示任何警报。确保单元测试不会触发任何实际警报的最安全方法是将其添加到测试夹具中:
private var sut: ViewController!
private var mockClient: MockClient!
private var alertVerifier: QCOMockAlertVerifier! //
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient()
sut.client = mockClient
alertVerifier = QCOMockAlertVerifier() //
}
override func tearDown() {
sut = nil
mockClient = nil
alertVerifier = nil //
super.tearDown()
}
安全捕获警报后,我们现在可以首先编写您想要的测试:
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}
因为我们搭建了足够的基础设施,所以测试代码很简单。 QCOMockAlertVerifier has many other properties 你可以测试一下。您也可以让它执行按钮操作。
有了这个测试,写其他的就很容易了。您可以使用其他错误情况或有效配置文件调用完成处理程序。不需要异步测试。
如果您真的非常需要在主线程上显式安排警报而不是直接调用它,我们可以这样做。创建一个期望,并要求警报验证者实现它。在断言之前添加一个短暂的等待。
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
let expectation = self.expectation(description: "alert presented") //
alertVerifier.completion = { expectation.fulfill() } //
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
waitForExpectations(timeout: 0.001) //
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}
我正在尝试测试由 IBAction 触发的登录方法,如果登录失败(如果完成处理程序返回错误),我想呈现一个警报控制器,但登录方法显然是异步的。
loginUser
方法已经被模拟并且总是在主线程上 returns handler(nil, .EmptyData)
像这样:
func loginUser(from url: URL, with username: String, and password: String, completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
DispatchQueue.main.async {
completionHandler(nil, .EmptyData)
}
}
这是 IBAction
@IBAction func onLoginButtonTapped(_ sender: Any) {
guard let url = URL(string: USER_LOGIN_ENDPOINT) else { return }
let username = userNameTextField.text
let password = passwordTextField.text
client.loginUser(from: url, with: username!, and: password!) { (data, error) in
if let error = error {
switch error {
case .EmptyData:
DispatchQueue.main.async {
presentAlertVC()
}
case .CannotDecodeJson:
DispatchQueue.main.async {
presentAlertVC()
}
}
}
}
我的问题是,如何测试处理程序 returns 是否出现 .EmptyData
错误,警报控制器会显示?
这是我在测试中的尝试,集合是测试中的viewController:
func testLoginButtin_ShouldPresentAlertContollerIfErrorIsNotNil() {
sut.onLoginButtonTapped(sut.loginButton)
let alert = sut.presentingViewController
XCTAssertNotNil(alert)
}
对于任何类型的异步调用单元测试,您应该像这样使用expectation
:
// Create an expectation for a async task.
let expectation = XCTestExpectation(description: "async task")
// Perform async call
myAsyncCall() { (data, x, y) in
// In completion callback, make sure you achieve your needs.
// For example check data is not nil
XCTAssertNotNil(data, "No data was downloaded.")
// Fulfill the expectation to indicate that the task has finished successfully.
expectation.fulfill()
}
// Wait until the expectation is fulfilled, with a timeout of 10 seconds or any you need.
wait(for: [expectation], timeout: 10.0)
关于您的案例,您应该从 IBAction 中提取 async call 并编写两个不同的测试。一项用于行动,一项用于呈现警报。 我建议您也为 async call it self 添加额外的测试。 (成功、失败等)
tl;dr 原先找的答案在最后
根据您的描述,我认为您不需要在这部分生产代码中使用 DispatchQueue.main
。由于Client的loginUser(from:with:and:completionHandler:)
做一些异步的事情,然后调用completion handler,所以可以保证completion handler在主线程上被调用。这将在响应在后台解码后发生。
如果这是真的,那么在视图控制器的 onLoginButtonTapped(_)
中,完成处理程序就不需要再次 分派到主 queue。我们已经知道它在主queue上是运行,所以它可以直接调用self.presentAlertVC()
,不需要任何技巧。
这让我们进入测试代码。您伪造的 loginUser
不必在 DispatchQueue.main
上安排任何事情。真版有,假版没有。我们可以消除它,使测试代码更加简单。所有测试代码都可以同步,无需使用 XCTestExpectation。
现在您的假客户端不应使用 hard-coded 值调用完成处理程序。我们希望每个测试都能够配置它想要的。这将使您测试每条路径。如果您还没有使用协议替换假货,让我们介绍一个:
protocol ClientProtocol {
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void)
}
您可能已经在这样做了。如果没有,那么您现在正在创建客户端的 test-specific 子类。遵循协议。因此,在您的视图控制器中,您将拥有多个插座和此客户端:
@IBOutlet private(set) var loginButton: UIButton!
@IBOutlet private(set) var usernameTextField: UITextField!
@IBOutlet private(set) var passwordTextField: UITextField!
var client: ClientProtocol = Client()
将出口设为 private(set)
而不是 private
以便测试可以访问它们。
这是我要编写的测试。耐心等待,我最终会找到你问的那个。首先,让我们测试网点是否已设置。对于我的示例,我假设您正在使用 storyboard-based 视图控制器。
final class ViewControllerTests: XCTestCase {
private var sut: ViewController!
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
}
override func tearDown() {
sut = nil
super.tearDown()
}
func test_outlets_shouldBeConnected() {
sut.loadViewIfNeeded()
XCTAssertNotNil(sut.loginButton, "loginButton")
XCTAssertNotNil(sut.usernameTextField, "usernameTextField")
XCTAssertNotNil(sut.passwordTextField, "passwordTextField")
}
}
接下来,我想测试一些你没有提到的东西:当用户点击登录按钮时,它调用 loginUser
传递预期的参数。为此,我们可以传递一个 mock object 来验证 loginUser
是如何被调用的。它将捕获调用计数和所有参数。它有一个验证方法来确认大部分参数。一个单独的方法为测试提供了一种调用完成处理程序的方法。
private class MockClient: ClientProtocol {
private var loginUserCallCount = 0
private var loginUserArgsURL: [URL] = []
private var loginUserArgsUsername: [String] = []
private var loginUserArgsPassword: [String] = []
private var loginUserArgsCompletionHandler: [(DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void] = []
func loginUser(from url: URL,
with username: String,
and password: String,
completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) {
loginUserCallCount += 1
loginUserArgsURL.append(url)
loginUserArgsUsername.append(username)
loginUserArgsPassword.append(password)
loginUserArgsCompletionHandler.append(completionHandler)
}
func verifyLoginUser(from url: URL,
with username: String,
and password: String,
file: StaticString = #file,
line: UInt = #line) {
XCTAssertEqual(loginUserCallCount, 1, "call count", file: file, line: line)
XCTAssertEqual(url, loginUserArgsURL.first, "url", file: file, line: line)
XCTAssertEqual(username, loginUserArgsUsername.first, "username", file: file, line: line)
XCTAssertEqual(password, loginUserArgsPassword.first, "password", file: file, line: line)
}
func invokeLoginUserCompletionHandler(profile: DrivetimeUserProfile?,
error: DrivetimeAPIError.LoginError?,
file: StaticString = #file,
line: UInt = #line) {
guard let handler = loginUserArgsCompletionHandler.first else {
XCTFail("No loginUser completion handler captured", file: file, line: line)
return
}
handler(profile, error)
}
}
因为我们要用它来做几个测试,所以我们把它放在测试夹具中:
private var sut: ViewController!
private var mockClient: MockClient! //
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient() //
sut.client = mockClient //
}
override func tearDown() {
sut = nil
mockClient = nil //
super.tearDown()
}
现在我准备编写第一个点击登录按钮调用客户端的测试:
func test_tappingLoginButton_shouldLoginUserWithEnteredUsernameAndPassword() {
sut.loadViewIfNeeded()
sut.usernameTextField.text = "USER"
sut.passwordTextField.text = "PASS"
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.verifyLoginUser(from: URL(string: "https://your.url")!, with: "USER", and: "PASS")
}
请注意,测试并未调用 sut.onLoginButtonTapped(sut.loginButton)
。相反,测试告诉登录按钮处理 .touchUpInside
。动作方法的名称无关紧要,因此可以声明 IBAction private
.
最后,我们回到您最初的问题。给定一些结果传递给完成处理程序,是否显示警报?为此,我们可以使用我的库 MockUIAlertController。将它添加到您的测试目标。它是用 Objective-C 编写的,因此创建一个导入 MockUIAlertController.h
.
警报验证器捕获有关所显示警报的信息,但实际上并未显示任何警报。确保单元测试不会触发任何实际警报的最安全方法是将其添加到测试夹具中:
private var sut: ViewController!
private var mockClient: MockClient!
private var alertVerifier: QCOMockAlertVerifier! //
override func setUp() {
super.setUp()
let storyboard = UIStoryboard(name: "Main", bundle: nil)
sut = storyboard.instantiateViewController(withIdentifier: "ViewController") as? ViewController
mockClient = MockClient()
sut.client = mockClient
alertVerifier = QCOMockAlertVerifier() //
}
override func tearDown() {
sut = nil
mockClient = nil
alertVerifier = nil //
super.tearDown()
}
安全捕获警报后,我们现在可以首先编写您想要的测试:
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}
因为我们搭建了足够的基础设施,所以测试代码很简单。 QCOMockAlertVerifier has many other properties 你可以测试一下。您也可以让它执行按钮操作。
有了这个测试,写其他的就很容易了。您可以使用其他错误情况或有效配置文件调用完成处理程序。不需要异步测试。
如果您真的非常需要在主线程上显式安排警报而不是直接调用它,我们可以这样做。创建一个期望,并要求警报验证者实现它。在断言之前添加一个短暂的等待。
func test_invokeLoginCompletionHandler_withEmptyData_shouldPresentAlert() {
sut.loadViewIfNeeded()
sut.loginButton.sendActions(for: .touchUpInside)
let expectation = self.expectation(description: "alert presented") //
alertVerifier.completion = { expectation.fulfill() } //
mockClient.invokeLoginUserCompletionHandler(profile: nil, error: .EmptyData)
waitForExpectations(timeout: 0.001) //
XCTAssertEqual(alertVerifier.presentedCount, 1, "presented count")
// more assertions here...
}