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

в 15:53, , рубрики: ios development, iOS разработка, mocking, swift, Unit-тестирование, unit-тесты, мобильные приложения, разработка под iOS, метки:

Предисловие

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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js