Как стать автором
Обновить
47.99
Циан
В топ-6 лучших ИТ-компаний рейтинга Хабр.Карьера

Тесты в iOS: хороший, плохой, …

Время на прочтение21 мин
Количество просмотров14K

Привет! Меня зовут Андрей Михайлов, я работаю iOS-разработчиком в Циан и отвечаю за внедрение модульного тестирования в процесс разработки. Сегодня я немного расскажу о том, какими свойствами должны обладать хорошие автоматизированные тесты, чем хорошие тесты отличаются от плохих, и для чего на самом деле стоит их писать (не только чтобы находить баги в коде). Это первая статья в серии, посвященной тестированию, она сфокусирована на теории и будет полезна не только iOS-разработчикам. Статью, посвященную UI-тестированию, можно прочитать тут.

Предыстория

Начну с небольшой предыстории. Изначально в iOS приложении Циан был классический VIPER с отдельными наборами Unit-тестов на Presenter, Interactor и прочую бизнес-логику, уровнем ниже UI. Долгое время это было стандартом тестирования, но написание таких тестов было довольно трудоёмкой задачей. Они требовали создания большого количества моков и дублирования проверок одних и тех же сценариев на разных слоях. Постепенно эти тесты стали разрабатываться от случая к случаю, а качество их стало страдать.

Параллельно с Unit-тестами отдельная команда автоматизаторов разрабатывала Appium UI-тесты. Но с ними тоже хватало проблем. Appium далеко не сразу начинал поддерживать актуальную версию Xcode, а сами Appium тесты писались с задержкой в несколько недель после релиза фич, и когда автоматизаторы добирались до их написания, сталкивались с тем, что элементы на экране требуют проставления дополнительных идентификаторов. В результате разработчику приходилось возвращаться к задаче, что несколько выбивало из ритма работы.

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

Сразу пару слов про различие модульных и Unit-тестов, так как это довольно дискуссионный вопрос. Unit-тестом мы считает тест для одного класса. Желательно, чтобы у класса, который покрывается Unit-тестами, вообще не было никаких изменяемых зависимостей, тогда его будет легко покрыть Unit-тестами. А модульными тестами мы считаем те, которые тестируют связку классов, где количество заменяемых моками сущностей модуля должно быть минимально возможным.

Почему же мы выбрали модульные тесты, а не классические Unit-тесты для VIPER? Во-первых, модульные тесты позволяют протестировать всю систему целиком, включая связи между частями модуля. В результате получим лучшее покрытие с меньшим количеством тестов, а как известно в VIPER много отдельных сущностей и, соответственно, тестов для них. Во-вторых, модульные тесты требуют меньше моков и меньше кода на этапе подготовки теста, а значит и усилий разработки. А в-третьих, такие тесты удобно писать прямо по пользовательским историям, и они выступают в роли документации к коду. Но важно понимать, что модульные тесты не являются полной заменой для UI-тестов, а скорее служат дополнением, позволяющим уменьшить количество проверок в UI-тестах и их общего количества.

Для того чтобы успешно внедрить модульное тестирование, была создана отдельная рабочая группа, которая провела исследование лучших практик модульного тестирования, и в результате этого исследования модульные тесты были успешно внедрены в разработку, а общее время, затрачиваемое на написание тестов, значительно уменьшилось по сравнению с тестированием только UI-тестами и Unit-тестами. Для того чтобы все разработчики в равной степени понимали, как тестировать код и в чём ценность тестов, было написано несколько статей для внутреннего использования, а уже по их мотивам написана эта статья. Итак, каким же должен быть хороший Unit или модульный тест и зачем вообще их писать?

Зачем же нужны тесты?

  • Хорошие тесты обеспечивают стабильный рост программного продукта.

  • Хорошие тесты способствуют повышению качества приложения и качества архитектуры.

  • Хорошие тесты создают safety net и помогают найти баги как на этапе разработки, так и при последующем внесении изменений. Safety net — это образная сеть, которая помогает отлавливать баги, которые оказываются в приложении во время разработки.

  • Хорошие тесты должны выступать в роли документации. При чтении тестов должно становиться понятно, что делает тестируемый код.

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

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

Основные свойства хороших тестов

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

  • Хорошие тесты должны максимально защищать от багов при минимальных затратах на сопровождение.

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

  • Хорошие тесты помогают понимать клиентский код. Если вы читаете тесты, но не можете понять, что делает тестируемый код, то это плохие тесты. Такие тесты сложно поддерживать, а следовательно, в код становится сложно вносить изменения и проводить рефакторинг. С такими тестами рефакторинг может стать даже сложнее, чем без тестов вовсе.

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

  • Тесты должны быть повторяемыми. Они должны давать один и тот же результат при любом количестве запусков.

Антипаттерны

Как правило, эти антипаттерны ведут к деградации кода тестов, а потом и клиентского кода. При их возникновении стоит задуматься о необходимости рефакторинга. 

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

Пример дублирования кода тестов
// Неправильный пример
 
func testFirstButtonTap() {
    // Arrange
    let presenter = ModulePresenter()
    let fakeView = ModuleViewFake()
    presenter.view = fakeView
    presenter.start()
    // Act
    presenter.tapFirstButton()
    //Assert
    let viewModel = fakeView.lastViewModel!
    XCTAssertEqual(viewModel.title, "hello world")
    XCTAssertEqual(viewModel.subtitle, "subtitle text")
    XCTAssertEqual(viewModel.info, "first button tapped")
}
 
func testSecondButtonTap() {
    // Arrange 
    // неправильно, дублируется создание
    let presenter = ModulePresenter()
    let fakeView = ModuleViewFake()
    presenter.view = fakeView
    presenter.start()
    // Act
    presenter.tapSecondButton()
    // Assert 
    // неправильно, дублируются проверки
    let viewModel = fakeView.lastViewModel!
    XCTAssertEqual(viewModel.title, "hello world")
    XCTAssertEqual(viewModel.subtitle, "subtitle text")
    XCTAssertEqual(viewModel.info, "second button tapped")
}

// Доработанный пример

let fakeView: ModuleViewFake!
let presenter: ModulePresenter!
private func setupModule() { // или внутри func setUp()
    presenter = ModulePresenter()
    fakeView = ModuleViewFake()
    presenter.view = fakeView
    presenter.start()
}

private func getExpectedViewModel(withInfo info: String) -> ViewModel {
    return ViewModel(title: "hello world", subtitle: "subtitle text", info: info)
}

func testFirstButtonTap() {
    //Arrange 
    // правильно, нет дублирования создания
    setupModule()
    //Act
    presenter.tapFirstButton()
    //Assert 
    // правильно, нет дублирования проверок
    let expectedViewModel = getExpectedViewModel(withInfo: "first button tapped")
    XCTAssertEqual(viewModel, expectedViewModel)
}

func testSecondButtonTap() {
    // Arrange 
    // правильно, нет дублирования создания
    setupModule()
    // Act
    presenter.tapSecondButton()
    // Assert 
    // правильно, нет дублирования проверок
    let expectedViewModel = getExpectedViewModel(withInfo: "second button tapped")
    XCTAssertEqual(viewModel, expectedViewModel)
}
  • Копирование логики из основного кода проекта для проверки этой же логики в тестах.
    Такие проверки по факту ничего не проверяют, усложняют чтение теста и приводят к хрупким тестам. Хрупкий тест — это тест, который перестаёт работать после изменений внутренней структуры кода, но с сохранением алгоритма и интерфейса. Получаемые от тестируемой системы данные всегда должны сравниваться с конкретными значениями, объявленными в тестах, а не высчитываться с помощью алгоритма, особенно скопированного из клиентского кода. Это относится и к строкам: лучше использовать строки, объявленные в тестах.

Пример копирования логики для проверки значений
final class StringConcatenationHelper {
    let a, b: String
    init(a: String, b: String) {
        self.a = a
        self.b = b
    }
    func concatenated() -> String {
        return "\(a)\(b)"
    }
}
 
// Тест

func testConcatenation() {
    // arrange
    let stringA = "hello"
    let stringB = " world"
    let helper = StringConcatenationHelper(a: stringA, b: stringB)
    // act
    let result = helper.concatenated()
    // assert
    XCTAssertEqual(result, "(stringA)(stringB)") // неправильно
    XCTAssertEqual(result, stringA + stringB) // неправильно
    XCTAssertEqual(result, "hello world") // правильно
    // всегда для проверки результатов надо использовать готовые значения
}
  • Условные операторы и циклы в коде тестов.
    В тестах не должны использоваться условные операторы и циклы. Эти конструкции усложняют чтение тестов. В момент чтения кода тестов с условными операторами непонятно, какая часть теста будет выполнена. Обычно тест с ветвлением логики можно разделить на два разных теста. Циклы тоже должны быть развёрнуты в линейный код для повышения читаемости.

Пример использования условных операторов
struct ViewModel {
    let title: String
    let subtitle: String
}
 
class ViewModelFactory {
    func getViewModels() -> [ViewModel] {
        return [
            .init(title: "hello", subtitle: "world"),
            .init(title: "world", subtitle: "hello")
        ]
    }
}

// Тесты

// неправильно, сложно определить, что проверят тест, с первого взгляда
func testViewModelsSubtitlesWrongWay() {
    let items = ViewModelFactory().getViewModels()
    for item in items {
        if item.title == "hello" {
            XCTAssertEqual(item.subtitle, "world")
        } else {
            XCTAssertEqual(item.subtitle, "hello")
        }
    }
}

// правильно, нет условной логики, тест более понятный
func testViewModelsSubtitlesRightWay() {
    let items = ViewModelFactory().getViewModels()
    let firstItem = items[0]
    let secondItem = items[1]
    XCTAssertEqual(items.count, 2)
    XCTAssertEqual(firstItem.subtitle, "world")
    XCTAssertEqual(secondItem.subtitle, "hello")
}
  • Тестирование приватных методов.
    Хорошие тесты не делают предположений о строении тестируемой системы и работают только с открытым интерфейсом и моками. Тест не должен наблюдать внутреннее состояние и вызывать приватные методы, а должен работать только с конечным результатом и внешним поведением системы. Если после рефакторинга класса и при неизменном интерфейсе тест начал падать, значит, такой тест можно назвать хрупким.

Пример тестирования приватных методов
struct ViewModel {
    let titleText: String
}
 
protocol ModuleViewInput: AnyObject {
    func update(with viewModel: ViewModel)
}
 
final class ModulePresenter {
    weak var view: ModuleViewInput?
 
    func start() {
        let viewModel = ViewModel(titleText: getTitleText())
        view?.update(with: viewModel)
    }
    // функция должна быть private, потому что другие
    // классы из клиентского кода не используют ее напрямую
    /* private */ func getTitleText() -> String {
        return "hello world"
    }
}
 
final class ModuleViewFake: ModuleViewInput {
    private(set) var lastViewModel: ViewModel?
 
    func update(with viewModel: ViewModel) {
        lastViewModel = viewModel
    }
}
 
// Тесты

func testPresenterTitle() {
    // Arrange
    let presenter = ModulePresenter()
    // Act
    let title = presenter.getTitleText() // неправильно, доступ к закрытым функциям
    // Assert
    XCTAssertEqual(title, "hello world")
}
 
func testModuleTitle() {
    // Arrange
    let presenter = ModulePresenter()
    let fakeView = ModuleViewFake()
    presenter.view = fakeView
    // Act
    presenter.start() // правильно, инкапсуляция не нарушается, данные проверяются через мок, внутренне устройство presenter легко изменить
    // Assert
    XCTAssertEqual(fakeView.lastViewModel?.titleText, "hello world")
}
  • Закомментированные или выключенные тесты.
    Тесты всегда должны находиться в рабочем и актуальном состоянии. Если закомментировать или отключить нерабочий тест, то другой разработчик, решивший его включить, может потратить часы на поиски причин поломки теста. Выключенные тесты не актуализируются и очень быстро устаревают. Разработчик, решивший закомментировать или отключить тест, должен подумать, как инвестировать своё время в решение проблемы, провести рефакторинг и привести тесты к рабочему состоянию.

  • Код, сложный для тестирования.
    Как правило, это такой код, который содержит много логики и много зависимостей одновременно. Достаточное тестирование такого кода занимает много времени, а поддержка чрезвычайно сложна. Желательно отрефакторить такой код и разделить его на части, которые содержат сложную логику и не имеют зависимостей, и части, которые имеют много зависимостей и не имеют сложной логики. Тогда на первую часть можно будет написать Unit-тесты, а на вторую – модульные.

    Примером тут можно считать наследников UIViewController при использовании паттерна MVC. У него много зависимостей, и он содержит всю логику. Эта проблема решается переходом на более подходящую архитектуру.

  • Модуль покрыт только одним видом тестов.
    Если модуль покрыт только UI-тестами, то это значит, что эти UI-тесты являются слишком сложными и проверяют то, что можно проверить и модульными тестами. Если есть только модульные тесты, то это значит, что нет никаких проверок работоспособности UI и роутинга, а это тоже плохо. Тесты, которые могут быть перенесены из UI-тестов в модульные, должны быть туда перенесены.

Метрики тестирования

Code coverage довольно часто рассматривается как основная метрика качества тестов. Но у такого подхода есть несколько проблем и лучше не делать процент покрытия кода тестами самоцелью.

Во-первых, code coverage будет считаться, даже если тест просто выполняет код, но ничего не проверяет. Во-вторых, эта метрика никак не учитывает код, скрытый в сторонних и системных библиотеках. В-третьих, существует такой код, который очень тяжело протестировать (hard to test code). Попытка достичь высокого процента coverage будет стоить в таком случае слишком дорого. Будет более целесообразно сначала инвестировать время в рефакторинг кода и извлечение плохо тестируемого кода из модулей, а уже после этого покрыть функционал тестами.

Code-coverage не стоит фанатично использовать как метрику качества тестов, высокие значения coverage ничего не говорят о качестве тестов. С другой стороны, coverage хорошо подходит как негативный индикатор качества тестов. Если он низкий, то тесты точно недостаточны — нужно уделить тестированию больше времени.

Установка обязательного высокого code-coverage может начать подменять цели тестирования. Разработчики должны фокусироваться на качестве тестов критических частей системы, а в итоге будут искать способы достичь высокого процента покрытия всей системы.

К сожалению, не существует идеальной метрики тестирования, но есть пара рекомендаций. Лучше инвестировать своё время в тестирование поведения модулей, чем в тестирование наибольшего количества строк кода. Также следует уделять больше времени качественному тестированию сложной и важной для проекта логики, чем покрытию тестами очевидного и линейного кода.

Какие бывают тесты и их назначение

Тесты в мобильных приложениях делятся на три типа: Unit-тесты, модульные тесты и UI-тесты. У каждого типа тестов есть своё ограниченное применение.

Unit-тесты

Unit-тесты — это тесты для одного класса. Такие тесты используют для тщательной проверки сложной логики и алгоритмов, инкапсулированных в одном классе. Желательно, чтобы у таких классов не было изменяемых зависимостей.

Если нужно протестировать класс, который содержит в себе и сложную логику, и множество зависимостей, то такой класс должен быть отрефакторен, сложная логика выделена в отдельный класс, и уже на новый класс должны быть написаны Unit-тесты.

Unit-тесты желательно писать на системные модули (техническая аналитика, диплинки и другие модули, скрытые от конечных пользователей). Соответственно, стоит проектировать эти модули с учётом написания Unit-тестов и сокращать их зависимости до минимума.

Если в модуле отсутствует сложная логика, то достаточно написать только модульные тесты.

Сложная логика — общее понятие, описывающее код, который содержит большое количество изменяемого стейта и/или большое количество ветвлений. Внесение изменений в такой код обычно сопряжено с риском его сломать.

Модульные тесты

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

Как правило, модульный тест для VIPER модуля пишется на связку классов, находящихся в иерархии модуля выше слоя view — например, на связку presenter-interactor-service, а view заменяется на мок ввиду сложности его тестирования. В других архитектурах будет схожая история: модульные тесты будет удобно писать, если из программного модуля будет исключён view-слой, а слой, содержащий логику, будет вызываться напрямую так же, как их вызывает view.

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

Для iOS-разработки это обычно связано с жизненным циклом view controler. Это значит, что если в коде приложения во view controler в методе viewDidLoad() вызывается метод start() у presenter, чтобы перевести presenter в активное состояние, то в тестах тоже должен быть вызван метод start() у presenter перед любыми другими действиями — например, нажатием кнопок на экране или запроса на дозагрузку данных. Такой подход позволять писать более надёжные тесты, которые максимально приближены к реальному использованию кода.

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

UI-тесты

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

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

Ещё иногда бывает удобно покрыть UI-тестом кейсы, которые тяжело воспроизвести при ручном тестировании. Например, при необходимости особенного ответа от сервера, который тяжело получить в ручном режиме. Также UI-тесты не требуют серьёзных доработок клиентского кода и могут быть написаны на устаревший код, реализация которого не позволяет использовать модульные тесты.

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

Как правило, количество таких тестов должно быть меньше, чем модульных тестов, потому что для общего упрощения тестов бо́льшую часть проверок модуля можно перенести из UI-тестов в модульные без потери качества тестирования.

Пирамида тестирования

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

Таким образом, мы подходим к понятию «пирамида тестирования». В классическом виде пирамида тестирования — это действительно пирамида, в основании которой лежат Unit-тесты, в середине — модульные, а в вершине — UI- и end-to-end-тесты. Смысл в том, что тесты, которые лежат в пирамиде ближе к основанию, легки в написании и поддержке, но не дают достаточной степени защиты. А тесты, лежащие в пирамиде ближе к вершине, дают отличную защиту от багов, но сложны в написании и дают медленную обратную связь.

Но в мобильных приложениях, где у модулей много интеграций и зависимостей, но не очень много сложной логики, пирамида тестирования смещается в сторону ромба, в вершине которого UI-тесты, в середине — модульные тесты, а внизу Unit-тесты. Это значит, что модульных тестов должно быть больше, чем других видов тестов.

Такая форма получается из-за небольшого количества сложной логики в приложении. Кроме того, модульные и UI-тесты частично берут на себя обязанности по проверке такой логики, поэтому необходимость в большом количестве Unit-тестов отпадает, и эта часть пирамиды «переходит» к модульным тестам.

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

Такой подход вдохновлен докладом, посвященным The Testing Trophy.

О том, что можно заменять на моки

Unit-тесты

Желательно, чтобы у кода, который тестируют Unit-тесты, не было изменяемых зависимостей (исключение — зависимости от Date(), но об этом ниже). Если организовать класс так, что в нём не будет изменяемых зависимостей, то это сильно упростит тестирование данного класса. Это связано с тем, что возвращаемые и проверяемые значения будут зависеть только от входных параметров. Такой подход позволяет протестировать логику максимально полно и не упустить никаких неожиданных сайд-эффектов.

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

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

* Неизменяемые зависимости — это зависимости, которые всегда возвращают одинаковые значения или никак не влияют на тестируемый код.

Пример кода, который удобно покрыть Unit-тестами
final class MyDataMapper {
    let user: User // неизменяемая зависимость

    init(user: User) {
        self.user = user
    }

    func getViewModel(from items: [Item]) -> ViewModel {
        // тут много сложной и важной логики преобразований items во ViewModel
    }
}
// Это класс — кандидат для тестирования Unit-тестами.
// У него нет изменяемых зависимостей, он содержит сложную логику,
// а результат зависит только от входных данных.
// Моками ничего заменять не надо.

Модульные тесты

Чем меньше моков в тестируемой системе, тем ближе она к реальному коду, и тем надёжнее будут тесты. Ведь если у класса заменить все зависимости моками, то с помощью тестов мы сможем только удостовериться в корректности работы нашего класса с моками, а не с реальными зависимостями. А ещё в таком случае модульный тест станет неудачной реализацией Unit-теста.

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

Например, UserDefaults можно не мокировать, а использовать реальный, передавая его как зависимость в конструктор тестируемого класса. В продакшн-коде использовать UserDefaults.shared, а в тестах создавать UserDefaults через UserDefaults(suiteName: #file) и очищать его от старых данных. То же относится и к встроенным базам данных, но они могут требовать более сложной дополнительной подготовки теста.

В модульных тестах обычно заменяется на моки view-слой, слой роутинга (если у него есть зависимость от UIViewController) и network client. Смысл в том, чтобы замокировать зависимости от UIKit и прочих, которые сложно тестировать. 

В итоге модульные тесты обычно покрывают связку presenter-interactor-service (из архитектуры VIPER) и все их внутренние классы-хелперы.

Пример кода, который удобно покрыть модульными тестами
final class MyService {
    private let networkClient: NetworkClient

    init(networkClient: NetworkClient) {
        self.networkClient = networkClient
    }
 
    func loadItems(onResult: (Result<[Item], Error>) -> Void) {
        networkClient.execute(method: .GET(Paths.loadItemsURL)) { result in
            // Парсим данные и вызываем callback
        }
    }
}
 
protocol MyViewInput: AnyObject {
    func update(withViewModel viewModel: ViewModel)
    func updateWithErrorState()
    func updateWithLoadingState()
}

protocol MyViewOutput {
    func start()
    func didTapInfoButton()
}

protocol MyRouter {
    func openInfoScreen(with user: User)
}

// Упрощенная вариация VIPER без interactor
final class MyPresenter: MyViewOutput {
    private let user: User
    private let mapper: MyDataMapper // из примера Unit-тестов
    private weak var viewInput: MyViewInput?
    private let service: MyService
    private let router: MyRouter

    init(user: User, viewInput: MyViewInput, service: MyService, router: MyRouter) {
        self.user = user
        self.mapper = MyDataMapper(user: user)
        self.viewInput = viewInput
        self.service = service
        self.router = router
    }

    // MARK: - MyViewOutput
    // вызывается в UIViewController.viewDidLoad
    func start() {
        viewInput?.updateWithLoadingState()
        loadData()
    }

    func didTapInfoButton() {
        router.openInfoScreen(with: user)
    }

    // MARK: - private
    private func loadData() {
        service.loadItems { [weak self] result in
            switch result {
            case let .success(items):
                self?.didLoadItems(items)
            case .failure:
                self?.didFailLoadItems()
            }
        }
    }

    private func didLoadItems(_ items: [Item]) {
        let viewModel = mapper.getViewModel(from: items)
        viewInput?.update(withViewModel: viewModel)
    }

    private func didFailLoadItems() {
        viewInput?.updateWithErrorState()
    }
}
// А этот набор классов — хороший кандидат на тестирование модульными тестами.
// В нем нет сложной логики (вся сложная логика инкапсулирована в MyDataMapper),
// но зато есть несколько зависимостей (Router, Service). Моками в этом случае нужно заменить MyViewInput и
// MyRouter у MyPresenter, а у MyService надо заменить моком NetworkClient

UI-тесты

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

В самом же коде UI-тестов подменяются ответы от сервера (если это не end-to-end UI-тест), выставляются нужные значения UserDefaults, значения фича тоглов и AB-тестов.

Про работу с Date

Для получения текущей даты или для формирования какого-либо её строкового представления в коде часто используется вызов Date(). Такой код плохо поддаётся тестированию, т.к. результат работы таких функций будет изменяться с течением времени. Грубо говоря, функция получения текущего времени является неявной системной зависимостью, а для улучшения тестируемости эта зависимость должна стать явной.

Решение — передавать в тестируемую систему фабрику, которая будет возвращать текущее время. Для клиентского кода одна фабрика будет возвращать текущее настоящее время, а во время тестирования другая фабрика, созданная специально для тестов, будет возвращать заданное в ней время, всегда одинаковое. При этом сам код теста не должен использовать значения, возвращаемые этой фабрикой. Такой подход позволит протестировать работу с Date так же, как и любой другой код.

Пример реализации DateProvider
protocol DateProvider {
    func getCurrentDate() -> Date
}
  
// для продакшн кода
final class DateProviderImpl: TimeProvider {
    func getCurrentDate() -> Date {
        Date()
    }
}
  
// для тестов
final class DateProviderFake: TimeProvider {
    func getCurrentDate() -> Date {
        Date(timeIntervalSince1970: 1609462861)
    }
}

Анатомия хорошего теста

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

Чтобы класс или связку классов можно было протестировать, они не должны содержать в себе hard-to-test (нетестируемых или сложных для тестирования) зависимостей внутри себя. 

Например, CLLocationManager является hard-to-test зависимостью, потому что его результаты зависят от положения устройства в пространстве. Зависимости такого вида должны быть инкапсулированы в отдельных классах, закрыты протоколом и передаваться в модули явно, чтобы в тестах такие зависимости можно было заменить на заглушки.

Другим примером hard-to-test-code является UI-слой приложения, в рамках модульных тестов он не тестируется. Вся логика должна быть вынесена из UI-слоя в презентер, чтобы её можно было протестировать.

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

Arrange-Act-Assert

Для структурирования кода теста стоит использовать паттерн Arrange-Act-Assert. Если все тесты будут написаны в одном стиле с использованием этого паттерна, то разработчикам будет легче ориентироваться в коде тестов и поддерживать их.

Сначала в верхней части функции теста идёт секция подготовки условий для теста, она называется Arrange. В ней создаётся тестируемый модуль, подменяются запросы к серверу, модуль приводится к нужному состоянию и т. д. Эта секция может занимать несколько строчек, а повторяющиеся между тестами строчки подготовки могут быть вынесены в функцию setUp() или в отдельные функции-хелперы.

Дальше идёт секция Act, в ней выполняется действие, результат которого мы хотим протестировать. Как правило, эта часть занимает одну строчку. Если для выполнения какого-то одного действия требуется вызов нескольких функций, то, возможно, произошла ошибка в проектировании клиентского кода.

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

Для большего повышения читаемости блоки Arrange-Act-Assert можно обозначить комментариями или разделить пустыми строками, чтобы после одного взгляда на тест становилось понятно, где какая проверка.

class SimpleTest: XCTestCase {
 
    // Мок, которым заменяется view в тестах
    // Нужен для проверки передаваемых в него значений
    class MockView: RealViewProtocol {
        private(set) var lastUpdatedViewModel: RealViewModel?
        // реализация функции из RealViewProtocol
        func setViewModel(_ viewModel: RealViewModel) {
            lastUpdatedViewModel = viewModel
        }
    }

    // Упрощенный программный модуль с мок-view, используемый только для тестов
    struct SimpleModule {
        // Presenter, используемый в production коде и его зависимости
        let presenter: RealPresenter
        // мок view
        let fakeView: MockView
    }

    var module: SimpleModule!

    // Выполняется перед каждым тестом
    override func setUp() {
        // Сюда мы выносим подготовку модуля, т.к. она будет повторяться для каждого теста
        // Внутри фабрики создается модуль, приближенный к реальному,
        // но используется MockView вместо настоящей View
        module = FakeFactory().makeModule()
    }

    func testButtonTap() throws {
        // Arrange. Здесь мы переводим систему в нужное нам состояние
        module.presenter.start()
        // Act. Выполняем действие, которое должен проверить тест
        module.presenter.didTapActionButton()
        // Assert. Проверяем результат нашего действия
        XCTAssertEqual(module.fakeView.lastUpdatedViewModel?.infoLabel, "Button was tapped!")
    }
}

Вместо заключения

На этом пока всё. При разработке кода и при проектировании архитектуры задавайте себе вопрос: «Насколько просто будет тестировать такой код?», «Нет ли в нём hard-to-test зависимостей?», «Удобно ли будет пользоваться интерфейсом модуля при его тестировании?» К сожалению, если модуль удобно тестировать, то это не всегда значит, что у него хорошая архитектура, но если модуль протестировать нельзя, то это однозначно говорит о низком качестве проектирования.

При работе над самими тестами стоит задавать себе вопросы: «Продолжит ли тест правильно работать после рефакторинга тестируемого модуля?», «Будет ли этот тест понятен другим разработчикам и смогут ли они его доработать при необходимости?», «Можно ли понять, каким функционалом обладает модуль, читая эти тесты?» Такие вопросы помогут вам разрабатывать пригодные для тестирования модули и покрывать их лёгкими в поддержке тестами.

Довольно сложно покрыть все аспекты качественного тестирования кода в одной небольшой статье. Но если вас зацепила эта тема и захотелось поглубже в неё погрузиться, то советую прочитать книгу «Принципы юнит-тестирования», автор Хориков Владимир. Бо́льшая часть этой статьи написана под вдохновением от прочтения этой книги, так что однозначно рекомендую к прочтению. 

Продолжение этой серии, посвящённое практике тестирования, будет в следующей статье, в ней мы подробно рассмотрим, как пользоваться фреймворком для тестирования Quick, как лучше организовать тесты, как их упростить и как повысить их качество. А статью о UI-тестировании в iOS уже можно прочитать тут.
Stay tuned!

Теги:
Хабы:
Всего голосов 13: ↑13 и ↓0+13
Комментарии14

Публикации

Информация

Сайт
www.cian.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия