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