Как писать автоматические тесты для iOS

Опубликовано: 2022-03-11

Как хороший разработчик, вы делаете все возможное, чтобы протестировать все функциональные возможности и все возможные пути кода и результат в программном обеспечении, которое вы пишете. Но крайне редко и необычно иметь возможность вручную протестировать каждый возможный результат и каждый возможный путь, который может выбрать пользователь.

По мере того, как приложение становится больше и сложнее, вероятность того, что вы что-то пропустите при ручном тестировании, значительно возрастает.

Автоматизированное тестирование как пользовательского интерфейса, так и внутренних API-интерфейсов служб сделает вас более уверенными в том, что все работает так, как задумано, и уменьшит нагрузку при разработке, рефакторинге, добавлении новых функций или изменении существующих.

С помощью автоматических тестов вы можете:

  • Уменьшите количество ошибок: не существует метода, который полностью устранит любую возможность ошибок в вашем коде, но автоматические тесты могут значительно уменьшить количество ошибок.
  • Вносите изменения уверенно: избегайте ошибок при добавлении новых функций, а это значит, что вы можете вносить изменения быстро и безболезненно.
  • Документируйте наш код: просматривая тесты, мы можем ясно видеть, что ожидается от определенных функций, каковы условия и каковы крайние случаи.
  • Рефакторинг безболезненно: Как разработчик, вы иногда можете бояться рефакторинга, особенно если вам нужно рефакторить большой кусок кода. Модульные тесты предназначены для того, чтобы убедиться, что рефакторинговый код по-прежнему работает должным образом.

В этой статье вы узнаете, как структурировать и выполнять автоматизированное тестирование на платформе iOS.

Модульные тесты против UI-тестов

Важно различать модульные тесты и тесты пользовательского интерфейса.

Модульный тест проверяет конкретную функцию в определенном контексте . Модульные тесты проверяют, что тестируемая часть кода (обычно одна функция) делает то, что должна делать. О модульных тестах написано множество книг и статей, поэтому мы не будем их освещать в этом посте.

Тесты пользовательского интерфейса предназначены для тестирования пользовательского интерфейса. Например, он позволяет проверить, обновляется ли представление должным образом или запускается определенное действие, как и должно быть, когда пользователь взаимодействует с определенным элементом пользовательского интерфейса.

Каждый тест пользовательского интерфейса проверяет конкретное взаимодействие пользователя с пользовательским интерфейсом приложения. Автоматизированное тестирование может и должно выполняться как на уровне модульного тестирования, так и на уровне тестирования пользовательского интерфейса.

Настройка автоматических тестов

Поскольку XCode поддерживает модульное и пользовательское тестирование «из коробки», их легко и просто добавить в свой проект. При создании нового проекта просто отметьте «Включить модульные тесты» и «Включить тесты пользовательского интерфейса».

Когда проект будет создан, в ваш проект будут добавлены две новые цели, когда эти две опции будут выбраны. К именам новых целей добавляются «Tests» или «UITests» в конце имени.

Вот и все. Вы готовы писать автоматизированные тесты для своего проекта.

Изображение: Настройка автоматических тестов в XCode.

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

Перейдите в меню « Файл» → «Создать» → «Цель » и выберите « Пакет модульного тестирования iOS» для модульных тестов или « Пакет тестирования пользовательского интерфейса iOS » для тестов пользовательского интерфейса.

Изображение: выбор пакета модульного тестирования iOS.

Нажмите Далее .

На экране параметров цели вы можете оставить все как есть (если у вас есть несколько целей и вы хотите протестировать только определенные цели, выберите цель в раскрывающемся списке Цель для тестирования).

Нажмите Готово . Повторите этот шаг для тестов пользовательского интерфейса, и у вас будет все готово для написания автоматизированных тестов в существующем проекте.

Написание модульных тестов

Прежде чем мы сможем начать писать модульные тесты, мы должны понять их анатомию. Когда вы включаете модульные тесты в свой проект, будет создан пример тестового класса. В нашем случае это будет выглядеть так:

 import XCTest class TestingIOSTests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } }

Наиболее важными для понимания являются setUp и tearDown . Метод setUp вызывается перед каждым методом тестирования, а метод tearDown вызывается после каждого метода тестирования. Если мы запустим тесты, определенные в этом примере тестового класса, методы будут работать следующим образом:

setUp → testExample → tearDown setUp → testPerformanceExample → tearDown

Совет. Тесты запускаются нажатием cmd + U, выбором «Продукт» → «Тест» или нажатием и удержанием кнопки «Выполнить», пока не появится меню параметров, затем выберите «Тест» в меню.

Если вы хотите запустить только один конкретный метод тестирования, нажмите кнопку слева от названия метода (показано на изображении ниже).

Изображение: выбор одного конкретного метода тестирования.

Теперь, когда у вас все готово для написания тестов, вы можете добавить класс-пример и несколько методов для тестирования.

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

Примечание. В этом примере используется архитектурный шаблон MVVM (или Model-View-ViewModel).

MVVM используется, потому что он делает архитектуру приложения более чистой и легкой для тестирования.

С MVVM проще отделить бизнес-логику от логики представления, что позволяет избежать серьезных проблем с контроллером представления.

Подробности об архитектуре MVVM выходят за рамки этой статьи, но вы можете прочитать об этом подробнее в этой статье.

Создадим класс модели представления, отвечающий за регистрацию пользователей. .

 class RegisterationViewModel { var emailAddress: String? { didSet { enableRegistrationAttempt() } } var password: String? { didSet { enableRegistrationAttempt() } } var passwordConfirmation: String? { didSet { enableRegistrationAttempt() } } var registrationEnabled = Dynamic(false) var errorMessage = Dynamic("") var loginSuccessful = Dynamic(false) var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } }

Во-первых, мы добавили несколько свойств, динамические свойства и метод инициализации.

Не беспокойтесь о Dynamic типе. Это часть архитектуры MVVM.

Когда для значения Dynamic<Bool> установлено значение true, контроллер представления, привязанный (подключенный) к RegistrationViewModel , активирует кнопку регистрации. Когда loginSuccessful установлено значение true, подключенное представление будет обновляться.

Давайте теперь добавим несколько методов для проверки правильности пароля и формата электронной почты.

 func enableRegistrationAttempt() { registrationEnabled.value = emailValid() && passwordValid() } func emailValid() -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: emailAddress) } func passwordValid() -> Bool { guard let password = password, let passwordConfirmation = passwordConfirmation else { return false } let isValid = (password == passwordConfirmation) && password.characters.count >= 6 return isValid }

Каждый раз, когда пользователь вводит что-то в поле электронной почты или пароля, метод enableRegistrationAttempt проверяет правильность формата электронной почты и пароля и включает или отключает кнопку registrationEnabled с помощью динамического свойства RegistrationEnabled.

Чтобы упростить пример, добавьте два простых метода — один для проверки доступности электронной почты и один для попытки регистрации с заданным именем пользователя и паролем.

 func checkEmailAvailability(email: String, withCallback callback: @escaping (Bool?)->(Void)) { networkService.checkEmailAvailability(email: email) { (available, error) in if let _ = error { self.errorMessage.value = "Our custom error message" } else if !available { self.errorMessage.value = "Sorry, provided email address is already taken" self.registrationEnabled.value = false callback(available) } } } func attemptUserRegistration() { guard registrationEnabled.value == true else { return } // To keep the example as simple as possible, password won't be hashed guard let emailAddress = emailAddress, let passwordHash = password else { return } networkService.attemptRegistration(forUserEmail: emailAddress, withPasswordHash: passwordHash) { (success, error) in // Handle the response if let _ = error { self.errorMessage.value = "Our custom error message" } else { self.loginSuccessful.value = true } } }

Эти два метода используют NetworkService для проверки доступности электронной почты и попытки регистрации.

Чтобы упростить этот пример, реализация NetworkService не использует какой-либо внутренний API, а является просто заглушкой, которая подделывает результаты. NetworkService реализован как протокол и его класс реализации.

 typealias RegistrationAttemptCallback = (_ success: Bool, _ error: NSError?) -> Void typealias EmailAvailabilityCallback = (_ available: Bool, _ error: NSError?) -> Void protocol NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) }

NetworkService — это очень простой протокол, содержащий всего два метода: попытка регистрации и методы проверки доступности электронной почты. Реализацией протокола является класс NetworkServiceImpl.

 class NetworkServiceImpl: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } }

Оба метода просто ждут некоторое время (имитируя временную задержку сетевого запроса), а затем вызывают соответствующие методы обратного вызова.

Совет: рекомендуется использовать протоколы (также известные как интерфейсы в других языках программирования). Вы можете узнать больше об этом, если вы ищете «принцип программирования для интерфейсов». Вы также увидите, как это хорошо сочетается с модульным тестированием.

Теперь, когда пример установлен, мы можем написать модульные тесты для покрытия методов этого класса.

  1. Создайте новый тестовый класс для нашей модели представления. Щелкните правой кнопкой мыши папку TestingIOSTests на панели Project Navigator, выберите New File → Unit Test Case Class и назовите его RegistrationViewModelTests .

  2. Удалите методы testExample и testPerformanceExample , так как мы хотим создать свои собственные методы тестирования.

  3. Поскольку Swift использует модули, а наши тесты находятся в другом модуле, чем код нашего приложения, мы должны импортировать модуль нашего приложения как @testable . Под оператором импорта и определением класса добавьте @testable import TestingIOS (или имя модуля вашего приложения). Без этого мы не смогли бы ссылаться ни на один из классов или методов нашего приложения.

  4. registrationViewModel переменную RegistrationViewModel.

Вот как теперь выглядит наш пустой тестовый класс:

 import XCTest @testable import TestingIOS class RegistrationViewModelTests: XCTestCase { var registrationViewModel: RegisterationViewModel? override func setUp() { super.setUp() } override func tearDown() { super.tearDown() } }

Попробуем написать тест для метода emailValid . Мы создадим новый тестовый метод с именем testEmailValid . Важно добавить ключевое слово test в начале имени. В противном случае метод не будет признан методом тестирования.

Наш тестовый метод выглядит так:

 func testEmailValid() { let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl()) registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "email@test" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = nil XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") registrationVM.emailAddress = "[email protected]" XCTAssert(registrationVM.emailValid(), "\(registrationVM.emailAddress) should be correct") }

В нашем методе тестирования используется метод утверждения XCTAssert , который в нашем случае проверяет, является ли условие истинным или ложным.

Если условие ложно, то assert не удастся (вместе с тестом), и наше сообщение будет выписано.

Существует множество методов assert, которые вы можете использовать в своих тестах. Описание и демонстрация каждого метода assert легко может стать отдельной статьей, поэтому я не буду здесь вдаваться в подробности.

Некоторые примеры доступных методов утверждения: XCTAssertEqualObjects , XCTAssertGreaterThan , XCTAssertNil , XCTAssertTrue или XCTAssertThrows .

Подробнее о доступных методах assert можно прочитать здесь.

Если вы запустите тест сейчас, метод теста будет пройден. Вы успешно создали свой первый тестовый метод, но он еще не совсем готов для использования в прайм-тайм. У этого метода тестирования все еще есть три проблемы (одна большая и две поменьше), как подробно описано ниже.

Проблема 1: вы используете настоящую реализацию протокола NetworkService

Один из основных принципов модульного тестирования заключается в том, что каждый тест не должен зависеть ни от каких внешних факторов или зависимостей. Модульные тесты должны быть атомарными.

Если вы тестируете метод, который в какой-то момент вызывает метод API с сервера, ваш тест зависит от вашего сетевого кода и от доступности сервера. Если сервер не работает во время тестирования, ваш тест завершится неудачей, что приведет к ошибочному обвинению вашего тестируемого метода в том, что он не работает.

В этом случае вы тестируете метод RegistrationViewModel .

RegistrationViewModel зависит от класса NetworkServiceImpl , хотя вы знаете, что ваш тестируемый метод, emailValid , не зависит напрямую от NetworkServiceImpl .

При написании модульных тестов следует удалить все внешние зависимости. Но как удалить зависимость NetworkService без изменения реализации класса RegistrationViewModel ?

У этой проблемы есть простое решение, и оно называется Object Mocking . Если вы внимательно посмотрите на RegistrationViewModel , то увидите, что на самом деле она зависит от протокола NetworkService .

 class RegisterationViewModel { … // It depends on NetworkService. RegistrationViewModel doesn't even care if NetworkServiceImple exists var networkService: NetworkService init(networkService: NetworkService) { self.networkService = networkService } ...

Когда RegistrationViewModel инициализируется, реализация протокола NetworkService передается (или внедряется) в объект RegistrationViewModel .

Этот принцип называется внедрением зависимостей через конструктор ( существуют и другие виды внедрения зависимостей ).

В Интернете есть много интересных статей о внедрении зависимостей, например, эта статья на objc.io.

Здесь также есть короткая, но интересная статья, объясняющая внедрение зависимостей простым и понятным способом.

Кроме того, в блоге Toptal доступна отличная статья о принципе единой ответственности и DI.

Когда создается экземпляр RegistrationViewModel , он внедряет реализацию протокола NetworkService в свой конструктор (отсюда и название принципа внедрения зависимостей):

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

Поскольку наш класс модели представления зависит только от протокола, ничто не мешает нам создать собственный (или фиктивный) класс реализации NetworkService и внедрить фиктивный класс в наш объект модели представления.

Давайте создадим нашу фиктивную реализацию протокола NetworkService .

Добавьте новый файл Swift в нашу тестовую цель, щелкнув правой кнопкой мыши папку TestingIOSTests в навигаторе проектов, выберите «Новый файл», выберите «Файл Swift» и назовите его NetworkServiceMock .

Вот как должен выглядеть наш фиктивный класс:

 import Foundation @testable import TestingIOS class NetworkServiceMock: NetworkService { func attemptRegistration(forUserEmail email: String, withPasswordHash passwordHash: String, andCallback callback: @escaping RegistrationAttemptCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(true, nil) }) } func checkEmailAvailability(email: String, withCallback callback: @escaping EmailAvailabilityCallback) { // Make it look like method needs some time to communicate with the server DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1), execute: { callback(false, nil) }) } }

На данный момент он не сильно отличается от нашей реальной реализации ( NetworkServiceImpl ), но в реальной ситуации фактический NetworkServiceImpl будет иметь сетевой код, обработку ответов и аналогичную функциональность.

Наш фиктивный класс ничего не делает, в этом суть фиктивного класса. Если он ничего не делает, он не будет мешать нашим тестам.

Чтобы исправить первую проблему нашего теста, давайте обновим наш тестовый метод, заменив:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceImpl())

с участием:

 let registrationVM = RegisterationViewModel(networkService: NetworkServiceMock())

Проблема 2: вы создаете экземпляр RegistrationVM в теле тестового метода

Существуют методы setUp и tearDown не просто так.

Эти методы используются для инициализации или настройки всех необходимых объектов, необходимых для теста. Вы должны использовать эти методы, чтобы избежать дублирования кода, написав одни и те же методы инициализации или настройки в каждом методе тестирования. Неиспользование методов setup и tearDown не всегда является большой проблемой, особенно если у вас есть действительно специфическая конфигурация для конкретного метода тестирования.

Поскольку наша инициализация класса RegistrationViewModel довольно проста, вы проведете рефакторинг своего тестового класса, чтобы использовать методы setup и tearDown.

RegistrationViewModelTests должен выглядеть так:

 class RegistrationViewModelTests: XCTestCase { var registrationVM: RegisterationViewModel! override func setUp() { super.setUp() registrationVM = RegisterationViewModel(networkService: NetworkServiceMock()) } override func tearDown() { registrationVM = nil super.tearDown() } func testEmailValid() { registrationVM.emailAddress = "email.test.com" XCTAssertFalse(registrationVM.emailValid(), "\(registrationVM.emailAddress) shouldn't be correct") ... } }

Проблема 3: у вас есть несколько утверждений в одном методе тестирования

Несмотря на то, что это не большая проблема, есть некоторые сторонники наличия одного утверждения для каждого метода.

Основным аргументом в пользу этого принципа является обнаружение ошибок.

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

Таким образом, вы обнаружите только одну ошибку за раз. Вы не знаете, потерпят ли другие утверждения неудачу или успех.

Не всегда плохо иметь несколько утверждений в одном методе, потому что вы можете исправить только одну ошибку за раз, поэтому обнаружение одной ошибки за раз может быть не такой уж большой проблемой.

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

Поскольку эта проблема на самом деле не является большой проблемой, а некоторые могут даже утверждать, что это вообще не проблема, вы сохраните свой метод тестирования как есть.

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

Методы тестирования с асинхронными вызовами

Независимо от того, насколько просто приложение, существует высокая вероятность того, что будет метод, который необходимо выполнить в другом потоке асинхронно, тем более что вам обычно нравится, чтобы пользовательский интерфейс выполнялся в своем собственном потоке.

Основная проблема с модульным тестированием и асинхронными вызовами заключается в том, что асинхронный вызов требует времени для завершения, но модульный тест не будет ждать, пока он завершится. Поскольку модульный тест завершается до того, как будет выполнен любой код внутри асинхронного блока, наш тест всегда будет заканчиваться одним и тем же результатом (независимо от того, что вы пишете в своем асинхронном блоке).

Чтобы продемонстрировать это, давайте создадим тест для метода checkEmailAvailability .

 func testCheckEmailAvailability() { registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled") } }

Здесь вы хотите проверить, будет ли для переменной RegistrationEnabled установлено значение false после того, как наш метод сообщит вам, что электронная почта недоступна (уже принята другим пользователем).

Если вы запустите этот тест, он будет пройден. Но попробуйте еще кое-что. Измените свое утверждение на:

 XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled")

Если вы снова запустите тест, он снова пройдет.

Это потому, что наше утверждение даже не было утверждено. Модульный тест завершился до того, как был выполнен блок обратного вызова (помните, что в нашей фиктивной реализации сетевой службы он настроен на ожидание в течение одной секунды перед возвратом).

К счастью, в Xcode 6 Apple добавила тестовые ожидания в структуру XCTest в виде класса XCTestExpectation . Класс XCTestExpectation работает следующим образом:

  1. В начале теста вы устанавливаете свое тестовое ожидание — с помощью простого текста, описывающего, что вы ожидаете от теста.
  2. В асинхронном блоке после выполнения вашего тестового кода вы выполняете ожидание.
  3. В конце теста нужно установить блок waitForExpectationWithTimer . Он будет выполнен, когда ожидание сбудется или таймер истечет — в зависимости от того, что произойдет раньше.
  4. Теперь модульный тест не завершится, пока ожидание не будет выполнено или пока не истечет таймер ожидания.

Давайте перепишем наш тест, чтобы использовать класс XCTestExpectation .

 func testCheckEmailAvailability() { // 1. Setting the expectation let exp = expectation(description: "Check email availability") registrationVM.registrationEnabled.value = true registrationVM.checkEmailAvailability(email: "[email protected]") { available in XCTAssert(self.registrationVM.registrationEnabled.value == true, "Email address is not available, registration should be disabled") // 2. Fulfilling the expectation exp.fulfill() } // 3. Waiting for expectation to fulfill waitForExpectations(timeout: 3.0) { error in if let _ = error { XCTAssert(false, "Timeout while checking email availability") } } }

Если вы запустите тест сейчас, он потерпит неудачу — как и должно быть. Давайте исправим тест, чтобы он прошел. Измените утверждение на:

 XCTAssert(self.registrationVM.registrationEnabled.value == false, "Email address is not available, registration should be disabled")

Запустите тест еще раз, чтобы убедиться, что он прошел. Вы можете попробовать изменить время задержки в фиктивной реализации сетевой службы, чтобы увидеть, что произойдет, если таймер ожидания истечет.

Методы тестирования с асинхронными вызовами без обратного вызова

В нашем примере метод проекта attemptUserRegistration использует метод NetworkService.attemptRegistration , который включает код, выполняемый асинхронно. Метод пытается зарегистрировать пользователя во внутренней службе.

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

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.attemptUserRegistration() XCTAssert(registrationVM.loginSuccessful.value, "Login must be successful") }

В случае запуска этот тест завершится ошибкой, поскольку значение loginSuccessful не будет установлено в значение true до тех пор, пока асинхронный метод networkService.attemptRegistration не будет завершен.

Поскольку вы создали имитацию NetworkServiceImpl , в которой метод attemptRegistration будет ждать одну секунду, прежде чем вернуть успешную регистрацию, вы можете просто использовать Grand Central Dispatch (GCD) и использовать метод asyncAfter для проверки вашего утверждения через одну секунду. После добавления GCD asyncAfter наш тестовый код будет выглядеть так:

 func testAttemptRegistration() { registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") } }

Если вы обратили внимание, вы знаете, что это все равно не сработает, потому что тестовый метод будет выполняться до того, как будет выполнен блок asyncAfter , и в результате метод всегда будет успешно проходить. К счастью, есть класс XCTestException .

Давайте перепишем наш метод, чтобы использовать класс XCTestException :

 func testAttemptRegistration() { let exp = expectation(description: "Check registration attempt") registrationVM.emailAddress = "[email protected]" registrationVM.password = "123456" registrationVM.passwordConfirmation = "123456" registrationVM.attemptUserRegistration() DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { XCTAssert(self.registrationVM.loginSuccessful.value, "Login must be successful") exp.fulfill() } waitForExpectations(timeout: 4.0) { error in if let _ = error { XCTAssert(false, "Timeout while attempting a registration") } } }

Благодаря модульным тестам, охватывающим нашу RegistrationViewModel , теперь вы можете быть более уверены, что добавление новых или обновление существующих функций ничего не сломает.

Важное примечание. Модульные тесты теряют свою ценность, если они не обновляются при изменении функциональных возможностей методов, которые они охватывают. Написание модульных тестов — это процесс, который должен идти в ногу с остальной частью приложения.

Совет: не откладывайте написание тестов на конец. Пишите тесты во время разработки. Таким образом, вы будете лучше понимать, что нужно тестировать и каковы пограничные случаи.

Написание UI-тестов

После того, как все модульные тесты полностью разработаны и успешно выполнены, вы можете быть уверены, что каждая единица кода работает правильно, но означает ли это, что ваше приложение в целом работает так, как задумано?

Вот тут-то и появляются интеграционные тесты, важным компонентом которых являются тесты пользовательского интерфейса.

Прежде чем приступить к тестированию пользовательского интерфейса, необходимо протестировать некоторые элементы пользовательского интерфейса и взаимодействия (или пользовательские истории). Давайте создадим простое представление и его контроллер представления.

  1. Откройте Main.storyboard и создайте простой контроллер представления, который будет выглядеть так, как показано на изображении ниже.

Изображение: создание простого представления и его контроллера представления.

Установите тег текстового поля электронной почты на 100, тег текстового поля пароля на 101 и тег подтверждения пароля на 102.

  1. Добавьте новый файл контроллера представления RegistrationViewController.swift и соедините все выходы с раскадровкой.
 import UIKit class RegistrationViewController: UIViewController, UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var passwordConfirmationTextField: UITextField! @IBOutlet weak var registerButton: UIButton! private struct TextFieldTags { static let emailTextField = 100 static let passwordTextField = 101 static let confirmPasswordTextField = 102 } var viewModel: RegisterationViewModel? override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self passwordTextField.delegate = self passwordConfirmationTextField.delegate = self bindViewModel() } }

Здесь вы добавляете в класс IBOutlets и структуру TextFieldTags .

Это позволит вам определить, какое текстовое поле редактируется. Чтобы использовать динамические свойства в модели представления, вы должны «связать» динамические свойства в контроллере представления. Вы можете сделать это в методе bindViewModel :

 fileprivate func bindViewModel() { if let viewModel = viewModel { viewModel.registrationEnabled.bindAndFire { self.registerButton.isEnabled = $0 } } }

Давайте теперь добавим метод делегата текстового поля, чтобы отслеживать, когда любое из текстовых полей обновляется:

 func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { guard let viewModel = viewModel else { return true } let newString = (textField.text! as NSString).replacingCharacters(in: range, with: string) switch textField.tag { case TextFieldTags.emailTextField: viewModel.emailAddress = newString case TextFieldTags.passwordTextField: viewModel.password = newString case TextFieldTags.confirmPasswordTextField: viewModel.passwordConfirmation = newString default: break } return true }
  1. Обновите AppDelegate , чтобы привязать контроллер представления к соответствующей модели представления (обратите внимание, что этот шаг является требованием архитектуры MVVM). Обновленный код AppDelegate должен выглядеть следующим образом:
 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { initializeStartingView() return true } fileprivate func initializeStartingView() { if let rootViewController = window?.rootViewController as? RegistrationViewController { let networkService = NetworkServiceImpl() let viewModel = RegisterationViewModel(networkService: networkService) rootViewController.viewModel = viewModel } }

Файл раскадровки и RegistrationViewController действительно просты, но их достаточно, чтобы продемонстрировать, как работает автоматизированное тестирование пользовательского интерфейса.

Если все настроено правильно, кнопка регистрации должна быть отключена при запуске приложения. Когда и только когда все поля заполнены и действительны, кнопка регистрации должна быть активирована.

Как только это настроено, вы можете создать свой первый тест пользовательского интерфейса.

Наш тест пользовательского интерфейса должен проверить, станет ли кнопка «Регистрация» активной, если и только если были введены действительный адрес электронной почты, действительный пароль и действительное подтверждение пароля. Вот как это настроить:

  1. Откройте файл TestingIOSUITests.swift .
  2. Удалите метод testExample() и добавьте метод testRegistrationButtonEnabled() .
  3. Поместите курсор в метод testRegistrationButtonEnabled , как будто вы собираетесь что-то там написать.
  4. Нажмите кнопку «Запись теста пользовательского интерфейса» (красный кружок внизу экрана).

Изображение: снимок экрана с кнопкой «Запись теста пользовательского интерфейса».

  1. При нажатии на кнопку «Запись» приложение будет запущено.
  2. После запуска приложения коснитесь текстового поля электронной почты и напишите «[email protected]». Вы заметите, что код автоматически появляется внутри тела тестового метода.

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

Это пример инструкции регистратора для нажатия на текстовое поле пароля и ввода адреса электронной почты «[email protected]».

 let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]")
  1. После записи взаимодействий с пользовательским интерфейсом, которые вы хотите протестировать, снова нажмите кнопку остановки (метка кнопки записи изменилась на остановку, когда вы начали запись), чтобы остановить запись.
  2. Теперь, когда у вас есть регистратор взаимодействий с пользовательским интерфейсом, вы можете добавлять различные XCTAsserts для проверки различных состояний приложения или элементов пользовательского интерфейса.

Изображение: анимация, показывающая инструкцию рекордера по нажатию на поле пароля.

Записанные инструкции не всегда говорят сами за себя и могут даже затруднить чтение и понимание всего метода тестирования. К счастью, вы можете вручную вводить инструкции пользовательского интерфейса.

Let's create the following UI instructions manually:

  1. User taps on the password text field.
  2. User enters a 'password'.

To reference a UI element, you can use a placeholder identifier. A placeholder identifier can be set in the storyboard in the Identity Inspector pane under Accessibility. Set the password text field's accessibility identifier to 'passwordTextField'.

The password UI interaction can now be written as:

 let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password")

There is one more UI interaction left: the confirm password input interaction. This time, you'll reference the confirm password text field by its placeholder. Go to storyboard and add the 'Confirm Password' placeholder for the confirm password text field. The user interaction can now be written like this:

 let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password")

Now, when you have all required UI interactions, all that is left is to write a simple XCTAssert (the same as you did in unit testing) to verify if the Register button's isEnabled state is set to true. The register button can be referenced using its title. Assert to check a button's isEnabled property looks like this:

 let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled")

The whole UI test should now look like this:

 func testRegistrationButtonEnabled() { // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("password") let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

If the test is run, Xcode will start the simulator and launch our test application. After the application is launched, our UI interaction instructions will be run one by one and at the end the assert will be successfully asserted.

To improve the test, let's also test that the isEnabled property of the register button is false whenever any of the required fields have not been not entered correctly.

The complete test method should now look like this:

 func testRegistrationButtonEnabled() { let registerButton = XCUIApplication().buttons["REGISTER"] XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Recorded by Xcode let emailTextField = XCUIApplication().otherElements.containing(.staticText, identifier:"Email Address").children(matching: .textField).element emailTextField.tap() emailTextField.typeText("[email protected]") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by accessibility identifier let passwordTextField = XCUIApplication().secureTextFields["passwordTextField"] passwordTextField.tap() passwordTextField.typeText("password") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") // Queried by placeholder text let confirmPasswordTextField = XCUIApplication().secureTextFields["Confirm Password"] confirmPasswordTextField.tap() confirmPasswordTextField.typeText("pass") XCTAssert(registerButton.isEnabled == false, "Registration button should be disabled") confirmPasswordTextField.typeText("word") // the whole confirm password word will now be "password" XCTAssert(registerButton.isEnabled == true, "Registration button should be enabled") }

Tip: The preferred way to identify UI elements is by using accessibility identifiers. If names, placeholders, or some other property that can be localized is used, the element won't be found if a different language is used in which case the test would fail.

The example UI test is very simple, but it demonstrates the power of automated UI testing.

The best way to discover all possibilities (and there are many) of the UI testing framework included in Xcode is to start writing UI tests in your projects. Start with simple user stories, like the one shown, and slowly move to more complex stories and tests.

Become a Better Developer by Writing Good Tests

From my experience, learning and trying to write good tests will make you think about other aspects of development. It will help you become a better iOS developer altogether.

To write good tests, you will have to learn how to better organize your code.

Organized, modular, well-written code is the main requirement for successful and stress-free unit and UI testing.

In some cases, it is even impossible to write tests when code is not organized well.

When thinking about application structure and code organization, you'll realize that by using MVVM, MVP, VIPER, or other such patterns, your code will be better structured, modular, and easy to test (you will also avoid Massive View Controller issues).

When writing tests, you will undoubtedly, at some point, have to create a mocked class. It will make you think and learn about the dependency injection principle and protocol-oriented coding practices. Knowing and using those principles will notably increase your future projects' code quality.

Once you begin writing tests, you will probably notice yourself thinking more about corner cases and edge conditions as you write your code. This will help you eliminate possible bugs before they become bugs. Thinking about possible issues and negative outcomes of methods, you won't only test positive outcomes, but you will also start to test negative outcomes too.

As you can see, unit tests can have impact on different development aspects, and by writing good unit and UI tests, you will likely become a better and happier developer (and you won't have to spend as much time fixing bugs).

Start writing automated tests, and eventually you'll see the benefits of automated testing. When you see it for yourself, you'll become its strongest advocate.