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

时间:2019-02-10 06:49:25

标签: swift unit-testing asynchronous xctest ibaction

我正在尝试测试由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)


}

2 个答案:

答案 0 :(得分:3)

对于任何类型的异步呼叫单元测试,您应使用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 中提取异步调用并编写两个不同的测试。一项用于操作,另一项用于呈现警报。 我建议您也为异步自称为添加额外的测试。 (成功,失败等)

答案 1 :(得分:1)

tl; dr最初寻求的答案结束了

根据您的描述,我认为您不需要在生产代码的这一部分中使用DispatchQueue.main。由于客户端的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 book I'm currently writing中进行深入介绍。)