Предисловие
В ходе разработки 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 расширений для структур в наших проектах (чтобы мы могли сравнивать объекты этих структур).
Полезные ссылки
→ Проект
→ Sourcery на github
→ Документация sourcery
Автор: Agranatmark