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

—

от автора

Проблема

Переходя в мир 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)

ссылка на оригинал статьи https://habr.com/ru/post/500420/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *