Поиск утечек памяти с помощью автоматизированных тестов

Процесс управления памятью может стать фактором, весьма затрудняющим работу в приложениях iOS. В связи с этим нужно уделять пристальное внимание размещенным в памяти object instances и обеспечивать их корректное высвобождение.  

Как известно, экземпляры высвобождаются из памяти посредством механизма автоматического подсчета ссылок (Automatic Reference Counting или ARC). В iOS существуют 2 типа таких ссылок: сильные и слабые. 

Сильные ссылки  —  это тип ссылки по умолчанию. Рассмотрим простой пример объявления объекта. Допустим, у нас есть класс Person, и мы объявляем объект следующим образом: 

var person = Person()

Здесь мы создаем сильную ссылку на Person. При каждом ее создании число ссылок на объект увеличивается на 1. Что это значит? И зачем нам это нужно? Дело в том, что ARC не будет удалять объект из памяти до тех пор, пока число ссылок на него не приравняется к 0. 

Иначе себя ведут слабые ссылки, которые не увеличивают общее число ссылок на объект. Вашему вниманию  —  очередной пример: 

var person = Person()
weak var anotherPerson: Person? = person

Здесь создается слабая ссылка на тот же самый объект person, так что теперь у нас есть сильная ссылка person и слабая  —  anotherPerson. Это значит, что при наличии 2 ссылок на один и тот же объект общее их число по-прежнему равняется 1, поскольку одна из них  —  слабая. Также важно знать, что слабые ссылки являются необязательными, поскольку доступ к ним можно получить даже после их удаления из памяти.

Поиск циклов retain и утечек памяти с помощью тестов

Цикл retain возникает, когда 2 объекта имеют сильную ссылку друг на друга. В результате этого они удерживают друг друга в памяти, поскольку число ссылок на них никогда не будет равно 0. Следовательно, наша задача  —  нарушить этот цикл, позволив ARC освободить область памяти, занятую такими объектами. 

Предположим, у нас есть два объекта. Один из них имеет тип HTTPClient и при этом состоит во втором объекте UserProfileLoader

class UserProfileLoader {   
   
   private let client: HTTPClient
   private let url: URL   
   init(url: URL, client: HTTPClient) {
      self.url = url
      self.client = client
   }   
   enum Error: Swift.Error {
      case invalidProfileData
      case connectivity
   }   
   enum Result: Equatable {
      case success(UserProfile)
      case failure(Error)
   }   
   func loadProfile(completion: @escaping (Result) -> Void) {
      client.get(from: url) { data in
         let result = self.map(data: data)
         completion(result)
      }
   }   
   func map(data: HTTPClientResult) -> Result {
      return .success(UserProfile(id: "an-user-id"))
   }
}
enum HTTPClientResult {
   case success(Data)
   case failure(Error)
}
protocol HTTPClient {
   func get(from url: URL, 
            completion: @escaping (HTTPClientResult) -> Void)
}
class HTTPClientSpy: HTTPClient {
   var completion: ((HTTPClientResult) -> Void)? = nil
   func get(from url: URL, 
            completion: @escaping (HTTPClientResult) -> Void) {
       
       self.completion = completion
   }
}
struct UserProfile: Equatable {
   var id: String
}

В данном примере UserProfileLoader содержит метод loadProfile, вызывающий метод get в созданном client типа HTTPClient. Как видно, реализация get в HTTPClientSpy сохраняет замыкание в свойстве completion, что делает его доступным для тестов. 

Представленная выше реализация, несомненно, содержит цикл retain, но вот для какой цели и в каком месте так сразу и не скажешь. Попробуем разобраться. UserProfileLoader содержит сильную ссылку на объект HTTPClient в свойстве client. С другой стороны, HTTPClient также имеет сильную ссылку на UserProfileLoader. “Где же она?” — спросите вы, поскольку может показаться, что никакого UserProfileLoader в HTTPClientSpy не существует. Однако он там точно есть. Обратим внимание на метод get. В свойстве completion он хранит полученное замыкание, которое содержит сильную ссылку на объект UserProfileLoader в вызове метода map. Как видно, мы добавили сильную ссылку с помощью явного использования self.

Как правило, ошибки подобного рода встречаются во многих базах кода. Порой они совсем не очевидны, так что никто от них не застрахован. Но мы можем сократить риск их появления с помощью автоматизированных тестов. Как же? Для этого необходимо убедиться, что тестируемая система (System Under Test или SUT) и ее составляющие высвобождаются должным образом. 

Начнем с написания первого теста, который задаст нам правильный вектор. В этом примере экземпляр UserProfileLoader будет нашим SUT. 

func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {

   let url = URL(string: "https://a-url.com")!
   let client = HTTPClientSpy()
   let sut = UserProfileLoader(url: url, client: client)

}

Мы располагаем исходными данными, и теперь нам необходимо где-то сохранить результат загрузки для создания утверждений, поскольку, как вы помните, метод loadProfile завершится замыканием типа (Result) -> Void. Сохраним этот результат в тесте. 

func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {

   let url = URL(string: "https://a-url.com")!
   let client = HTTPClientSpy()
   let sut = UserProfileLoader(url: url, client: client)

   var capturedResults = [UserProfileLoader.Result]()
   sut.loadProfile { capturedResults.append($0) }

}

В вышеуказанном тесте замыкание загрузки никогда не будет выполнено. Причина в том, что loadProfile вызывает реализацию get. В экземпляре HTTPClientSpy метод get сохраняет полученное замыкание в свойстве. Для получения результата нужно вызвать в тесте этот сохраненный блок completion

func test_load_withSuccesfulClientResponseDeliversSuccesLoadingResult() {

   let url = URL(string: "https://a-url.com")!
   let client = HTTPClientSpy()
   let sut = UserProfileLoader(url: url, client: client)

   var capturedResults = [UserProfileLoader.Result]()
   sut.loadProfile { capturedResults.append($0) }

   client.completion?(.success(Data()))

}

Вот теперь мы можем сделать утверждения с помощью массива capturedResults. Метод loadProfile будет всегда завершаться с успешным результатом, содержащим экземпляр UserProfile. Именно этого нам и нужно ожидать в тесте. 

class MemoryLeaksTests: XCTestCase {  
 
   func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {   
   
      let url = URL(string: "https://a-url.com")!
      let client = HTTPClientSpy()
      let sut = UserProfileLoader(url: url, client: client)   
   
      let expectedResult: UserProfileLoader.Result =
          .success(UserProfile(id: "an-user-id"))      

      var capturedResults = [UserProfileLoader.Result]()
      sut.loadProfile { capturedResults.append($0) }
      client.completion?(.success(Data()))         

      XCTAssertEqual(capturedResults, [expectedResult])   

   }

}

Тест прошел! Гарантирует ли он корректное высвобождение SUT и его составляющих компонентов? Пока нет. Добавим еще утверждений для большей уверенности в высвобождении теста из памяти. 

Класс MemoryLeaksTests расширяется из класса XCTestCase, предоставляющего метод addTearDownBlock. Этот метод получает блок типа () -> Void, который будет выполняться каждый раз по завершении тестовой функции. Мы могли бы добавить блок teardown сразу после создания SUT и его составляющих. 

class MemoryLeaksTests: XCTestCase {

   func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {

      let url = URL(string: "https://a-url.com")!
      let client = HTTPClientSpy()
      let sut = UserProfileLoader(url: url, client: client) 

      addTeardownBlock { [weak sut, weak client] in
         XCTAssertNil(client, "Instance has not been deallocated")
         XCTAssertNil(sut, "Instance has not been deallocated")
      }

      let expectedResult: UserProfileLoader.Result =
          .success(UserProfile(id: "an-user-id"))

      var capturedResults = [UserProfileLoader.Result]()
      sut.loadProfile { capturedResults.append($0) }
      client.completion?(.success(Data()))    

      XCTAssertEqual(capturedResults, [expectedResult])   

   }

}

Теперь блоки на своих местах. Обратите внимание, что в них мы добавили слабые ссылки на экземпляры sut и client, чтобы обойтись без дальнейшего увеличения их числа. Теперь зеленый тест уже не зеленый, потому что утверждения блоков teardown не срабатывают. 

Для устранения цикла retain стоит воздержаться от явного использования self в методе loadProfile. И у нас есть несколько вариантов: 

  1. Применить в замыкании слабую ссылку и добавить блок guard let для развертывания результата. 
  2. Превратить метод map в статический.
  3. Переместить map в статический метод класса-преобразователя.  

В следующем примере мы воспользовались 2 вариантом, чтобы нарушить цикл retain и успешно пройти тест.  

func loadProfile(completion: @escaping (Result) -> Void) {
   client.get(from: url) { data in
      completion(UserProfileLoader.map(data: data))
   }
}

static func map(data: HTTPClientResult) -> Result {
   return .success(UserProfile(id: "an-user-id"))
}

Теперь тест проходит, но при этом выглядит немного неряшливо, поэтому стоит провести рефакторинг и немного его почистить. Допустим, нам понадобились дополнительные тестовые функции. Тогда мы бы создали SUT и его компоненты, а также добавили бы для каждого из них блоки tear down, что свидетельствовало бы о повторении. 

Для рефакторинга этого теста создадим несколько фабричных методов. Первым станет makeSUT, который вернет SUT и его компоненты. 

class MemoryLeaksTests: XCTestCase {

   func test_load_withSuccesfulCallDeliversSuccesLoadingResult() {      

      let (sut, client) = 
          makeSUT(url: URL(string: "https://a-url.com")!)
      let expectedResult: UserProfileLoader.Result =
          .success(UserProfile(id: "an-user-id"))   

      var capturedResults = [UserProfileLoader.Result]()
      sut.loadProfile { capturedResults.append($0) }
      client.completion?(.success(Data()))     

      XCTAssertEqual(capturedResults, [expectedResult])
   }   

   // MARK: - Helpers   

   func makeSUT(url: URL,
                file: StaticString = #filePath,
                line: UInt = #line) -> (sut: UserProfileLoader, client: HTTPClientSpy) {

      let client = HTTPClientSpy()
      let sut = UserProfileLoader(url: url, client: client)
      addTeardownBlock { [weak sut, weak client] in
         XCTAssertNil(client, 
                      "Instance has not been deallocated", 
                      file: file, 
                      line: line)
         XCTAssertNil(sut, 
                      "Instance has not been deallocated", 
                      file: file, 
                      line: line)
      }

      return (sut, client)  

   }

}

Вот теперь тест стал более чистым и читаемым. К тому же у нас появилось еще одно дополнительное и большое преимущество. Присмотревшись к фабричному методу makeSUT, вы увидите, что он добавляет блоки tear down. Это значит, что по завершении каждый тест, который создает SUT, используя этот метод, будет автоматически проверять корректное высвобождение данного SUT и его объектов из памяти. Если SUT и его компоненты не будут удалены из памяти, то тест покажет ошибку при вызове makeSUT, поскольку мы передаем в XCTAssertNil параметры file и line.

Данный подход позволит уверенно проводить рефакторинг и послужит гарантией правильного управления памятью. 

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Luis Piura: Find potential memory leaks with automated tests

Предыдущая статьяВведение в Pulumi
Следующая статьяКак на самом деле работает Git