Интеграционное тестирование для проверки на наличие утечки памяти

Автор оригинала: Matthew Healy
  • Перевод
Мы пишем множество юнит тестов, разрабатывая приложение SoundCloud под iOS. Юнит тесты выглядят вполне великолепно. Они короткие, (надеюсь) читабельны, и они дают нам уверенность в том, что код, который мы пишем, работает так, как и ожидается. Но юнит тесты, как следует из их названия, охватывают только один блок кода, чаще всего функцию или класс. Итак, как отловить ошибки, которые существуют во взаимодействиях между классами — ошибки, такие как утечки памяти?


Утечки памяти/Memory Leaks


Иногда обнаружить ошибку утечки памяти достаточно сложно. Существуют вероятность наличия сильной ссылки на делегат, но есть и такие ошибки, которые намного сложнее обнаружить. Например, очевидно ли, что следующий код может содержать утечку памяти?

final class UseCase {
    weak var delegate: UseCaseDelegate?
    private let service: Service

    init(service: Service) {
        self.service = service
    }

    func run() {
        service.makeRequest(handleResponse)
    }

    private func handleResponse(response: ServiceResponse) {
        // some business logic and then...
        delegate.operationDidComplete()
    }
}


Поскольку Service уже внедряется, нет никаких гарантий относительно его поведения. Передавая в приватную функцию handleResponse, которая захватыает self, мы обеспечиваем Service сильной ссылкой на UseCase. Если Service решит сохранить эту ссылку — и у нас нет гарантий, что так не случится — в таком случае возникает утечка памяти. Но при беглом изучение кода не очевидно, что это действительно может произойти.

Также есть замечательный пост Джона Санделл про использование юнит тестов, для обнаружения утечки памяти для классов. Но с примером, приведенным выше, где очень легко пропустить утечку памяти, не всегда ясно, как необходимо написать такой модульный тест. (Мы, конечно, говорим здесь не с точки зрения опыта.)

Как писал Гильерме в своем недавнем сообщении, новые функции в приложении SoundCloud для iOS написаны в соответствии с «чистыми архитектурными шаблонами» — чаще всего это разновидность VIPER. Большинство этих VIPER модулей построены с использованием того, что мы называем ModuleFactory. Такой ModuleFactory принимает некоторые входные данные, зависимости и конфигурацию — и создает UIViewController, который уже подключен к остальной части модуля и может быть помещен в стек навигации.

В данном VIPER модуле может быть несколько делегатов, наблюдателей и сбегающие замыканий, каждое из которых может привести к тому, что контроллер останется в памяти после его удаления из стека навигации. Когда это произойдет, объем памяти будет расти, и операционная система вполне может решить прекратить работу приложения.

Так возможно ли покрыть столько потенциальных утечек, написав как можно меньше юнит-тестов? В случае, если нет, то всё это было огромной тратой времени.

Интеграционные тесты


Ответ, как можно было предположить из названия этого поста, — да. И мы делаем это с помощью интеграционного тестирования. Цель интеграционного теста — проверить, как объекты взаимодействуют друг с другом. Конечно, VIPER модули представляют собой группы объектов, утечки памяти являются одной из форм взаимодействия, которую мы определенно хотим избежать.

Наш план прост: Мы собираемся использовать наш ModuleFactory для создания экземпляра VIPER модуля. Затем мы удалим ссылку на UIViewController и убедимся, что все важные части модуля уничтожены вместе с ней.

Первая проблема, с которой мы столкнулись, заключается в том, что по своей природе мы не можем легко получить доступ к какой-либо части VIPER модуля, кроме UIViewController. Единственная public функция в нашем ModuleFactory — это func make() -> UIViewController. Но что, если мы добавим еще одну точку входа только для наших тестов? Этот новый метод будет объявлен посредством internal, поэтому мы сможем получить к нему доступ только посредством @testable importing — фреймворка ModuleFactory. Он будет возвращать ссылки на все наиболее важные части м модуля, которые мы могли бы затем удержать для слабых ссылок для входа в нашем тесте. Это в конечном итоге выглядит так:

public final class ModuleFactory {
    // Некоторые свойства и код инициализации, а затем ...
    public func make() -> UIViewController {
        makeAndExpose().view
    }

    typealias ModuleComponents = (
        view: UIViewController,
        presenter: Presenter,
        Interactor: Interactor
    )

    func makeAndExpose() -> ModuleComponents {
      // Set up code, and then...
      return (
          view: viewController,
          presenter: presenter,
          interactor: interactor
      )
    }
}


Это решает проблему отсутствия прямого доступа к данным объекта. Очевидно, что это не идеально, но это соответствует нашим потребностям, поэтому давайте перейдем к написанию теста. Это будет выглядеть следующим образом:

final class ModuleMemoryLeakTests: XCTestCase {
    // Нам нужна сильная ссылка на представление. В противном случае весь
    // модуль будет уничтожен немедленно.
    private var view: UIViewController?
    // Однако мы хотим удерживать слабые ссылки на
    // другие части стека, чтобы имитировать поведение
    // UIKit, представляющего наш UIViewController на экране.
    private weak var presenter: Presenter?
    private weak var interactor: Interactor?

    // В методе setUp мы создаем экземпляр ModuleFactory и
    // вызываем метод makeAndExpose. Нам нужно убедиться в том, что мы
    // не случайно удержали возвращенный ModuleComponents
    // кортеж, так как он содержит сильные ссылки на все части стека.
    // Это явно мешало бы тесту.
    func setUp() {
        super.setUp()
        let moduleFactory = ModuleFactory(/* mocked dependencies & config */)
        let components = moduleFactory.makeAndExpose()
        view = components.view
        presenter = components.presenter
        interactor = components.interactor
    }

    // Если тест пройден, то шаг tearDown фактически не понадобится,
    // но мы обязательно должны быть покрыты на случай, если тест будет не пройден, чтобы остановить
    // сами тесты от утечки памяти.
    func tearDown() {
        view = nil
        presenter = nil
        interactor = nil
        super.tearDown()
    }

    func test_module_doesNotLeakMemory() {
        // Начнем с того, что все ссылки не равны нулю.
        // Это необходимо для диагностики ложных срабатываний, например
        // если мы не смогли правильно назначить переменные на этапе setUp.
        XCTAssertNotNil(presenter)
        XCTAssertNotNil(interactor)

        // Теперь мы удалили нашу сильную ссылку на представление.
        // Если все настроено правильно, это единственная
        // сильная ссылка на весь стек, так что все
        // остальное должно исчезнуть рядом с ней.
        view = nil

        // Наконец, мы проверяем, что слабые ссылки
        // экземпляры Presenter и Interactor теперь равны нулю.
        // Это гарантирует, что эти компоненты и любые их
        // собственные подкомпоненты, не имеют утечек памяти.
        XCTAssertNil(presenter)
        XCTAssertNil(interactor)
    }
}


Итак, у нас есть простой способ выявить утечки памяти в VIPER модуле. Он ни в коем случае не идеален и требует определенной пользовательской работы для каждого нового модуля, который мы хотим протестировать, но это, безусловно, намного меньше работы, чем написание отдельных юнит тестов для каждой возможной утечки памяти. Это также помогает выявить утечки памяти, о которых мы даже не подозреваем. Фактически, после написания нескольких из этих тестов, выявили, что у нас есть тест, который не проходит, и после некоторого исследования мы обнаружили утечку памяти в модуле. После исправления, тест следует повторить.

Это также дает нам отправную точку для написания более общего набора интеграционных тестов для модулей. В конце концов, если мы просто сохраним сильную ссылку на Presenter и заменим UIViewController на mock, то мы можем подделать пользовательский ввод данных, после чего вызвав методы презентатора, и проверив фиктивное отображение данных на View.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

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

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

    0
    Как же я устал от этих танцев с бубном за 6 лет iOS разработки. Похоже, у Apple это хроническое.
      0
      Я до этого на C# танцевал лет 5. ))) я думаю везде танцы с бубном.
        0
        Волею судеб освоил TypeScript. VSCode/TypeScript по сравнению с Xcode/Swift просто космос :)
      +1

      Есть вот такой интересный материал по поводу тестирования на утечки памяти при помощи autoreleasepool

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

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