Как выполнить модульное тестирование UIButton, который вызывает асинхронную задачу - PullRequest
0 голосов
/ 10 февраля 2019

Я пытаюсь протестировать метод входа в систему, вызванный 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 голосов
/ 11 февраля 2019

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 имеет много других свойств , которые вы можете проверить.Вы также можете заставить его выполнять действия кнопки.

С этим тестом легко писать другие.Вы можете вызвать обработчик завершения с другим случаем ошибки или с действительным профилем.Асинхронное тестирование не требуется.

Если вам действительно нужно явно запланировать предупреждение в главном потоке, а не вызывать его напрямую, мы можем это сделать.Создайте ожидание и попросите его проверить его.Добавьте короткое ожидание перед утверждением.

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, которую я сейчас пишу .)

0 голосов
/ 10 февраля 2019

Для любого вида модульного теста асинхронного вызова вы должны использовать 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, также называйте себя .(Успех, неудача и т. Д.)

...