Проблема
Переходя в мир 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"!
