Как проверить, правильно ли освобождаются объекты, не внося накладные расходы в производственный код.

Тестирование — одна из моих любимых тем, это не новость. И я люблю исследовать и находить новые способы улучшить то, как мы можем тестировать наши приложения и библиотеки.

Одна из самых сложных вещей для тестирования в iOS — эталонные циклы. Я уже писал о тестировании эталонных циклов полтора года назад, но это решение не совсем устраивало: вынуждало загрязнять продакшн-код замыканиями и наблюдателями.

Недавно Майкл Лонг реанимировал этот пост с другими предложениями о том, как добиться аналогичного результата. Я очень благодарен за эти предложения, так как люблю обсуждать эти темы с сообществом iOS, но они оба страдали от одной и той же проблемы: они требуют добавления служебного кода для тестирования. Да, вы можете скомпилировать их, используя прагмы прекомпилятора, такие как #if DEBUG, но они все равно сделают код менее читаемым.

В прошлом году, также благодаря Essential Developer, я научился другому, отточенному способу тестирования эталонных циклов, и сегодня я хочу поделиться им со всеми.

Что такое эталонный цикл?

Цикл ссылок возникает, когда два объекта, A и B, содержат ссылки друг на друга:

class A {
  var b: B?
}

class B {
  var a: A?
}

// app
let a = A()
let b = B()
a.b = b
b.a = a

Это проблема, потому что, когда объект A должен быть освобожден для освобождения памяти, он не может этого сделать, пока не будет освобожден объект B. Но B не может быть освобожден, пока A не будет освобожден. Следовательно, один объект препятствует освобождению другого и наоборот.

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

Важно найти способ обнаружения эталонных циклов и решить их как можно скорее. Тестирование — самый быстрый способ сделать это.

Пример из реального мира

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

С шаблоном делегата у нас обычно есть объект A, который реализует протокол DelegateA, создает и сохраняет ссылку на объект B, устанавливая себя в качестве делегата.

UIKit часто использует этот шаблон, и мы используем шаблоны делегатов с ViewControllers. Обычно, когда родительский ViewController A представляет дочерний ViewController B, он устанавливает себя в качестве своего делегата.

import UIKit

protocol ADelegate {}

class B: UIViewController {
  // Wrong implementation. This creates a reference cycle!
  var delegate: ADelegate?

  // Right implementation. The weak var breaks the cycle
  // weak var delegate: ADelegate?
}

class A: UIViewController, ADelegate {
  func presentB() {
    let b = UIViewControllerB()
    b.delegate = self
    self.present(b, animated: false)
  }
}

После вызова функции presentB A содержит ссылку на B (хранящуюся в классе UIViewController), а B содержит ссылку на A в форме протокола ADelegate.

Для тестов воспользуемся упрощенным примером:

protocol ADelegate: AnyObject {}

class B: UIViewController {
  var delegate: ADelegate?
}

class A: UIViewController, ADelegate {
  var b: B?
  func presentB() {
    let b = UIViewControllerB()
    b.delegate = self
    self.b = b
  }
}

Это имитирует шаблон делегата без вызова функций UIViewController.present. Это требует дополнительной работы для правильного тестирования, и это может отвлечь от основной концепции.

Тест

Во-первых, давайте напишем тест, чтобы убедиться, что наш код строится и работает правильно, несмотря на утечку памяти. Мы хотим проверить, что при вызове presentB создается экземпляр B и у него есть правильный набор делегатов.

Тест выглядит следующим образом (обратите внимание, что я буду использовать SUT для ссылки на тестируемую систему — класс, который мы хотим протестировать):

import XCTest
@testable import RefCycleApp

final class RefCycleAppTests: XCTestCase {
    func testExample() throws {
      let sut = A()
      sut.presentB()
      let b = sut.b
      XCTAssertIdentical(b?.delegate, sut) // => compares references
    }
}

Если мы запустим этот тест, он пройдет. Вопрос в том, правильно ли освобождены объекты?

Вы можете добавить пару операторов print в deinit, чтобы проверить, что они вызываются:

class B {
  var delegate: DelegateA?
  
+  deinit {
+    print("B deallocated!")
+  }
}

class A: DelegateA {
  var b: B?
  
+  deinit {
+    print("A deallocated!")
+  }

  func presentB() {
    let b = B()
    b.delegate = self
    self.b = b
  }
}

Перезапустите тесты и наблюдайте, что они все еще проходят, но в консоли нет сообщений: объект не уничтожен. Вы можете перепроверить это, установив некоторые точки останова в deinit, запустив тесты и наблюдая, что выполнение не останавливается в точках останова.

Автоматическое определение эталонного цикла

Добавление операторов print — это эффективный (и старый) метод отладки, но вы не можете вручную проверить журнал тестов, особенно если у вас есть сотни тестов в вашем приложении или фреймворке.

Вы хотите писать тесты, которые кричат ​​на вас, когда что-то не так. Как вы можете написать тест, который сообщает вам, что объект не был освобожден?

Идея проста:

  1. возьмите ссылку weak на объект, который вы тестируете.
  2. убедитесь, что ссылка nil после выполнения тестов (в методе демонтажа).

Вот и все. weak ссылки автоматически устанавливаются в nil, когда объект, на который они указывают, освобождается, и они не считаются ссылками, когда речь идет о подсчете ссылок для управления памятью.

Давайте обновим наши тесты этой идеей:

final class RefCycleAppTests: XCTestCase {
  // 1. Define a weakSUT variable to track the SUT
  weak var weakSUT: A?

  // 2. Define an helper method that creates the SUT and sets the weak reference
  func prepareSUT() -> A {
    let a = A()
    self.weakSUT = a
    return a
  }

  // 3. Write the tearDown method to assert that the weakSUT is nil
  override func tearDownWithError() throws {
    XCTAssertNil(self.weakSUT)
  }

  // 4. Update the test to use the helper method defined at 2
  func testExample() throws {
    let sut = prepareSUT()
    sut.presentB()
    let b = sut.b
    XCTAssertNotNil(b?.delegate)
  }
}

Вот и все. Резюме:

  1. В классе Test мы определяем weak var для хранения ссылки на SUT.
  2. Мы определяем вспомогательный метод, который инициализирует SUT и переменную weakSUT.
  3. В методе tearDown мы утверждаем, что переменная weakSUT должна быть равна нулю.
  4. В наших методах тестирования мы инициализируем ТУС, используя вспомогательный метод, определенный на шаге 2.

Если мы запустим тест сейчас, метод tearDown будет кричать на нас:

Обратите внимание, что утверждение также сообщает нам, какой метод тестирования дает сбой и какой объект должен быть nil.

Теперь мы можем исправить наш производственный код и убедиться, что все проходит:

class B {
-  var delegate: DelegateA?
+  weak var delegate: DelegateA?
  
  deinit {
    print("B deallocated!")
  }
}

Если мы снова запустим тест, он будет успешно пройден, и операторы печати будут показаны в консоли!

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

Примечание: этот метод работает, только если вы создаете ТРИ во всех методах тестирования с помощью метода, который также инициализирует слабую переменную (шаг 4 выше).
Вы не можете создать ТРИ в методе setUpWithError и присвоить ее переменной-члену: если вы это сделаете, ваш тестовый класс будет содержать ссылку на объект, и он никогда не будет освобожден.

Заключение

Сегодня я поделился другим методом тестирования эталонных циклов. Этот метод имеет основное преимущество, заключающееся в том, что он не загрязняет производственный код дополнительными накладными расходами.

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

Первую статью об опорных циклах я написал полтора года назад и с тех пор многому научился и постоянно учусь новому. Это важная истина нашей профессии: нам всегда есть чему поучиться, независимо от нашего звания и стажа работы.

Мне нравится этот аспект моей работы, и я надеюсь, что вам он тоже понравится!