Unit тестирование в архитектуре Clean Swift

  • Tutorial

Привет, читатель!


Не секрет, что тестирование является неотъемлемой частью любой разработки. В предыдущих статьях мы разобрали основную структуру архитектуры Clean Swift и теперь пора научиться покрывать ее Unit тестами. За основу мы возьмем проект из статьи о Worker’ах и разберем основные моменты.



Теория


Благодаря инжектированию зависимостей и протоколо-ориентированности, все компоненты сцены в Clean Swift независимы друг от друга и могут тестироваться отдельно. Как пример, Interactor зависим от Presenter’а и Worker’а, но эти зависимости опциональны и основаны на протоколах. Таким образом, Interactor может выполнять свою работу (хоть и неполноценную) без Presenter’a и Worker’a, а так же мы можем подменить их на другие объекты, подписанные под их протоколы.


Так как мы хотим тестировать каждый компонент отдельно, нам нужно заменить зависимости на псевдо-компоненты. В этом нам помогут шпионы (Spy). Spy — это объекты тестирования, реализующие протоколы, которые мы хотим инжектировать, и отслеживающие вызовы методов в них. Другими словами, мы создаем Spy для Presenter’a и Worker’a, а затем инжектируем их в Interactor для отслеживания вызова методов.



Справедливости ради, дополню, что так же есть объекты тестирования (Test Doubles) Dummy, Fake, Stub и Mock. Но в рамках этой статьи, мы не будем их затрагивать. Подробнее можно почитать здесь — TestDoubles


Практика


Закончили со словами, переходим к делу. Для экономии вашего времени, мы будем рассматривать абстрактный код, не вдаваясь в подробности реализации каждого метода. Детально с кодом приложения можно ознакомиться здесь: CleanSwiftTests


Для каждого компонента сцены мы создаем файл с тестами и тестовые двойники для зависимостей компонента (Test Doubles).


Пример такой структуры:



Структура каждого файла с тестами (для компонентов) выглядит одинаково и придерживается, примерно, такой последовательности написания:


  • Объявляем переменную с SUT (объект, который будем тестировать) и переменные с его основными зависимостями
  • Производим инициализацию SUT и его зависимостей в setUp(), а затем очищаем их в tearDown()
  • Методы тестирования

Как мы разбирали в теории, каждый компонент сцены может тестироваться отдельно. Мы можем инжектировать в его зависимости тестовые дубли (Spy) и тем самым отслеживать работу методов нашего SUT. Рассмотрим детально процесс написания тестов на примере Interactor’a сцены Home.


HomeInteractor зависим от двух объектов — Presenter и Worker. Обе переменные, в классе, имеют тип протоколов. Это значит, что мы можем создать тестовые дубли, подписанные под протоколы HomePresentationLogic и HomeWorkingLogic, а затем инжектировать их в HomeInteractor.


final class HomeInteractor: HomeBusinessLogic, HomeDataStore {

  // MARK: - Public Properties

  var presenter: HomePresentationLogic?
  lazy var worker: HomeWorkingLogic = HomeWorker()

  var users: [User] = []
  var selectedUser: User?

  // MARK: - HomeBusinessLogic

  func fetchUsers(_ request: HomeModels.FetchUsers.Request) {
    // ...
  }

  func selectUser(_ request: HomeModels.SelectUser.Request) {
    // ...
}

Тестировать мы будем два метода:


  • fetchUsers(:). Отвечает за получение списка пользователей по API. Запрос к API отправляется с помощью Worker’a.
  • selectUser(:). Отвечает за выбор активного пользователя (selectedUser) из списка загруженных пользователей (users).

Для начала написания тестов Interactor’a, мы должны создать шпионов, которые будут отслеживать вызов методов в HomePresentationLogic и HomeWorkingLogic. Для этого создаем класс HomePresentationLogicSpy в директории 'CleanSwiftTestsTests/Stores/Home/TestDoubles/Spies', подписываем под протокол HomePresentationLogic и реализуем метод этого протокола.


final class HomePresentationLogicSpy: HomePresentationLogic {

  // MARK: - Public Properties

  private(set) var isCalledPresentFetchedUsers = false

  // MARK: - Public Methods

  func presentFetchedUsers(_ response: HomeModels.FetchUsers.Response) {
    isCalledPresentFetchedUsers = true
  }
}

Здесь все предельно прозрачно. Если метод presentFetchedUsers (протокола HomePresentationLogic) был вызван, мы устанавливаем значение переменной isCalledPresentFetchedUsers на true. Тем самым, мы можем отследить был ли вызван данный метод в ходе тестирования Interactor’a.


По такому же принципу создаем HomeWorkingLogicSpy. Одно отличие, мы вызываем completion, т.к. часть кода в Interactor’е будет обернута в замыкание этого метода. Методы HomeWorkingLogic занимаются сетевыми запросами. Нам необходимо избежать реальных запросов в сеть во время тестирования. Для этого мы и заменяем его тестовым дублем, который отслеживает вызов методов и возвращает шаблонные данные, но при этом не совершает запросов в сеть.


final class HomeWorkingLogicSpy: HomeWorkingLogic {

  // MARK: - Public Properties

  private(set) var isCalledFetchUsers = false

  let users: [User] = [
    User(id: 1, name: "Ivan", username: "ivan91"),
    User(id: 2, name: "Igor", username: "igor_test")
  ]

  // MARK: - Public Methods

  func fetchUsers(_ completion: @escaping ([User]?) -> Void) {
    isCalledFetchUsers = true
    completion(users)
  }
}

Далее создаем класс HomeInteractorTests, которым будем тестировать HomeInteractor.


final class HomeInteractorTests: XCTestCase {

  // MARK: - Private Properties

  private var sut: HomeInteractor!
  private var worker: HomeWorkingLogicSpy!
  private var presenter: HomePresentationLogicSpy!

  // MARK: - Lifecycle

  override func setUp() {
    super.setUp()

    let homeInteractor = HomeInteractor()
    let homeWorker = HomeWorkingLogicSpy()
    let homePresenter = HomePresentationLogicSpy()

    homeInteractor.worker = homeWorker
    homeInteractor.presenter = homePresenter

    sut = homeInteractor
    worker = homeWorker
    presenter = homePresenter
  }

  override func tearDown() {
    sut = nil
    worker = nil
    presenter = nil

    super.tearDown()
  }

  // MARK: - Public Methods

  func testFetchUsers() {
    // ...
  }

  func testSelectUser() {
    // ...
  }
}

Мы указываем три основные переменные — sut, worker и presenter.


В setUp() инициализируем необходимые объекты, инжектируем зависимости в Interactor и присваиваем объекты в переменные класса.
В tearDown() мы очищаем переменные классы для чистоты эксперимента.


Метод setUp() вызывается перед стартом метода тестирования, например testFetchUsers(), а tearDown(), когда работа этого метода была завершена. Тем самым, мы пересоздаем объект тестирования (sut) перед каждым запуском тестового метода.


Далее идут сами методы тестирования. Структура делится на 3 основных логических блока — создание необходимых объектов, запуск трестируемого метода в SUT и проверка результатов. В примере ниже, мы создаем request (в нашем случае он без параметров), запускаем метод fetchUsers(:) Interactor’a, а затем проверяем, были ли вызваны необходимые методы в HomeWorkingLogicSpy и HomePresentationLogicSpy. Так же проверяем, сохранил ли Interactor тестовые данные, полученные от Worker’a, в свой DataStore.


func testFetchUsers() {
    let request = HomeModels.FetchUsers.Request()

    sut.fetchUsers(request)

    XCTAssertTrue(worker.isCalledFetchUsers, "Not started worker.fetchUsers(:)")
    XCTAssertTrue(presenter.isCalledPresentFetchedUsers, "Not started presenter.presentFetchedUsers(:)")
    XCTAssertEqual(sut.users.count, worker.users.count)
}

Выбор пользователя будем тестировать по похожей структуре. Мы объявляем переменные expectationId и expectationName, по которым мы будем сравнивать результат выбора пользователя. Переменная users хранит тестовый список пользователей, который мы присвоим в Interactor. Т.к. методы тестирования вызываются независимо друг от друга, а в tearDown() мы обнуляем данные, то список пользователей Interactor’a пуст и нам нужно его чем-то заполнить. А далее мы проверяем, был ли присвоен пользователь в DataStore Interactor’a, после вызова sut.selectUser(:), и нужный ли это пользователь.


func testSelectUser() {
    let expectationId = 2
    let expectationName = "Vasya"
    let users = [
      User(id: 1, name: "Ivan", username: "ivan"),
      User(id: 2, name: "Vasya", username: "vasya91"),
      User(id: 3, name: "Maria", username: "maria_love")
    ]
    let request = HomeModels.SelectUser.Request(index: 1)

    sut.users = users
    sut.selectUser(request)

    XCTAssertNotNil(sut.selectedUser, "User not selected")
    XCTAssertEqual(sut.selectedUser?.id, expectationId)
    XCTAssertEqual(sut.selectedUser?.name, expectationName)
}

Тестирование Presenter’a и ViewController’a происходит по такому же принципу, с минимальными отличиями. Одно из отличий в том, что для тестирования ViewController’a потребуется создать UIWindow и получить контроллер со Storyboard’a в setUp(), а так же делать Spy объекты на таблицы и коллекции. Но эти нюансы варьируются от потребностей.


Для полноты картины, рекомендую ознакомиться с проектом по ссылке в конце статьи.


Заключение


Мы разобрали основные принципы тестирования приложений на архитектуре Clean Swift. Она не имеем принципиально сильных отличий от тестирования проектов на других архитектурах, все такие же тестовые дубли, инжектирование и протоколы. Главное не забывать, что у каждого VIP цикла должна быть одна (и только одна!) ответственность. Это сделает код чище, а тесты — очевидней.


Ссылка на проект: CleanSwiftTests
Помощь в написании статьи: Bastien


Серия статей


  1. Общее представление об архитектуре Clean Swift
  2. Router и Data Passing в архитектуре Clean Swift
  3. Workers в архитектуре Clean Swift
  4. Unit тестирование в архитектуре Clean Swift (вы здесь)
  5. Пример простого интернет-магазина на архитектуре Clean Swift
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 7

    0
    Привет, привет!

    Несколько философский вопрос: а нам точно нужны такие подробности как был ли вызван имя_метода? Не совсем понятно, что нам это гарантирует. То что он вызвался не значит что все правильно?

    А еще мне кажется что в данном случаем мы тестирует «техническую» реализацию составляющую, а не «логическую». Что я имею ввиду, мы проверяем не «правильность» работы а «путь» выполнения работы. На простом примере, мы должны валидировать имейл. Вместо того чтоб, проверять конечный результат валидный/невалидный, мы проверяем как мы это делаем(регялярки или еще что-то).
      0
      Несколько философский вопрос: а нам точно нужны такие подробности как был ли вызван имя_метода? Не совсем понятно, что нам это гарантирует. То что он вызвался не значит что все правильно?


      В случае с Interactor'ом, мы проверяем, было ли обращение к Presenter'у и Worker'у.
      — Вызывается Presenter, значит нет запутанной (тупиковой) логики в методе Interactor'a и данные уходят дальше.
      — В случае с Worker'ом, мы подставляем тестовый дубль (Spy) для избежания реальных запросов в сеть при тестировании. Так же возвращаем шаблонные данные (вместо реальных из сети), для удобства тестирования работы логики Interactor'a. Так же, без подмены Worker'a, не будет вызываться замыкание, а значит мы не обработаем данные и не передадим их в Presenter. Но это уже от кейсов зависит.

      А еще мне кажется что в данном случаем мы тестирует «техническую» реализацию составляющую, а не «логическую». Что я имею ввиду, мы проверяем не «правильность» работы а «путь» выполнения работы. На простом примере, мы должны валидировать имейл. Вместо того чтоб, проверять конечный результат валидный/невалидный, мы проверяем как мы это делаем(регялярки или еще что-то).


      Да, конечно, мы должны проверять результат. Основная задача статьи — показать как это делается с компонентами в Clean Swift, как заменять объекты тестовыми дублями, для чего и к чему это приводит. В полном примере приложения (на GitHub), есть тестирование Presenter'a, где проверяется правильность сортировки и т.д.
        0
        Благодарю за ответ! Я более детально ознакомился с проектом и у меня возникли ряд вопросов. Буду рад дискусии.

        Вопрос. Проект CleanSwiftTests
        В компоненте «Home» есть «HomeWorker» — фактически это класс отвечающий за запрос на сервер. Мне нравиться что под один endpoint есть отдельный класс. Но мне не понравилось, что этот класс является частью компонента «Home». А если он мне понадобится в другом компоненте? Я тоже буду использовать его? Но тогда это будет зависимостью между двумя модулями/компонентами.
          0
          Класс HomeWorker содержит в себе методы только для сцены Home, не обязательно запросы в сеть. На «реальных проектах», запросы в сеть строятся в отдельном Network слое, а Worker'ы используются для «выноса» сложной/объемной реализации из Interactor'a для его разгрузки.

          А если он мне понадобится в другом компоненте? Я тоже буду использовать его? Но тогда это будет зависимостью между двумя модулями/компонентами.


          Нет, такой зависимости не должно быть. Для этого делаются глобальные Worker'ы и размещаются в директории Workers для переиспользования в разных сценах (в проекте CleanSwiftTests такой файл есть).

          Как пример:
          Вы имеете слой Network, который содержит в себе модели, ендпоинты и все необходимое, чтоб отправлять запросы и парсить ответы в модели. Далее, уже на Presentation слое (где у нас Clean Swift), мы через глобальные Worker'ы (если требуется использование на разных сценах) или локальные (если это только для одной сцены) выстраивает запрос (напрямую, если отсутствует отдельный слой Business Logic), обрабатываем его и возвращаем в Interactor через замыкание (или иный способ). Но это в том случае, если запрос нужно объемным способом сформировать или объемным способом обработать ответ. В противном случае, можно обращаться напрямую из Interactor'a (к Network или Business слою).
            0
            Понятно. Я во многом согласен с Вашим взглядом на эту проблему. Но считаю очень важным подчеркнуть то что Вы сказали, что Clean Swift (в нашем контексте) — это архитектура на уровне отображения, а не на уровне всего приложения. Считаю что это очень важно и это стоит упомянуть в первой главе, так как на данный момент создается впечатление. что мы все приложение должны делать в таких модулях(компонентах) VIP.

            Я все время вижу как все пытаются делать приложения отталкиваясь от отображения(дизайна) а не юзкейсов. Этим страдают многие «архитектуры» VIPER, MVVM и тд. Это все архитектуры реализации UI а не приложения вцелом.
              0
              Так и есть. Мое упущение, что не сделал должного акцента на том, что Clean Swift архитектура презентационного слоя. Впереди еще одна статья (на самом деле две), где сделаю должный акцент на этом моменте. Спасибо.
      +1
      Спасибо за статью!
      Хочу дополнить, что вместо свойств типа isCalledFetchUsers можно использовать enum Action, который дублирует название функций или их смысл. И в каждой функции добавлять значение в общий массив — actions. Так мы сможем проверять еще порядок вызова функций и количество вызовов.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое