Предисловие
В ходе разработки ios-приложения, перед разработчиком может встать задача unit-тестирования кода. Именно с такой задачей столкнулся я.
Задача
Допустим, у нас есть приложение с аутентификацией. За аутентификацию, в нём отвечает сервис аутентификации — AuthenticationService. Для примера, у него будут два метода, оба аутентифицируют пользователя, но один синхронный, а другой асинхронный:
protocol AuthenticationService {
typealias Login = String
typealias Password = String
typealias isSucces = Bool
/// Функция аутентификации пользователя
///
/// - Parameters:
/// - login: Учётная запись
/// - password: Пароль
/// - Returns: Успешность аутентификации
func authenticate(with login: Login, and password: Password) -> isSucces
/// Асинхронная функция аутентификации пользователя
///
/// - Parameters:
/// - login: Учётная запись
/// - password: Пароль
/// - authenticationHandler: Callback(completionHandler) аутентификации
func asyncAuthenticate(with login: Login, and password: Password, authenticationHandler: @escaping (isSucces) -> Void)
}
Имеется viewController, который будет использовать этот сервис:
class ViewController: UIViewController {
var authenticationService: AuthenticationService!
var login = "Login"
var password = "Password"
/// Обработчик аутентификации, используется для асинхронной аутентификации
var aunthenticationHandler: ((Bool) -> Void) = { (isAuthenticated) in
print("\nРезультат асинхронной функции:")
isAuthenticated ? print("Добро пожаловать") : print("В доступе отказано")
}
override func viewDidLoad() {
super.viewDidLoad()
authenticationService = AuthenticationServiceImplementation() // Какая-то реализация сервиса аутентификации, нам не важно, т.к. тестировать мы будем viewController
performAuthentication()
performAsyncAuthentication()
}
func performAuthentication() {
let isAuthenticated = authenticationService.authenticate(with: login, and: password)
print("Результат синхронной функции:")
isAuthenticated ? print("Добро пожаловать") : print("В доступе отказано")
}
func performAsyncAuthentication() {
authenticationService.asyncAuthenticate(with: login, and: password, and: aunthenticationHandler)
}
}
Нам нужно протестировать viewController.
Решение
Т.к. мы не хотим, чтобы наши тесты зависели от каки-либо ещё объектов, кроме класса нашего viewController'a, мы будем мокировать все его зависимости. Для этого сделаем заглушку сервиса аутентификации. Выглядела бы она примерно вот так:
class MockAuthenticationService: AuthenticationService {
var emulatedResult: Bool? // То, что вернёт синхронная функция аутентификации
var receivedLogin: AuthenticationService.Login? // Поле для проверки полученния логина
var receivedPassword: AuthenticationService.Password? // Поле для проверки полученния пароля
var receivedAuthenticationHandler: ((AuthenticationService.isSucces) -> Void)? // Обработчик, с помощью которого будем управлять возвращаемым значением при тестировании функции асинхронной аутентификации
func authenticate(with login: AuthenticationService.Login,
and password: AuthenticationService.Password) -> AuthenticationService.isSucces {
receivedLogin = login
receivedPassword = password
return emulatedResult ?? false
}
func asyncAuthenticate(with login: AuthenticationService.Login,
and password: AuthenticationService.Password,
and authenticationHandler: @escaping (AuthenticationService.isSucces) -> Void) {
receivedLogin = login
receivedPassword = password
receivedAuthenticationHandler = authenticationHandler
}
}
В ручную писать столько кода для каждой зависимости, очень не приятное занятие (особенно приятно переписывать их, когда у зависимостей меняется протокол). Я начал искать решение данной проблемы. Думал найти аналог mockito(подсмотрел у коллег занимающихся android-разработкой). В ходе поиска узнал, что swift поддерживает read-only рефлексию (в рантайме, мы можем только узнавать информацию об объектах, менять поведение объекта, мы не можем). Поэтому подобной библиотеки нет. Отчаявшись, я задал вопрос на тостере. Решение подсказали: Вячеслав Бельтюков и Человек с медведем (ManWithBear).
Мы будем генерировать моки при помощи Sourcery. Sourcery использует шаблоны для генерации кода. Имеются несколько стандартных, для наших целей подходит AutoMockable.
Приступим к делу:
1) Добавляем в наш проект pod 'Sourcery'.
2) Настраиваем RunScript для нашего проекта.
$PODS_ROOT/Sourcery/bin/sourcery --sources . --templates ./Pods/Sourcery/Templates/AutoMockable.stencil --output ./SwiftMocking
Где:
"$PODS_ROOT/Sourcery/bin/sourcery" — путь к исполняемому файлу Sourcery.
"--sources ." — Указание, что анализировать для кодогенерации (точка указывает на текущую папку проекта, то есть мы будем смотреть нужно ли сгенерировать моки для каждого файла нашего проекта).
"--templates ./Pods/Sourcery/Templates/AutoMockable.stencil" — путь к шаблону кодогенерации.
"--output ./SwiftMocking" — место, где будет хранится результат кодогенерации (наш проект называется SwiftMocking).
3) Добавлям файл AutoMockable.swift в наш проект:
/// Базовый протокол для протоколов, которые мы хотим мокировать
protocol AutoMockable {}
4) Протоколы, которые мы хотим мокировать, должны наследоваться от AutoMockable. В нашем случае наследуемся AuthenticationService'ом:
protocol AuthenticationService: AutoMockable {
5) Билдим проект. В папке путь к которой мы указали как параметр --ouput, сгенерируется файл AutoMockable.generated.swift, в котором будут лежать сгенерированные моки. Все последующие моки будут складываться в этот файл.
6) Добавляем этот файл в наш проект. Теперь мы можем использовать наши заглушки.
Давайте посмотрим, что сгенерировалось для протокола сервиса аутентификации.
class AuthenticationServiceMock: AuthenticationService {
//MARK: - authenticate
var authenticateCalled = false
var authenticateReceivedArguments: (login: Login, password: Password)?
var authenticateReturnValue: isSucces!
func authenticate(with login: Login, and password: Password) -> isSucces {
authenticateCalled = true
authenticateReceivedArguments = (login: login, password: password)
return authenticateReturnValue
}
//MARK: - asyncAuthenticate
var asyncAuthenticateCalled = false
var asyncAuthenticateReceivedArguments: (login: Login, password: Password, authenticationHandler: (isSucces) -> Void)?
func asyncAuthenticate(with login: Login, and password: Password, and authenticationHandler: @escaping (isSucces) -> Void) {
asyncAuthenticateCalled = true
asyncAuthenticateReceivedArguments = (login: login, password: password, authenticationHandler: authenticationHandler)
}
}
Прекрасно. Теперь мы можем использовать заглушки в наших тестах:
import XCTest
@testable import SwiftMocking
class SwiftMockingTests: XCTestCase {
var viewController: ViewController!
var authenticationService: AuthenticationServiceMock!
override func setUp() {
super.setUp()
authenticationService = AuthenticationServiceMock()
viewController = ViewController()
viewController.authenticationService = authenticationService
viewController.login = "Test login"
viewController.password = "Test password"
}
func testPerformAuthentication() {
// given
authenticationService.authenticateReturnValue = true
// when
viewController.performAuthentication()
// then
XCTAssert(authenticationService.authenticateReceivedArguments?.login == viewController.login, "Логин не был передан в функцию аутентификации")
XCTAssert(authenticationService.authenticateReceivedArguments?.password == viewController.password, "Пароль не был передан в функцию аутентификации")
XCTAssert(authenticationService.authenticateCalled, "Не произошёл вызова функции аутентификации")
}
func testPerformAsyncAuthentication() {
// given
var isAuthenticated = false
viewController.aunthenticationHandler = { isAuthenticated = $0 }
// when
viewController.performAsyncAuthentication()
authenticationService.asyncAuthenticateReceivedArguments?.authenticationHandler(true)
// then
XCTAssert(authenticationService.asyncAuthenticateCalled, "Не произошёл вызов асинхронной функции аутентификации")
XCTAssert(authenticationService.asyncAuthenticateReceivedArguments?.login == viewController.login, "Логин не был передан в асинхронную функцию аутентификации")
XCTAssert(authenticationService.asyncAuthenticateReceivedArguments?.password == viewController.password, "Пароль не был передан в асинхронную функцию аутентификации")
XCTAssert(isAuthenticated, "Контроллер не обрабтывает результат аутентификации")
}
}
Заключение
Sourcery пишет за нас заглушки, экономя тем самым наше время. У этой утилиты имеются и другие применения: генерация Equatable расширений для структур в наших проектах (чтобы мы могли сравнивать объекты этих структур).
Полезные ссылки
Комментарии (8)
maestrro712
03.07.2017 20:02Не думал, что краткая рекомендация вырастет в статью :)
У меня маленькая ремарка: файл с автогенерированным кодом лучше помещать в таргет тестов, чтобы моки не путались под ногами в основном таргете.Agranatmark
03.07.2017 20:03+1Да, сделал больше как конспект для себя. Раз уж сделал, то решил с другими поделиться. :)
s_suhanov
04.07.2017 07:42Я пока использую Cuckoo: https://habrahabr.ru/post/322572/ но мне она не очень нравится. Спасибо за пост, попробую Sourcery.
s_suhanov
04.07.2017 10:39Скажите: а вы не свои классы (например NotificationCenter) пробовали мокать этой либой?
Agranatmark
04.07.2017 16:41Нет, а какие у вас проблемы возникли?
s_suhanov
04.07.2017 16:56Ну вот я с помощью Cuckoo так и не смог этого сделать (пришлось писать свою прослойку для NotificationCenter), а с Sourcery попробую обязательно. Если получится — это будет прекрасно и упростит жизнь. :)
А у вас спросил — может вы уже пробовали и у вас есть уже положительный или отрицательный опыт в этом вопросе. :)
Monnoroch
Спасибо за статью! Что скажете про https://github.com/Brightify/Cuckoo? Оно поддерживает генерацию моков без всяких протоколов.
Agranatmark
Мне не понравилось, что нужно прописывать путь к каждому файлу в run-script. А так, выглядит вполне годно.