4 May

sMock — Swift mocking framework для Unit-tests (спасибо gMock за идеи)

Development for iOSSwiftDevelopment for MacOS
Sandbox

Проблема


Переходя в мир Swift из ObjC/C++, я столкнулся с проблемой при написании юнит-тестов: отсутствием инструментов для создания Mock-объектов.


При написании декомпозированного кода мы часто скрываем детали реализации за интерфейсами (протоколами). А также проверять функциональность того или иного объекта отдельно от других очень удобно, подменяя его составняе части моками.


Погуглив, я нашел несколько фреймворков Swift Mocking на github. Но ни один из них не явился мне ясным и очевидным в использовании (по одной или нескольким причинам):


  • имеет тучу зависимостей
  • не имеет интуитивного синтаксиса
  • требуется кодогенерация / написание вручную большого количества вспомогательного кода
  • не интегрирован с XCTest (позволяет мокать, но не тестировать)
  • иметь много возможностей, сваленых в кучу одного фреймворка (неразбериха что и зачем)
  • должен использоваться с ограниченным количеством предопределенных/встроенных типов

Эта ситуация была для меня неприятной, и напротяжении около года я использовал обходные пути и самописные моки.
Самописные Mock-объекты просты, но они


  • каждый раз разные (изобретаем велосипед)
  • или каждый раз одинаковые (копипаст)
  • неочевидные в использовании

Решение


В мире C ++ существует популярный и чудесный фреймворк gTest / gMock (от Google).
Он позволяет создавать Mock-объекты очень наглядно и компактно. Также он имеет интуитивно понятный синтаксис, который позволяет просто «читать» (не изучать) написанный тестовый код.


Вдохновленный gMock, я решил написать фреймворк sMock (Swift Mock) с похожим подходом и синтаксисом gMock.
https://github.com/Alkenso/sMock


sMock:


  • написание mock на протоколы/базовые классы и коллбеки
  • интуитивно понятный синтаксис установки expectations
  • минимально-требуемый код для создания mock-объектов
  • интегрирован с XCTest.framework
  • работает с любыми типами
  • расширяемый
  • поддерживает синхронные и асинхронные тесты
  • zero-dependency
  • pure Swift

Пример(ы)


Для тестирования с sMock нужно


  • создать Mock класс, который имплементит методы протокола (переопределяем методы родителя)
  • в Mock классе объявить вспомогательные проперти methodCall. "замокать" методы.

Mocking synchronous method


Начнём с наиболее простого случая: синхронный код


import XCTest
import sMock

//  Протокол для примера, который будем мокать
protocol HTTPClient {
    func sendRequestSync(_ request: String) -> String
}

//  Mock реализация
class MockHTTPClient: HTTPClient {
    //  Определяем call's mock entity.
    let sendRequestSyncCall = MockMethod<String, String>()

    func sendRequestSync(_ request: String) -> String {
        //  1. Пробрасываем агрументы в Mock-сущность
        //  2. Для non-void результата предоставляем "default" результат для случая 'Unexpected call' (т.е. когда мы вызова не ожидаем, но он произошёл).
        sendRequestSyncCall.call(request) ?? ""
    }
}

//  Объект, который мы хотим тестировать
struct Client {
    let httpClient: HTTPClient

    //  Клиент, используя HTTP транспорт, получает и парсит данные.
    func retrieveRecordsSync() -> [String] {
        let response = httpClient.sendRequestSync("{ action: 'retrieve_records' }")
        return response.split(separator: ";").map(String.init)
    }
}

class ExampleTests: XCTestCase {
    func test_Example() {
        let mock = MockHTTPClient()
        let client = Client(httpClient: mock)

        //  Далее мы пишем expectation, при котором метод 'sendRequestSync' у HTTPClient будет вызван с агрументом 'request' равным "{ action: 'retrieve_records' }".
        //  Мы ожидаем, что метод будет вызван только 1 раз и вернёт строку "r1;r2;r3".
        mock.sendRequestSyncCall
            //  Именуем наш expectation (полезно для чтения сообщений о ошибке теста);
            .expect("Request sent.")
            //  Говорим, что данное expectation подходит только для случая, когда аргумент соответствуем таковому в match
            .match("{ action: 'retrieve_records' }")
            //  Задаём количество раз, сколько мы ожидаем что будет вызван метод (для конкретно данного match)
            .willOnce(
                //  Указываем, что должен вернуть метод.
                .return("r1;r2;r3"))

        //  Установив expectations, вызываем метод Client-а и проверяем распаршенный результат.
        let records = client.retrieveRecordsSync()
        XCTAssertEqual(records, ["r1", "r2", "r3"])
    }
}

Mocking synchronous method + mocking asynchonous callback


Для второго примера хотел бы рассмотреть дополнительно кейс с написанием Mock callback'a, которым мы тестируем возвращаемый асинхронно результат.


//  Протокол для примера, который будем мокать
protocol HTTPClient {
    func sendRequestSync(_ request: String) -> String
}

//  Mock реализация
class MockHTTPClient: HTTPClient {
    //  Определяем call's mock entity.
    let sendRequestSyncCall = MockMethod<String, String>()

    func sendRequestSync(_ request: String) -> String {
        //  1. Пробрасываем агрументы в Mock-сущность
        //  2. Для non-void результата предоставляем "default" результат для случая 'Unexpected call' (т.е. когда мы вызова не ожидаем, но он произошёл).
        sendRequestSyncCall.call(request) ?? ""
    }
}

//  Объект, который мы хотим тестировать
struct Client {
    let httpClient: HTTPClient

    //  Клиент, используя HTTP транспорт, получает и парсит данные. Результат возвращает асинхронно
    func retrieveRecordsAsync(completion: @escaping ([String]) -> Void) {
        let response = httpClient.sendRequestSync("{ action: 'retrieve_records' }")
        completion(response.split(separator: ";").map(String.init))
    }
}

class ExampleTests: XCTestCase {
    func test_Example() {
        let mock = MockHTTPClient()
        let client = Client(httpClient: mock)

        //  Далее мы пишем expectation, при котором метод 'sendRequestSync' у HTTPClient будет вызван с агрументом 'request' равным "{ action: 'retrieve_records' }".
        //  Мы ожидаем, что метод будет вызван только 1 раз и вернёт строку "r1;r2;r3".
        mock.sendRequestSyncCall
            //  Именуем наш expectation (полезно для чтения сообщений о ошибке теста);
            .expect("Request sent.")
            //  Говорим, что данное expectation подходит только для случая, когда аргумент соответствуем таковому в match
            .match("{ action: 'retrieve_records' }")
            //  Задаём количество раз, сколько мы ожидаем что будет вызван метод (для конкретно данного match)
            .willOnce(
                //  Указываем, что должен вернуть метод.
                .return("r1;r2;r3"))

        //  Теперь создаём expectation для коллбека клиента (асинхронный ответ). 
        //  Мы должны быть уверенны, что по завершению теста колбек-таки был вызван: используем 'MockClosure' mock entity.
        //  Здесь мы установим expectation, что коллбек будет вызван строго 1 раз с результатом (распаршенным клиентом) ["r1", "r2", "r3"].
        let completionCall = MockClosure<[String], Void>()
        completionCall
            //  Именуем наш expectation (полезно для чтения сообщений о ошибке теста);
            .expect("Records retrieved.")
            //  Говорим, что данное expectation подходит только для случая, когда аргумент соответствуем таковому в match
            .match(["r1", "r2", "r3"])
            //  Задаём количество раз, сколько мы ожидаем что будет вызван метод (для конкретно данного match).
            //  Поскольку return value у коллбека Void, .return можем не писать.
            .willOnce()

        //  Установив expectations, вызываем метод Client-а.
        client.retrieveRecordsAsync(completion: completionCall.asClosure())

        //  Дожидаемся завершения всех установленных expectations (если вызовы "под капотом" выполняются на другом потоке).
        sMock.waitForExpectations()
    }
}

Matchers, Actions, Argument capture


sMock также предоставляем (см. доку на гитхаб):


  • объемное семейство Matchers — вспомогательных классов, которые можно использовать в .match: от .any (любой агрумент) до анализа контента коллекций
  • кастомные Actions: expectation может при match'e не только .return, но и полностью кастомизированный блок.
  • захват агрументов: при вызове expectation захватывать агрументы в спец. объект
  • кастомизацию конфигурации: таймаут ожидания, кастомное поведение при unexpectedCall

Итог


Наличие приятных в использовании инструментов тестирования позволяют разработчикам писать тесты с большим желанием.
Чистый код тестов делает их читабельными и поддерживаемыми.
Широко известно: человек будет использовать те инструменты, которые ему нравятся. В противном случае он будет всячески избегать их использования.


Я был приятно удивлён тем, что у Swift широкое и социальное сообщество.
Буду рад всем, кому sMock придётся по душе. Обязательно сообщайте об ошибках и предложениях.
Сообща можно создать действительно стоящий инструмент "testing with Swift"!


Try sMock on github (with SwiftPackageManager)

Tags:swiftunit-testingios developmentmacos разработка
Hubs: Development for iOS Swift Development for MacOS
+9
1.1k 12
Comments 4
Popular right now
iOS разработчик (swift)
from 120,000 ₽Onlinetours.ruRemote job
iOS-разработчик (Obj-C/Swift)
to 190,000 ₽4Taps MobileТольяттиRemote job
IOS developer
from 140,000 to 250,000 ₽МТСМоскваRemote job
Senior iOS Developer
from 3,000 to 3,500 €Pure AppRemote job
IOS-разработчик
from 190,000 to 300,000 ₽ENJOY PROСанкт-ПетербургRemote job
Top of the last 24 hours