Что такое модульные тесты и зачем их писать?

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

До того как начать писать модульные тесты, нам надо разобраться, чем они отличаются от Unit-тестов. Это довольно дискуссионный вопрос, и он уже поднимался в первой статье, но я думаю, тут стоит немного повториться. Unit-тестом мы считаем тест на один изолированный класс, где все его зависимости закрыты моками. А модульным мы считаем тест, который тестирует связку классов (модуль) с минимальным возможным количеством моков. Иногда такие тесты еще могут называться интеграционными или функциональными.

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

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

При выборе инструментов мы остановились на фреймворках Quick и Nimble, и они неплохо себя зарекомендовали. С их помощью удобно валидировать поведение системы целиком, они позволяют избегать дублирования кода и делать тесты компактными и выразительными.

Что такое Quick, что такое Nimble?

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

override func spec() {
    describe("Проверяем SumCalculator") { // 1
        var calculator: SumCalculator!
        beforeEach { // 2
            calculator = SumCalculator()
        }
        context("Если сложить 1 и 2") { // 3
            beforeEach { // 4
                calculator.sum(1, 2)
            }
            it("Результат равен 3") { // 5
                expect(calculator.result).to(equal(3)) // 6
            }
        }
        context("Если сложить 1 и -1") { // 7
            beforeEach { // 8
                calculator.sum(1, -1) // 9
            }
            it("Результат равен 0") { // 10
                expect(calculator.result) == 0 // 11
            }
        }
    }
}

Выражения expect(calculator.result).to(equal(3)) (3) и expect(calculator.result) == 0 (11) относятся к фреймворку Nimble, поговорим о них чуть дальше.

А функции describe() (1), beforeEach() (2, 4, 8), context() (3, 7) и it() (5, 10) относятся к фреймворку Quick. Они помогают организовывать код тестов и являются базой этого фреймворка. Далее они будут называться блоками.

Подробнее о блоках

  1. describe() — блок, вложенный в метод spec() и описывающий назначение всех вложенных тест-кейсов. Блоков describe() внутри функции spec() может быть несколько, особенно если нужно разбить один большой describe() для улучшения читаемости кода тестов.

  2. context() —  такой блок, как правило, описывает какое-либо действие из пользовательской истории. Блоки context() вкладываются в блоки describe() или другие блоки context(). Описание этих блоков удобно писать в виде условия, например: “Если данные загрузились успешно” или “Если ввели неверные данные”.

  3. beforeEach() — такой блок должен находиться внутри describe() или context(). Код внутри beforeEach() переводит тестируемую систему в новое состояние. Желательно, чтобы все действия над системой проводились только внутри этих блоков.

  4. it() — в этих блоках выполняется проверка того, что совершенные в beforeEach() действия привели к ожидаемым результатам.  В описание блока обычно вписывают результат действия, например: “View обновляется с правильным State” или “Во View была показана ошибка”.

Порядок выполнения блоков кода:

Понимание порядка вызова блоков является ключом к пониманию работы Quick. Важно понимать, что блоки it() не вызываются последовательно друг за другом. Для каждого блока it() формируется последовательность вызовов, согласно вложенности блоков describe(), context() и it(). В этом примере последовательность вызова блоков будет такая: [2, 4, 5] и [2, 8, 10], т.е. по одной последовательности для каждого it() (5, 10). Но никак не [2, 4, 5, 8, 10].

Если бы мы захотели преобразовать этот набор тестов на Quick в классические XCTest, то выглядели бы они примерно так:

Пример с XCTest
func testSum1And2() {
    // Arrange
    let calculator = SumCalculator()
    // Act
    calculator.sum(1, 2)
    // Assert
    XCTAssertEqual(calculator.result, 3)
}
    
func testSum1AndMinus1() {
    // Arrange
    let calculator = SumCalculator()
    // Act
    calculator.sum(1, -1)
    // Assert
    XCTAssertEqual(calculator.result, 0)
}

Важные замечания:

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

  2. Важно следить за читаемостью описаний блоков describe(), context() и it(). Читаемость — одна из основных характеристик теста. А еще это описание позволит проще анализировать результаты упавших тестов.

  3. Не стоит вызывать какой-либо код вне блоков beforeEach() или it(). Это может привести к неожиданному поведению теста. Но вне этих блоков разрешается размещать переменные, как, например, SumCalculator из теста, приведенного выше. Главное — не забывать присваивать ему новое значение в соответствующих блоках beforeEach().

  4. Блоки на одном уровне вложенности выполняются не в порядке объявления в коде, а в алфавитном порядке их описаний. Но не стоит на это завязываться. Для удобства лучше считать, что они вызываются в случайном порядке.

Несколько слов по поводу Nimble

Nimble — это метчер. Метчер нужен для проверки и сравнения значений результатов выполнения тестов. В примере Nimble используется в expect(calculator.result).to(equal(3)) (6) и expect(calculator.result) == 0 (11). Проверки == и .to(equal()) эквиваленты. На мой взгляд, == читается чуть лучше, но тут уже дело вкуса, каждый выбирает, что ему удобнее.

Еще из важных функций Nimble стоит отметить проверку toEventually(). Она позволяет работать с асинхронными вызовами и ждать момента, когда условие станет верным. Подробнее ее использование рассмотрим чуть позже.

А еще советую почитать документацию, там много всего интересного. Например, в Nimble есть проверки исключений, сравнения (включая неточные сравнения для чисел с плавающей точкой), проверки вхождения элементов в коллекции и много другое.

Что будем тестировать?

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

Нам важно вынести всю логику из View-слоя, чтобы вероятность логических ошибок в нем была минимальна. Кроме этого, работа с Date() должна быть вынесена в специальный DateProvider, потому что создание через Date()  — это неявная изменяемая зависимость, которая может повлиять на результаты тестирования. 

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

Описание модуля

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

При нажатии на нее показывается лоадер и происходит попытка загрузки данных из сети.

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

При неудачной загрузке показывается алерт с информацией об ошибке. При закрытии алерта снова видно кнопку “Загрузить полезный факт”.

Перейдем к реализации в коде. Наш модуль состоит из следующих основных частей: View, Presenter, Service, Router и NetworkTransport. Разберем каждую из них более подробно. Принесем в жертву некоторые хорошие практики разработки ради компактности статьи, так что если вам кажется, что код мог бы выглядеть элегантнее, то, скорее всего, оно так и есть.

Network

Начнем с NetworkTransport и Service. Есть протокол NetworkTransport, закрывающий NetworkTransportImpl (сам код приводить не буду, предположим, что он оборачивает URLSession), который умеет ходить в сеть за данными.

struct Request {
    enum Method {
        case get
        case post
    }
    let method: Method
    let url: String
    let params: [String: String]
}
 
protocol NetworkTransport {
    func load<T: Decodable>(
        request: Request,
        onResult: @escaping ((Result<T, Error>) -> Void)
    )
}

Использоваться он будет внутри InfoService. Это сервис, который знает, по какому URL обратиться и с какими параметрами отправить запрос.

final class InfoService {
    private let networkTransport: NetworkTransport
 
    init(networkTransport: NetworkTransport) {
        self.networkTransport = networkTransport
    }
 
    func loadInfo(onResult: @escaping ((Result<InfoModel, Error>) -> Void)) {
        let request = Request(method: .get, url: "https://info-example.com/get_info", params: [:])
        networkTransport.load(request: request, onResult: onResult)
    }
}

От сервера нам должны будут прийти такие данные:

{
    "infoText": "Quick и Nimble — это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер.",
    "identifier": 123,
    "creationDate": "2021-09-22T10:32:36.917"
}

И нам надо будет замаппить их в эту модель:

struct InfoModel: Decodable {
    let infoText: String
    let identifier: Int
    let creationDate: Date
// для парсинга Date нужно будет отдельно реализовать init(from decoder: Decoder)
}

Presenter

Дальше идет Presentation-слой. В нашем примере в нем идет основная логика. 

Сам Presenter будет общаться с View через протокол InfoViewInput. Функция update() обновляет state экрана, переводя его в требуемое состояние, а showError() показывает алерт об ошибке. Состояний у экрана может быть всего три: изначальный с синей кнопкой “Загрузить полезный факт”, загрузка в процессе с лоадером по центру экрана и уже экран с полезным фактом, датой и кнопкой “Посмотреть подробнее”. Эти состояния как раз и отражает перечисление InfoViewState.

enum InfoViewState: Equatable {
    case initial
    case loading
    case info(infoText: String, timeAgoText: String)
}
 
protocol InfoViewInput: AnyObject {
    func update(withViewState viewState: InfoViewState)
    func showError()
}

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

Код Presenter
final class InfoPresenter {
    weak var view: InfoViewInput? // weak ссылка на view
 
    private let service: InfoService 
    private let router: InfoRouter 
    private let dateProvider: DateProvider 
 
    private var lastLoadedInfoModel: InfoModel?
 
    init(service: InfoService,
         router: InfoRouter,
         dateProvider: DateProvider) {
        self.service = service
        self.router = router
        self.dateProvider = dateProvider
    }
 
    func start() { // вызывается внутри UIViewController.viewDidLoad()
        view?.update(withViewState: .initial)
    }
 
    func didTapLoadInfoButton() { // при нажатии на кнопку “Загрузить полезный факт”
        view?.update(withViewState: .loading)
        service.loadInfo { [weak self] result in
            guard let self = self else { return }
            let viewState: InfoViewState
            switch result {
            case let .success(infoModel):
                self.lastLoadedInfoModel = infoModel
                viewState = self.makeSuccessInfoState(from: infoModel)
            case .failure:
                viewState = .initial
                self.view?.showError()
            }
            self.view?.update(withViewState: viewState)
        }
    }
 
    func didTapShowDetailsButton() { при нажатии на кнопку “Посмотреть подробнее”
        guard let identifier = lastLoadedInfoModel?.identifier else { return }
        router.openDetails(withIdentifier: identifier)
    }
 
    private func makeSuccessInfoState(from infoModel: InfoModel) -> InfoViewState {
        let formatter = RelativeDateTimeFormatter()
        formatter.unitsStyle = .full
        let currentDate = dateProvider.getCurrentDate()
        let timeAgoText = formatter.localizedString(for: infoModel.creationDate, relativeTo: currentDate)
        return .info(infoText: infoModel.infoText, timeAgoText: timeAgoText)
    }
}

Рассмотрим этот класс подробнее.Метод презентера start() должен вызываться при вызове viewDidLoad() у UIViewController’а. Метод start() обновляет стейт у этого UIViewController’а и приводит его в начальное состояние с кнопкой “Загрузить полезный факт” по центру.

Метод didTapLoadInfoButton() вызывается при нажатии кнопку “Загрузить полезный факт” и начинает загружать полезный факт, а также переводит View в состоянии отображения лоадера. При успешной загрузке полученные данные обрабатываются, и Presenter обновляет с ними стейт у View. Если загрузка неудачная, то показывается ошибка и View переводится в изначальный стейт.

А метод didTapShowDetailsButton() вызывается при нажатии на кнопку “Посмотреть подробнее”, что приводит к вызову соответствующей функции Router’а. Сам Router и протокол, закрывающий его, выглядят так:

Протокол InfoRouter и его имплементация
protocol InfoRouter {
    func openDetails(withIdentifier identifier: Int)
}
 
final class InfoRouterImpl: InfoRouter {
    private weak var viewController: UIViewController?
    init(viewController: UIViewController) {
        self.viewController = viewController
    }
 
    func openDetails(withIdentifier identifier: Int) {
        let factory = DetailInfoFactoryImpl()
        let detailViewController = factory.makeViewController(withIdentifier: identifier)
        viewController?.present(detailViewController, animated: true)
    }
}

Стоит отдельно обратить внимание на DateProvider внутри Presenter и сам процесс получения даты.

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

protocol DateProvider {
    func getCurrentDate() -> Date
}
 
// для реального кода
final class RealDateProvider: DateProvider {
    func getCurrentDate() -> Date {
        return Date()
    }
}
 
// для использования в тестах
final class FakeDateProvider: DateProvider {
    func getCurrentDate() -> Date {
        Date(timeIntervalSince1970: 1632744444)
    }
}

View

А теперь код UIViewController’а, в нем все очень просто. Оставим за скобками стилизацию и настройки layout’а::

Код InfoViewController
final class InfoViewController: UIViewController {
 
    var presenter: InfoPresenter!
 
    private let loadInfoButton = UIButton()
    private let activityIndicator = UIActivityIndicatorView(style: .medium)
    private let infoContainer = UIView() // контейнер для showDetailsButton, infoLabel и timeAgoLabel
    private let showDetailsButton = UIButton()
    private let infoLabel = UILabel()
    private let timeAgoLabel = UILabel()
 
    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        presenter.start()
    }
 
    private func setupViews() {
        loadInfoButton.addTarget(self, action: #selector(didTapLoadInfoButton), for: .touchUpInside)
        showDetailsButton.addTarget(self, action: #selector(didTapShowDetailsButton), for: .touchUpInside)
        // настройка всех view
        // ...
    }
 
    @objc
    private func didTapLoadInfoButton() {
        presenter.didTapLoadInfoButton()
    }
 
    @objc
    private func didTapShowDetailsButton() {
        presenter.didTapShowDetailsButton()
    }
}
 
extension InfoViewController: InfoViewInput {
    func update(withViewState viewState: InfoViewState) {
        switch viewState {
        case .initial:
            loadInfoButton.isHidden = false
            activityIndicator.isHidden = true
            infoContainer.isHidden = true
        case .loading:
            loadInfoButton.isHidden = true
            activityIndicator.isHidden = false
            infoContainer.isHidden = true
        case let .info(infoText: infoText, timeAgoText: timeAgoText):
            loadInfoButton.isHidden = true
            activityIndicator.isHidden = true
            infoContainer.isHidden = false
 
            infoLabel.text = infoText
            timeAgoLabel.text = timeAgoText
        }
    }
 
    func showError() {
        let alertController = makeErrorAlert()
        present(alertController, animated: true)
    }
}

Тут стоит обратить внимание на update(), в ней при изменении state показываются, скрываются и обновляются соответствующие элементы.

Factory

Все эти элементы модуля собираются через фабрику:

Код фабрики
final class InfoModuleFactory {
    func makeViewController() -> UIViewController {
        let viewController = InfoViewController()
        let networkTransport = NetworkTransportImpl()
        let service = InfoService(networkTransport: networkTransport)
        let router = InfoRouterImpl(viewController: viewController)
        let dateProvider = RealDateProvider()
        let presenter = InfoPresenter(service: service, router: router,
                                      dateProvider: dateProvider)
        viewController.presenter = presenter
        presenter.view = viewController
 
        return viewController
    }
}

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

Что и как будем тестировать?

Основная логика этого модуля лежит в Presenter и в Service, эту связку мы и будем тестировать. Если бы в нашем модуле был еще и Interactor, то он тоже попал бы в тестируемую связку. А чтобы мы могли протестировать слои, содержащие логику, мы должны сначала закрыть моками те части, которые невозможно или неоправданно сложно протестировать.

Какие части закрыть моками? Моками мы закрываем View, Router, DateProvider и NetworkTransport. View и Router закрываем, потому что в них есть зависимости от UIKit. Про DateProvider уже пояснили чуть выше, так мы избавляемся от неявной изменяемой зависимости Date(). А NetworkTransport нужно закрыть моком, чтобы не ходить за реальными данными в сеть и сделать тесты быстрыми и воспроизводимыми.

Стоит отметить, что для упрощения в этой статье моками будет называться любая часть, которая подменяет реальный код. Хотя по факту некоторые из них было бы правильнее назвать Spy или Fake. Подробнее о моках, стабах и шпионах можно почитать в статье Mocks aren’t Stubs.

Создаем моки:

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

Чтобы избежать написания этого бойлерплейта, можно использовать SwiftMockGeneratorForXcode. Это расширение для Xcode, которое позволяет создавать моки в несколько кликов мышкой. Не буду дублировать инструкцию тут, ее можно найти на страничке проекта, там всё очень просто и потребует минут 5 на установку и запуск. Для начала работы с тестами я советую использовать именно эту утилиту.

А еще моки можно генерировать с помощью Sourcery, в документации которой есть отдельный пункт про кодогенерацию для моков. В Циан мы используем именно этот инструмент.

Начнем с InfoViewInput и сгенерируем для него мок через SwiftMockGeneratorForXcode.

Мок InfoViewInput
final class InfoViewInputSpy: InfoViewInput {
 
    var invokedUpdate = false
    var invokedUpdateCount = 0
    var invokedUpdateParameters: (viewState: InfoViewState, Void)?
    var invokedUpdateParametersList = [(viewState: InfoViewState, Void)]()
 
    func update(withViewState viewState: InfoViewState) {
        invokedUpdate = true
        invokedUpdateCount += 1
        invokedUpdateParameters = (viewState, ())
        invokedUpdateParametersList.append((viewState, ()))
    }
 
    var invokedShowError = false
    var invokedShowErrorCount = 0
 
    func showError() {
        invokedShowError = true
        invokedShowErrorCount += 1
    }
}

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

А теперь создадим мок для InfoRouter:

Мок InfoRouter
final class InfoRouterSpy: InfoRouter {
 
    var invokedOpenDetails = false
    var invokedOpenDetailsCount = 0
    var invokedOpenDetailsParameters: (identifier: Int, Void)?
    var invokedOpenDetailsParametersList = [(identifier: Int, Void)]()
 
    func openDetails(withIdentifier identifier: Int) {
        invokedOpenDetails = true
        invokedOpenDetailsCount += 1
        invokedOpenDetailsParameters = (identifier, ())
        invokedOpenDetailsParametersList.append((identifier, ()))
    }
}

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

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

Код минималистичной версии FakeNetworkTransport будет выглядеть примерно так:

Код NetworkTransportMock
public struct FakeResponse: Hashable {
    enum ResponseType {
        case success(responseFileName: String)
        case failure(error: Swift.Error)
    }
 
    let url: String
    let method: Request.Method
    let response: ResponseType
 
    public static func == (lhs: FakeResponse, rhs: FakeResponse) -> Bool {
        lhs.url == rhs.url &&
        lhs.method == rhs.method
    }
 
    public func hash(into hasher: inout Hasher) {
        hasher.combine(url)
        hasher.combine(method)
    }
}
 
public final class NetworkTransportMock {
    public var delay: TimeInterval = 0
    private let bundle: Bundle
    private var fakeResponses = Set<FakeResponse>()
    private(set) var invokedRequests = [Request]()
 
    public init(bundle: Bundle) {
        self.bundle = bundle
    }
 
    func mockSuccess(url: String, method: Request.Method, responseFile: String) {
        let responseType: FakeResponse.ResponseType = .success(responseFileName: responseFile)
        let fakeResponse = FakeResponse(url: url, method: method, response: responseType)
        insertFakeResponse(fakeResponse)
    }
 
    func mockFailure(url: String, method: Request.Method, error: Swift.Error) {
        let responseType: FakeResponse.ResponseType = .failure(error: error)
        let fakeResponse = FakeResponse(url: url, method: method, response: responseType)
        insertFakeResponse(fakeResponse)
    }
 
    func removeFakeResponse(withURL url: String, method: Request.Method) {
        guard let fakeResponse = findFakeResponse(forURL: url, method: method) else { return }
        fakeResponses.remove(fakeResponse)
    }
    
    private func insertFakeResponse(_ fakeResponse: FakeResponse) {
        removeFakeResponse(withURL: fakeResponse.url, method: fakeResponse.method)
        fakeResponses.insert(fakeResponse)
    }
 
    private func findFakeResponse(forURL url: String, method: Request.Method) -> FakeResponse? {
        return fakeResponses.first(where: { $0.url == url && $0.method == method })
    }
 
    private func getObject<T: Decodable>(with jsonName: String) -> T {
        guard
            let url = bundle.url(forResource: jsonName, withExtension: "json"),
            let jsonData = try? Data(contentsOf: url),
            let object = try? JSONDecoder().decode(T.self, from: jsonData)
        else {
            fatalError()
        }
        return object
    }
}
 
extension NetworkTransportMock: NetworkTransport {
 
    func load<T>(request: Request, onResult: @escaping ((Result<T, Error>) -> Void)) where T : Decodable {
        invokedRequests.append(request)
        guard let fakeResponse = findFakeResponse(forURL: request.url, method: request.method) else {
            fatalError()
        }
        let result: Result<T, Error>
        switch fakeResponse.response {
        case let .success(responseFileName: responseFileName):
            let responseObject: T = getObject(with: responseFileName)
            result = .success(responseObject)
        case let .failure(error: error):
            result = .failure(error)
        }
        if delay == 0 {
            DispatchQueue.main.async {
                onResult(result)
            }
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(1000 * delay))) {
                onResult(result)
            }
        }
    }
}

Тут стоит обратить внимание на функции mockSuccess() и mockFailure(). Через них можно сообщить NetworkTransportMock для каких запросов какие json-файлы с ответами использовать. А при вызове load() уже будет происходить поиск нужных ответов в bundle и их декодинг.

На этом с моками всё. Теперь нам нужно всё это как-то собрать воедино, чтобы протестировать. Объекты, которые понадобятся нам во время тестирования, мы будем хранить в TestInfoModule.

struct TestInfoModule {
    let presenter: InfoPresenter
 
    let viewInputSpy: InfoViewInputSpy
    let routerSpy: InfoRouterSpy
    let transportMock: NetworkTransportMock
}

А собирать этот модуль для тестирования мы будем с помощью специальной фабрики.

Код фабрики для TestInfoModule
final class TestInfoFactory {
    func makeModule() -> TestInfoModule {
        
        let viewInputSpy = InfoViewInputSpy()
        let bundle = Bundle(for: TestInfoFactory.self)
        let fakeNetworkTransport = NetworkTransportMock(bundle: bundle)
        let service = InfoService(networkTransport: fakeNetworkTransport)
        let routerSpy = InfoRouterSpy()
        let dateProvider = FakeDateProvider()
        let presenter = InfoPresenter(service: service, router: routerSpy, dateProvider: dateProvider)
        
        presenter.view = viewInputSpy
 
        return TestInfoModule(
            presenter: presenter,
            viewInputSpy: viewInputSpy,
            routerSpy: routerSpy,
            transportMock: fakeNetworkTransport
        )
    }
}

Наконец, подготовка закончена. Мы заменили на моки всё, что нам было нужно, дальше переходим к тестированию!

Тестируем!

Вот мы и почти готовы к тестированию нашего модуля. Чтобы начать писать тесты, осталось только интегрировать Quick в проект. Как это сделать, лучше всего описано в документации, выберите там подходящий вам способ. 

В модульных тестах очень важно соблюдать последовательность вызовов, максимально близкую к реальной. У нас в Presenter есть функция start(), которая вызывается из UIViewController.viewDidLoad(), и функция didTapLoadInfoButton(), которая начинает загрузку данных. На реальном устройстве нажать на кнопку на экране можно будет только после того, как вызовется viewDidLoad(). Вот и в тестах, чтобы проверить работу функции didTapLoadInfoButton(), мы сначала должны вызвать функцию start(). Благо в Quick это всё довольно легко организовать.

Создание модуля

Начнем с создания модуля:

class QuickDemoTests: QuickSpec { // 1
    override func spec() { // 2
        describe("Экран с интересными фактами") { // 3
 
            var module: TestInfoModule! // 4
            var presenter: InfoPresenter { module.presenter } // 4.1
            var lastViewState: InfoViewState { module.viewInputSpy.invokedUpdateParameters!.viewState } // 4.2
            var networkTransport: NetworkTransportMock { module.transportMock } // 4.3
 
            beforeEach {
                module = TestInfoFactory().makeModule() // 5
            }
            // 6
        }
    }
}

Пойдем по порядку. В (1) мы объявляем наш класс с тестами и наследуем его от QuickSpec, в (2) переопределяем функцию spec(). Потом объявляем внешний блок describe() (3) и в его описании пишем, что именно будем проверять в этом тесте. Обычно это название тестируемого модуля.

Едем дальше: в (4) идет объявление переменной модуля, а в (4.1, 4.2, 4.3) — вычислимые проперти-хелперы для упрощения обращения к частям модуля. Важно, что сам модуль создается (5) в блоке beforeEach(), а не в момент объявления переменной. Это нужно, чтобы части теста не влияли друг на друга.А на место (6) мы будем добавлять новые части теста.

Первая часть теста

Начнем с проверки изначального состояния. После вызова viewDidLoad() у UIViewController на экране должна показаться кнопка “Загрузить полезный факт”, а это значит, что Presenter должен сообщить View, что надо перейти в состояние initial. В этот момент еще никаких запросов не должно быть отправлено на сервер.

Код теста будет выглядеть так, мы вставляем его на место (6):

context("При появлении экрана") { // 7
    beforeEach {  // 8
        presenter.start()
    }
    it("На экране появляется кнопка загрузки данных") { // 9
        expect(module.viewInputSpy.invokedUpdateCount) == 1 // 10
        expect(lastViewState) == .initial // 11
    }
    it("Никаких запросов на сервер не отправляется") { // 12
        expect(networkTransport.invokedRequests.isEmpty) == true // 13
    }
    // 14
}

Итак, пойдем по порядку. В этой части теста у нас появляется context() (7), внутри описания context() мы пишем определенное условие, последствия выполнения которого мы будем проверять внутри. Читать это можно так: “Если View появилось на экране, то …”.  Далее идет блок beforeEach() (8). В нем мы переводим систему в состояние, которое соответствует условию в описании context(). Все действия над системой нужно производить в блоках beforeEach()

Чуть ниже идут две проверки it() (9 и 12). В описании проверок it() мы пишем, что именно будем проверять. Проверки разных аспектов (например, изменение View и работы с сетью) лучше разделять на разные блоки it(). В (9) мы проверяем, что было выполнено одно обновление View (10) и что состояние стало именно initial (11). А в (12) проверяем, что никаких обращений к сети не было (13).

Тут очень важно еще раз отметить порядок выполнения блоков кода. Нужно учитывать, что каждый it() фактически является отдельным тестом, и beforeEach() будет выполняться перед каждым вложенным блоком на одном уровне. Это значит, что порядок кода будет такой: [(8), (9)] и [(8), (12)], но никак не [(8), (9), (12)].

Теперь наш модуль переведен в начальное состояние, и все необходимые проверки выполнены. Следующую проверку будем вписывать на место (14).

Проверяем нажатие на кнопку и успешную загрузку данных

После того как система переведена в начальное состояние, можно нажать на кнопку “Загрузить полезный факт”, которая вызовет загрузку данных. Сразу после нажатия на кнопку должен появиться лоадер (View переходит в состояние loading), а после успешной загрузки View перейдет в состояние отображения полезного факта (состояние info).

context("При нажатии на кнопку загрузки данных и успешная загрузка") { // 15
    beforeEach { // 16
        networkTransport.mockSuccessForInfoLoading() // 17
        presenter.didTapLoadInfoButton() // 18
    }
    it("Данные начинают загружаться и в итоге загружаются") { // 19
        expect(lastViewState) == .loading // 20
        expect(module.viewInputSpy.invokedUpdateCount) == 2 // 21
 
        expect(lastViewState).toEventually(equal(self.successState())) // 22
        expect(module.viewInputSpy.invokedUpdateCount) == 3 // 23
    }
    // 24
}
 
private extension NetworkTransportMock {
    static let infoURL = "https://info-example.com/get_info"
    func mockSuccessForInfoLoading() {  // 17.1
	delay = 0.1
        mockSuccess(
            url: Self.infoURL,
            method: .get,
            responseFile: "info-fake-response"
        )
    }
}
 
private extension QuickDemoTests {
    func successState() -> InfoViewState { // 22.1
        return .info(
            infoText: "Quick и Nimble — это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер.",
            timeAgoText: "5 дней назад"
        )
    }
}
info-fake-response.json
{
    "infoText": "Quick и Nimble — это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер.",
    "identifier": 123,
    "creationDate": "2021-09-22T10:32:36.917"
}

Пройдемся по коду теста: context() (15) идет на месте (14), т. е. он вложен внутрь предыдущего context() (7). Это нужно, чтобы избежать дублирования вызова presenter.start() из (8), а еще это позволяет более внятно организовать код тестов.

В beforeEach() (16) мы добавляем обработчик запроса (17) в NetworkTransportMock через функцию в его extension (17.1). Добавление вынесено в extension специально, чтобы повысить читаемость кода теста.

Это значит, что если в NetworkTransportMock придет запрос с url: https://info-example.com/get_info и method: GET, то ответ будет взят из info-fake-response.json

А в (18) мы уже нажимаем на саму кнопку загрузки данных и в it() (19) сначала проверяем, что обновление View было сделано в общей сложности 2 раза (20) (первый раз стейт был initial). Таким образом, итоговый стейт, сразу после нажатия на кнопку, становится loading (21). 

В (22) становится интереснее. Из-за того что у NetworkTransportMock мы настроили задержку ответа в 0.1 секунду (в методе (17.1)), чтобы эмулировать ожидание выполнения запроса к серверу, нам придется подождать эту 0.1 секунды, пока стейт не обновится до ожидаемого. А для того чтобы дождаться отложенного действия, мы будем использовать функцию toEventually(). Прочитать, как она работает, можно тут

В (22) мы как раз ждем, пока стейт обновится до необходимого нам. Создание правильного стейта для проверки вынесено в отдельную функцию (22.1) для улучшения читаемости кода. В (23) мы проверяем, что стейт обновился ровно 3 раза, и мы случайно не обновили его лишний раз после загрузки данных. В (24) мы будем вставлять еще один context() для проверки нажатия на кнопку “Посмотреть подробнее”, которая будет открывать новый экран.

Всё, успешную загрузку данных мы проверили, осталось проверить еще буквально пару вещей.

Проверка открытия экрана с дополнительной информацией

После того, как данные загружены, мы можем нажать на кнопку, и откроется экран с подробной информацией. Из-за того, что на реальном устройстве нажать на эту кнопку можно только после загрузки данных, в тестах мы тоже должны проверять открытие экрана только после проверки загрузки данных, т.е. context() (25) должен идти на месте (24).

context("При нажатии на кнопку подробной информации") { // 25
    beforeEach {
        waitUntilEqual(lastViewState, self.successState()) // 26
        presenter.didTapShowDetailsButton() // 27
    }
    it("Открывается экран с подробностями") {
        expect(module.routerSpy.invokedOpenDetailsCount) == 1 // 28
        expect(module.routerSpy.invokedOpenDetailsParameters!.identifier) == 123 // 29
    }
}
 
// 30
public func waitUntilEqual<T: Equatable>(file: FileString = #file,
                                         line: UInt = #line,
                                         _ left: @autoclosure @escaping () throws -> T,
                                         _ right: T?,
                                         timeout: DispatchTimeInterval = AsyncDefaults.timeout) {
    expect(file: file, line: line, left).toEventually(equal(right), timeout: timeout)
}

Чтобы этот тест правильно работал, придется сделать небольшой трюк. Поскольку context() (25) начнет выполняться сразу после выполнения кода в beforeEach() на предыдущем уровне (16), то и в стейт info система перейти не успеет. Поэтому тут нам надо подождать, пока это произойдет прямо в этом блоке beforeEach() (25). Но чтобы внутри beforeEach() не использовать expect(), их можно заменить на waitUntilTrue(), это просто небольшая обертка над expect().toEventually()(30), но она делает код немного чище.

После того как мы дождались перехода системы в состояние info с помощью waitUntilTrue(), мы нажимаем на кнопку (27) и дальше проверяем, что нажатие на кнопку было одно (28), и в роутер был передан правильный идентификатор факта (29).

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

Неуспешная загрузка данных

При неуспешной загрузке должен показаться алерт об ошибке, а система должна перейти в состояние initial.

context("При нажатии на кнопку загрузки данных и неуспешной загрузке") { // 31
    beforeEach {
        networkTransport.mockFailureForInfoLoading() // 32
        presenter.didTapLoadInfoButton() // 33
    }
    it("В итоге данные не загружаются и показывается ошибка") {
        expect(lastViewState).toEventually(equal(.initial)) // 34
        expect(module.viewInputSpy.invokedUpdateCount) == 3 // 35
        expect(module.viewInputSpy.invokedShowErrorCount) == 1 // 36
    }
}
 
private extension NetworkTransportMock {
    static let infoURL = "https://info-example.com/get_info"
    ...
    func mockFailureForInfoLoading() { // 32.1
        mockFailure(
            url: Self.infoURL,
             method: .get,
             error: NSError()
        )
    }
}

Проверка неуспешной загрузки данных (31) должна идти на том же уровне вложенности, что и context() проверки успешной загрузки (15). В начале в NetworkTransportMock мы добавляем обработчик (32, 32.1), который будет возвращать ошибку для обращения к сети. Потом мы нажимаем на кнопку (33) и проверяем, что система в итоге перешла в состояние initial (34), всего было 3 обновления стейта (первый на initial, потом на loading, потом снова на initial) и был показан алерт об ошибке (36).

Тесты готовы

Готово, модуль полностью протестирован. Полный код тестов модуля приведен ниже, по нему будет немного понятнее, какие блоки context() куда вложены. А еще код тестов выступает в виде простой документации модуля. С его помощью можно понять, какая функциональность есть у модуля и в каком порядке она может использоваться.

Полный код тестов
import Quick
import Nimble
@testable import QuickDemo

class QuickDemoTests: QuickSpec { // 1
    override func spec() { // 2
        describe("Экран с интересными фактами") { // 3
            
            var module: TestInfoModule! = TestInfoFactory().makeModule() // 4
            var presenter: InfoPresenter { module.presenter } // 4.1
            var lastViewState: InfoViewState { module.viewInputSpy.invokedUpdateParameters!.viewState } // 4.2
            var networkTransport: NetworkTransportMock { module.transportMock } // 4.3
            
            beforeEach {
                module = TestInfoFactory().makeModule() // 5
            }
            // 6
            context("При появлении экрана") { // 7
                beforeEach {  // 8
                    presenter.start()
                }
                it("На экране появляется кнопка загрузки данных") { // 9
                    expect(lastViewState) == .initial // 10
                    expect(module.viewInputSpy.invokedUpdateCount) == 1 // 11
                }
                it("Никаких запросов на сервер не отправляется") { // 12
                    expect(networkTransport.invokedRequests.isEmpty) == true // 13
                }
                // 14
                context("При нажатии на кнопку загрузки данных и успешная загрузке") { // 15
                    beforeEach { // 16
                        networkTransport.mockSuccessForInfoLoading() // 17
                        presenter.didTapLoadInfoButton() // 18
                    }
                    it("Данные начинают загружаться и в итоге загружаются") { // 19
                        expect(lastViewState) == .loading // 20
                        expect(module.viewInputSpy.invokedUpdateCount) == 2 // 21
                        
                        expect(lastViewState).toEventually(equal(self.successState())) // 22
                        
                        expect(lastViewState) == self.successState()
                        if case let .info(infoText: infoText, timeAgoText: timeAgoText) = lastViewState {
                            expect(infoText) == "Quick и Nimble — это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер."
                            expect(timeAgoText) == "5 дней назад"
                        }
                        expect(module.viewInputSpy.invokedUpdateCount) == 3 // 23
                    }
                    // 24
                    context("При нажатии на кнопку подробной информации") { // 25
                        beforeEach {
                            waitUntilEqual(lastViewState, self.successState()) // 26
                            presenter.didTapShowDetailsButton() // 27
                        }
                        it("Открывается экран с подробностями") {
                            expect(module.routerSpy.invokedOpenDetailsCount) == 1 // 28
                            expect(module.routerSpy.invokedOpenDetailsParameters!.identifier) == 123 // 29
                        }
                    }
                }
                context("При нажатии на кнопку загрузки данных и неуспешной загрузке") { // 31
                    beforeEach {
                        networkTransport.mockFailureForInfoLoading() // 32
                        presenter.didTapLoadInfoButton() // 33
                    }
                    it("В итоге данные не загружаются и показывается ошибка") {
                        expect(lastViewState).toEventually(equal(.initial)) // 34
                        expect(module.viewInputSpy.invokedUpdateCount) == 3 // 35
                        expect(module.viewInputSpy.invokedShowErrorCount) == 1 //36
                    }
                }
            }
        }
    }
}

extension QuickDemoTests {
    func successState() -> InfoViewState { // 22.1
        return .info(
            infoText: "Quick и Nimble — это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер.",
            timeAgoText: "5 дней назад"
        )
    }
}

private extension NetworkTransportMock {
    static let infoURL = "https://info-example.com/get_info"
    func mockSuccessForInfoLoading() { // 17.1
        delay = 0.1
        mockSuccess(
            url: Self.infoURL,
            method: .get,
            responseFile: "info-fake-response"
        )
    }
    
    func mockFailureForInfoLoading() { // 32.1
        mockFailure(
            url: Self.infoURL,
            method: .get,
            error: NSError()
        )
    }
}

// 30
public func waitUntilEqual<T: Equatable>(file: FileString = #file,
                                         line: UInt = #line,
                                         _ left: @autoclosure @escaping () throws -> T,
                                         _ right: T?,
                                         timeout: DispatchTimeInterval = AsyncDefaults.timeout) {
    expect(file: file, line: line, left).toEventually(equal(right), timeout: timeout)
}

Как можно улучшить код тестов?

Тут приведен небольшой список приемов, которые могут улучшить код тестов

Вынос создания конфигурации в отдельные фабрики

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

Разделение разных сценариев на разные тесты

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

В этом случае имеет смысл разделить тест на несколько блоков describe() или даже разных тестовых файлов.

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

Вынос кода в функции

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

Написание extension для тестируемых элементов

Очень похоже на вынос кода в функции, но немного под другим углом. В нашем тесте уже был такой пример, когда мы выносили добавление обработчиков в NetworkTransportMock.

Пример

Без добавления кода extension:

beforeEach {
    networkTransport.mockFailure(
        url: "https://info-example.com/get_info",
        method: .get,
        error: NSError()
    )
    presenter.didTapLoadInfoButton() 
}

С добавлением через extension:

beforeEach {
    networkTransport.mockFailureForInfoLoading()
    presenter.didTapLoadInfoButton() 
}
.....
private extension NetworkTransportMock {
    static let infoURL = "https://info-example.com/get_info"
    ...
    func mockFailureForInfoLoading() { // 36
        mockFailure(
            url: Self.infoURL,
             method: .get,
             error: NSError()
        )
    }
}

Во время чтения кода теста нас в целом не волнует, как добавляется обработчик с ошибкой, нас интересует только сам факт добавления обработчика. Поэтому в данном случае такой подход вполне оправдан и улучшает читаемость теста.

Сравнение с образцом

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

Пример

Пример прямой проверки

it("Данные начинают загружаться и в итоге загружаются") { 
		if case let .info(infoText: infoText, timeAgoText: timeAgoText) = lastViewState {
				expect(infoText) == "Quick и Nimble это фреймворки, используемые для тестирования в iOS разработке. Quick — это фреймворк для тестирования, а Nimble — это метчер."
        expect(timeAgoText) == "5 дней назад"
		}
    expect(module.viewInputSpy.invokedUpdateCount) == 3 
}

Пример сравнения с образцом:

it("Данные начинают загружаться и в итоге загружаются") {
		expect(lastViewState) == self.successState()
		expect(module.viewInputSpy.invokedUpdateCount) == 3 
}

Force unwrap можно использовать в тестах

В тестах можно использовать force unwrap, особенно для проверяемых значений. Если значения нет, то тест упадет, а это значит, что в клиентском коде появились какие-то ошибки.

Тестирование модулей в CocoaPods

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

Заключение

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

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

Комментарии (0)