Юнит тестирование вашего Swift приложение

Перевод статьи от автора Riccardo Cipolleschi

Привет всем, Я Риккардо. Инженер Сеньор iOS в Bending Spoons, я дышу iOS разработкой, приложениями и инструментами, и я люблю делиться знаниями с другими.

В прошлые пару дней, я написал множество юнит тестов для большой библиотеки, которую мы пишем в Bending Spoons. Даже если это может звучать не слишком восхищающее, я люблю это. Написание тестов имеет некоторые плюсы, которые часто являются недооцененные:

  1. Это очень полезно проверять что код который вы написали делает именно то что вы ожидаете, без построения и запуска приложения.
  2. Это защищает вас от поломки другого кода когда вы исправляете ошибки или добавляете особенности: запуск всех тестов после изменений будет предотвращать вопросы регрессии на другой стороне вашего кода.
  3. Это помогает вам в создании небольших пулреквестов: если вы не привыкли писать тесты, есть тенденция писать всю функция, прежде чем сливать ее, вместо того, чтобы разбивать ее на более мелкие части. Более крупные пулреквесты действительно трудно должным образом проанализировать и это нехорошо. Добавление модульных тестов позволяет вам точно определить границы ваших компонентов и результирующий пулреквест будет меньше и его будет легче читать и просматривать. Дополнительное преимущество: пулреквест кода и тестовый пулреквест могут быть двумя разными, меньшими пулреквестами.
  4. Это хорошо помогает структурировать код. Код должен следовать некоторым принципам структурирования для более простого тестирования.

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

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

Внедрение зависимостей

Внедрение зависимостей это самая первая техника которую мы должны принять для написания хорошо трестируемого кода. Наша логика часто нуждается в компонентах которые приходят к нам из других частей кода: внешние библиотека, OS фреймворк, другие компоненты…

Я думаю мы иногда пишем некоторый код как следующий, по крайней мере один:

class MyViewController: UIViewController {
   var myPath: String
   var content: [MyModel]
  
   // ...
   
  func loadContent() {
    let fileManager = FileManager.default
    
    guard
      let data = fileManager.contents(atPath: myPath),
      self.content = try? JSONDecoder().decode([MyModel].self, from data)
    else {
      self.content = []
      return
    }
  }
}

Этот кусок кода вызывает некоторые вопросы. Во первых, там напрямую используется паттерн одиночка из кода. Во вторых, он имеет две обязанности: чтение из файла и создание модели. Третье, он зависит от параметров класса(переменная myPath).

Сейчас, давайте представим что вы хотите тестировать метод loadContent(). Как вы это сделаете? Наверняка вы создадите MyViewController и передадите в него путь в myPath. Но как вы будете разобрать с FileManager? У вас абсолютно нет контроля над ним: код берет менеджера файловой системы и  когда тестирование будет выполнятся реализация ОС выполнит код, пытаясь на самом деле получить доступ в диску.

Помните: цель тестирования это тестирование вашего кода, а не того что приходит из ОС.

Это когда срабатывает внедрение зависимостей: мы можем легко изменить ваш передав в метод loadContent() экземпляр FileManager. Спасибо этим изменениям, мы может передать наш кастомный экземпляр FileManager в метод который мы тестируем.

class MyViewController: UIViewController {
   var myPath: String
   var content: [MyModel]
  
   // ...
   
  func loadContent(
     fileManager: FileManager,
     path: String,
     jsonDecoder: JSONDecoder = JSONDecoder()
  ) throws {
    let data = fileManager.contents(atPath: path)
    self.content = try jsonDecoder.decode([MyModel].self, from data)
  }
}

Эту идею можно доработать, так же передавать путь в локальном файлу и JSONDecoder который мы используем. Таким образом мы не передаем эти значения во Вью Контроллер, но мы можем передать параметры напрямую в метод. А так же, мы сделаем метод который может выкинуть ошибку: это может быть полезным для тестирования не верных входных значений, на пример.

Круто.. Но.. мы по прежнему передаем FileManager и что мы можем сделать если не полагаться на экземпляр FileManager.default? Решение это создать подкласс FileManager: таким образом вы можете перезаписать методы которые необходимы Вью Контроллеру и их реализовать. Этот код выглядит так:

import XCTest
import Foundation

/// File manager stub to override the methods we need
class FileManagerStub: FileManager {
  override func getContent(atPath: Stirng) -> Data? {
    return Data()
  }
}

/// Class under test
class MyViewControllerTest: XCTestCase {
  
  func testLoadContent_withEmptyFile() {
    let vc = MyViewController()
    let fileManagerStub = FileManagerStub()
    let decoder: JSONDecoder = JSONDecoder()
    vc.loadContent(fileManager: fileManagerStub, path: "emptyFile", jsonDecoder: decoder)
    XCTAssertTrue(vc.content.isEmpty)
  }
}

Путем перезаписи оригинального класса, вы можете реализовать очень простую логику для методов которая необходима системе, без беспокойства об актуальной реализации: для наших нужд, метод getContent(…) это черная коробка что выдает String которая возвращает некоторую Data которая может быть конвертирована в MyModel. Нет необходимости иметь доступ к диску.

Этот пример работает. Тем не менее что происходит в случае есть класс final и не может расширить его. Или что происходит в этом случае где класс требует некоторые объекты которые мы не не можем инициализировать ( StoreKit… я смотрю на тебя)?

Используйте протоколы

Решение для данных случаев и лучший общий подход это использовать протоколы. 

Проколоты это лучший путь для раздвоения интерфейс объекта из текущей реализации. В упомянутом выше случае, мы можем определить наш протокол AppFileManager с прототипами методов которые нам нужны. Затем, только передайте параметры типа протокола вместо FileManager в метод loadContent(…)

// Protocol definition for the FileManager
protocol AppFileManager {
  func contents(atPath: String)
}

// Protocol conformance of the system FileManager
extension FileManager: AppFileManager {}

// ViewController
class MyViewController: UIViewController {
   var myPath: String
   var content: [MyModel]
  
   // ...
   
  func loadContent(
    fileManager: AppFileManager,
    path: String,
    jsonDecoder: JSONDecoder = JSONDecoder()
  ) throws {
      let data = fileManager.contents(atPath: path),
      self.content = try jsonDecoder.decode([MyModel].self, from data)
    }
  }
}

Важная строка кода выше это соответствие Protocol системного FileManager (строка 7). Эта строка позволяет нам использовать экземпляр FileManager.default в приложении.

Код теста не очень отличается от предыдущего: просто замените наследование от FIleManager к AppFileManager. Тем не менее, этот подход более мощный потому что он позволяет вам так же ввести final классы (которые не могут быть расширены) или это позволит вам завернуть типы которые не имеют public инициализатора. 

import XCTest
import Foundation

// File manager stub to override the methods we need
class FileManagerStub: AppFileManager {
  func getContent(atPath: Stirng) -> Data? {
    return Data()
  }
}

// Class under test
class MyViewControllerTest: XCTestCase {
  
  func testLoadContent_withEmptyFile() {
    let vc = MyViewController()
    let fileManagerStub = FileManagerStub()
    let decoder: JSONDecoder = JSONDecoder()
    vc.loadContent(fileManager: fileManagerStub, path: "emptyFile", jsonDecoder: decoder)
    XCTAssertTrue(vc.content.isEmpty)
  }
}

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

Заключение

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

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