Как проверить, правильно ли освобождаются объекты, не внося накладные расходы в производственный код.
Тестирование — одна из моих любимых тем, это не новость. И я люблю исследовать и находить новые способы улучшить то, как мы можем тестировать наши приложения и библиотеки.
Одна из самых сложных вещей для тестирования в 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
— это эффективный (и старый) метод отладки, но вы не можете вручную проверить журнал тестов, особенно если у вас есть сотни тестов в вашем приложении или фреймворке.
Вы хотите писать тесты, которые кричат на вас, когда что-то не так. Как вы можете написать тест, который сообщает вам, что объект не был освобожден?
Идея проста:
- возьмите ссылку
weak
на объект, который вы тестируете. - убедитесь, что ссылка
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) } }
Вот и все. Резюме:
- В классе Test мы определяем
weak var
для хранения ссылки на SUT. - Мы определяем вспомогательный метод, который инициализирует SUT и переменную
weakSUT
. - В методе
tearDown
мы утверждаем, что переменнаяweakSUT
должна быть равна нулю. - В наших методах тестирования мы инициализируем ТУС, используя вспомогательный метод, определенный на шаге 2.
Если мы запустим тест сейчас, метод tearDown будет кричать на нас:
Обратите внимание, что утверждение также сообщает нам, какой метод тестирования дает сбой и какой объект должен быть nil
.
Теперь мы можем исправить наш производственный код и убедиться, что все проходит:
class B { - var delegate: DelegateA? + weak var delegate: DelegateA? deinit { print("B deallocated!") } }
Если мы снова запустим тест, он будет успешно пройден, и операторы печати будут показаны в консоли!
Теперь вы можете уверенно удалять deinit
с операторами печати из кода, сохраняя его чистым и читабельным.
Примечание: этот метод работает, только если вы создаете ТРИ во всех методах тестирования с помощью метода, который также инициализирует слабую переменную (шаг 4 выше).
Вы не можете создать ТРИ в методеsetUpWithError
и присвоить ее переменной-члену: если вы это сделаете, ваш тестовый класс будет содержать ссылку на объект, и он никогда не будет освобожден.
Заключение
Сегодня я поделился другим методом тестирования эталонных циклов. Этот метод имеет основное преимущество, заключающееся в том, что он не загрязняет производственный код дополнительными накладными расходами.
Это также делает тестовый код более изолированным: тестируемая система должна быть инициализирована в каждом тестовом методе, и это гарантирует, что она не перенесет грязные состояния из предыдущих выполнений.
Первую статью об опорных циклах я написал полтора года назад и с тех пор многому научился и постоянно учусь новому. Это важная истина нашей профессии: нам всегда есть чему поучиться, независимо от нашего звания и стажа работы.
Мне нравится этот аспект моей работы, и я надеюсь, что вам он тоже понравится!