如何对调用异步任务的UIButton进行单元测试

问题描述 投票:1回答:2

[我正在尝试测试由IBAction触发的登录方法,如果登录失败(如果完成处理程序返回了错误),我想提供一个警报控制器,但是该登录方法显然是异步的。

loginUser方法已经被模拟,并且总是在主线程上返回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()
                    }
                }
            }
        }

我的问题是,如何测试处理程序是否返回.EmptyData错误,警报控制器将显示?

这是我对测试的尝试,集合是测试中的viewController:

func testLoginButtin_ShouldPresentAlertContollerIfErrorIsNotNil() {

    sut.onLoginButtonTapped(sut.loginButton)

    let alert = sut.presentingViewController

    XCTAssertNotNil(alert)


}
swift unit-testing asynchronous xctest ibaction
2个回答
1
投票

tl; dr最初寻求的答案在结尾

根据您的描述,我认为您不需要在生产代码的此部分中使用DispatchQueue.main。由于Client的loginUser(from:with:and:completionHandler:)执行异步操作,然后调用完成处理程序,因此可以保证在主线程上调用完成处理程序。这将在后台解码响应后发生。

如果是这样,则在视图控制器的onLoginButtonTapped(_)中,不需要完成处理程序

再次

调度到主队列。我们已经知道它正在主队列上运行,因此它可以调用self.presentAlertVC()而没有任何技巧。这使我们进入测试代码。伪造的loginUser不必在DispatchQueue.main上安排任何内容。真实版本可以,但假冒版本则不必。我们可以消除这种情况,从而使测试代码更加简单。所有测试代码都可以通过同步进行,从而无需使用XCTestExpectation。

现在,您的虚假客户端不应使用硬编码值来调用完成处理程序。我们希望每个测试都能够配置所需的内容。这将使您测试每条路径。如果您还没有使用协议来替代假货,让我们介绍一下:

protocol ClientProtocol { func loginUser(from url: URL, with username: String, and password: String, completionHandler: @escaping (DrivetimeUserProfile?, DrivetimeAPIError.LoginError?) -> Void) }

您可能已经在这样做了。如果不是,那么您现在正在为客户端创建特定于测试的子类。遵循协议。因此,在您的视图控制器中,您将拥有许多网点和该客户端:

@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,以便测试可以访问它们。

以下是我要编写的测试。忍受我,我最终会解决您所问的问题。首先,让我们测试一下插座的设置。对于我的示例,我假设您使用的是基于情节提要的视图控制器。

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并传递期望的参数。为此,我们可以传递一个模拟对象,使我们可以验证如何调用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... }

(此类内容已在iOS Unit Testing by Example: XCTest Tips and Techniques Using Swift中进行了深入介绍。)


1
投票
对于任何类型的

异步呼叫单元测试

© www.soinside.com 2019 - 2024. All rights reserved.