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