Mocking в swift при помощи Sourcery

Предисловие


В ходе разработки 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

Метки:
  • +12
  • 2,6k
  • 8
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 8
  • +1
    Спасибо за статью! Что скажете про https://github.com/Brightify/Cuckoo? Оно поддерживает генерацию моков без всяких протоколов.
    • +1

      Мне не понравилось, что нужно прописывать путь к каждому файлу в run-script. А так, выглядит вполне годно.

    • 0
      Не думал, что краткая рекомендация вырастет в статью :)
      У меня маленькая ремарка: файл с автогенерированным кодом лучше помещать в таргет тестов, чтобы моки не путались под ногами в основном таргете.
      • +1
        Да, сделал больше как конспект для себя. Раз уж сделал, то решил с другими поделиться. :)
      • 0

        Я пока использую Cuckoo: https://habrahabr.ru/post/322572/ но мне она не очень нравится. Спасибо за пост, попробую Sourcery.

        • 0

          Скажите: а вы не свои классы (например NotificationCenter) пробовали мокать этой либой?

          • 0

            Нет, а какие у вас проблемы возникли?

            • 0

              Ну вот я с помощью Cuckoo так и не смог этого сделать (пришлось писать свою прослойку для NotificationCenter), а с Sourcery попробую обязательно. Если получится — это будет прекрасно и упростит жизнь. :)


              А у вас спросил — может вы уже пробовали и у вас есть уже положительный или отрицательный опыт в этом вопросе. :)

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