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

Код выглядит так.

import SwiftUI

struct TodoListView: View {
    @State private var state: ListViewState = .idle
    private let databaseManager: DatabaseManager = .shared

    var body: some View {
        Group {
            switch state {
            case .idle:
                Button("Start") {
                    Task {
                        await refreshTodos()
                    }
                }

            case .loading:
                Text("Loading…")

            case .error:
                VStack {
                    Text("Oops")
                    Button("Try again") {
                        Task {
                            await refreshTodos()
                        }
                    }
                }

            case .loaded(let todos):
                VStack {
                    List(todos) {
                        Text("\($0.title)")
                    }
                }
            }
        }.onChange(of: state) {
            guard case .loaded(let todos) = $0 else {
                return
            }

            databaseManager.save(data: todos)
        }
    }

    private func refreshTodos() async {
        state = .loading
        do {
            let todos = try await loadTodos().sorted { $0.title < $1.title }
            state = .loaded(todos)
        } catch {
            state = .error
        }
    }

    private func loadTodos() async throws -> [Todo] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
        let (data, _) = try await URLSession.shared.data(from: url)
        let todos = try JSONDecoder().decode([Todo].self, from: data)
        return todos
    }
}

enum ListViewState: Equatable {
    case idle
    case loading
    case loaded([Todo])
    case error
}

struct Todo: Codable, Identifiable, Equatable {
    var userId: Int
    var id: Int
    var title: String
    var completed: Bool
}

final class DatabaseManager {
    static let shared: DatabaseManager = .init()
    private init() {}

    func save<T>(data: T) where T: Encodable {
        // TBD
    }
}

Этот код далек от идеала, но на данный момент он выполняет свою работу, не слишком усложняя вещи.

Мы хотим проверить это:

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

Мы протестируем все эти варианты использования с помощью различных инструментов, начиная с более простого: с помощью XCUITest.

Тестирование пользовательского интерфейса через XCUITest

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

Код выглядит так.

import SnapshotTesting
import XCTest

final class TodoListViewUITests: XCTestCase {
    func testIdle() {
        // Given
        let app = XCUIApplication()
        app.launch()

        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )
    }

    func testLoading() {
        // Given
        let app = XCUIApplication()
        app.launch()

        // When
        app.buttons.element.tap()

        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )
    }

    func testLoaded() {
        // Given
        let app = XCUIApplication()
        app.launch()

        // When
        app.buttons.element.tap()

        // Then
        guard app.collectionViews.element.waitForExistence(timeout: 5) else {
            XCTFail()
            return
        }
            
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )
    }
}

private extension XCUIScreenshot {
    // Let's get rid of both the status bar and home indicator to have deterministic results.
    var withoutStatusBarAndHomeIndicator: UIImage {
        let statusBarOffset = 40.0
        let homeIndicatorOffset = 20.0
        let image = image
        return .init(
            cgImage: image.cgImage!.cropping(
                to: .init(
                    x: 0,
                    y: Int(statusBarOffset * image.scale),
                    width: image.cgImage!.width,
                    height: image.cgImage!.height - Int((statusBarOffset + homeIndicatorOffset) * image.scale)
                )
            )!,
            scale: image.scale,
            orientation: image.imageOrientation
        )
    }
}

Несколько интересных замечаний:

  • Сочетание тестов пользовательского интерфейса с тестированием скриншотов действительно эффективно, поскольку оно значительно упрощает часть утверждения представления. Скриншот-тестирование имеет довольно много недостатков, которые выходят за рамки этой статьи, но они чрезвычайно удобны.
  • Чтобы заставить их работать детерминировано, мы должны удалить как строку состояния, так и домашний индикатор (да, домашний индикатор иногда меняется между тестовыми запусками ¯\_(ツ)_/¯).
  • Мы не можем протестировать случай ошибки, так как у нас нет простого способа управления сетью.
  • Отсутствие контроля над сетью означает, что тесты выполняются медленно из-за тайм-аутов. Если мы запустим тесты без подключения к Интернету, они потерпят неудачу и т. д.
  • Нагрузочный тест не является детерминированным. В зависимости от скорости ответа сети и API снимок будет сделан на экране «Загрузка…» или на экране загрузки.
  • Загруженный тест также может быть недетерминированным, в зависимости от результата сети (конечная точка loadTodos). На данный момент он каждый раз возвращает одни и те же данные, но это может измениться.
  • Мы не можем проверить, что данные были сохранены в базе данных. Тесты пользовательского интерфейса выполняются в другом процессе, отличном от процесса запуска тестов, поэтому доступ к базовым данным FileManager невозможен.
  • Очень сложно и обременительно подготовить тесты для перехода к конкретному экрану, который мы хотим протестировать. Приложение открывается с нуля, и нам нужно сначала перейти к тестируемому экрану перед выполнением утверждений.

Как видите, у нас есть много важных проблем с использованием XCUITest. Это не похоже на лучший подход. И, конечно же, не должен. Тесты пользовательского интерфейса должны охватывать лишь небольшую часть критически важных бизнес-процессов, поскольку они более надежно имитируют взаимодействие с пользователем.

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

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

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

Тестирование пользовательского интерфейса через EarlGrey 2.0

В прошлом у нас был KIF, хорошая альтернатива XCUITest для тестирования пользовательского интерфейса. Хотя KIF все еще работает, у него есть некоторые ограничения. Главный из них заключается в том, что мы не можем взаимодействовать с алертами и другими окнами, отличными от основного. Насколько я знаю, это было основной причиной, по которой Google отказался от своей первой версии EarlGrey, в которой использовался аналогичный подход к тестированию пользовательского интерфейса, основанный на принципах белого ящика, в пользу использования существующей платформы XCUITest и добавления некоторого волшебства сверху. Я думаю, что в 2023 году EarlGrey 2.0 — единственная хорошая альтернатива XCUITest, которую стоит рассмотреть, ИМО.

С EarlGrey 2.0 мы можем создать конкретное, правильно настроенное представление и установить его в качестве корневого контроллера представления окна. Для управления сетью и базой данных нам нужно сделать небольшой рефакторинг TodoListView.

  • Мы добавим асинхронную функцию для управления вызовом API.
  • Мы внедрим абстракцию менеджера баз данных, что позволит позже внедрить правильный макет.
import SwiftUI

struct TodoListView: View {
    @State private var state: ListViewState = .idle

    private let databaseManager: DatabaseManagerProtocol
    private let loadTodos: () async throws -> [Todo]

    init(
        databaseManager: DatabaseManagerProtocol,
        loadTodos: @escaping () async throws -> [Todo] = loadTodos
    ) {
        self.databaseManager = databaseManager
        self.loadTodos = loadTodos
    }

    var body: some View { … }

    private static func loadTodos() async throws -> [Todo] { … }
}

protocol DatabaseManagerProtocol {
    func save<T>(data: T) where T: Encodable
}

final class DatabaseManager: DatabaseManagerProtocol {
    static let shared: DatabaseManager = .init()
    private init() {}

    func save<T>(data: T) where T: Encodable {
        // TBD
    }
}

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

import XCTest
import SnapshotTesting

final class TodoListViewUITests: XCTestCase {
    func testIdle() {
        // Given
        let app = XCUIApplication()
        app.launch()

        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )
    }

    func testLoading() {
        // Given
        let app = XCUIApplication()
        app.launch()

        let todosSaved = todoListViewHost().givenLoadingTodoListView()

        // When
        app.buttons.element.tap()

        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )

        XCTAssertFalse(todosSaved())
    }

    func testLoaded() {
        // Given
        let app = XCUIApplication()
        app.launch()

        let todosSaved = todoListViewHost().givenLoadedTodoListView()

        // When
        app.buttons.element.tap()

        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )

        XCTAssertTrue(todosSaved())
    }

    func testError() {
        // Given
        let app = XCUIApplication()
        app.launch()
        
        let todosSaved = todoListViewHost().givenErrorTodoListView()
        
        // When
        app.buttons.element.tap()
        
        // Then
        assertSnapshot(
            matching: app.screenshot().withoutStatusBarAndHomeIndicator,
            as: .image
        )
        
        XCTAssertFalse(todosSaved())
    }
}

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

Как вы понимаете, эти два мира невозможно пересечь дико. Есть некоторые ограничения. Мы можем использовать только те типы, которые видны среде выполнения Objective-C, как видно из ключевого слова @objc в протоколе TodoListViewHost. Кроме того, разные файлы должны принадлежать очень конкретным целям. Вся конфигурация довольно громоздкая.

Эта статья не является учебным пособием EarlGrey. Если вам интересно, на их веб-странице есть обширная документация и примеры.

Это два файла, необходимые для нашего примера.

@objc
protocol TodoListViewHost {
    func givenLoadedTodoListView() -> () -> Bool
    func givenLoadingTodoListView() -> () -> Bool
    func givenErrorTodoListView() -> () -> Bool
}

// Hosts cannot be reused across tests. Make sure to create a new one each time.
let todoListViewHost: () -> TodoListViewHost = {
    unsafeBitCast(
        GREYHostApplicationDistantObject.sharedInstance,
        to: TodoListViewHost.self
    )
}
import SwiftUI
@testable import Testing

extension GREYHostApplicationDistantObject: TodoListViewHost {
    func givenLoadingTodoListView() -> () -> Bool {
        givenTodoListView {
            try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
            fatalError("Should never happen")
        }
    }

    func givenLoadedTodoListView() -> () -> Bool {
        givenTodoListView {
            // Load unsorted todos so we can verify that they are properly sorted in the view.
            Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        }
    }

    func givenErrorTodoListView() -> () -> Bool {
        givenTodoListView {
            struct SomeError: Error {}
            throw SomeError()
        }
    }

    private func givenTodoListView(loadTodos: @escaping () async throws -> [Todo]) -> () -> Bool {
        let databaseSpy = DatabaseManagerSpy()
        let view = TodoListView(databaseManager: databaseSpy, loadTodos: loadTodos)
        UIWindow.current.rootViewController = UIHostingController(rootView: view)
        return { databaseSpy.called }
    }
}

private extension UIWindow {
    static var current: UIWindow {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap(\.windows)
            .filter(\.isKeyWindow)
            .first!
    }
}

private class DatabaseManagerSpy: DatabaseManagerProtocol {
    var called: Bool = false

    func save<T>(data: T) where T: Encodable {
        called = true
    }
}

Мы значительно улучшили начальные тесты пользовательского интерфейса:

  • Теперь тесты гораздо более детерминированы. Они никогда не выходят в сеть, и благодаря этому их выполнение занимает меньше времени.
  • Мы можем надежно протестировать случаи загрузки и загрузки, а также случай ошибки.
  • Мы можем проверить, были ли задачи правильно сохранены (или не сохранены) в базе данных (ну, по крайней мере, мы можем проверить, что сообщение правильно отправлено менеджеру базы данных).
  • Мы можем установить определенное представление в качестве корневого представления окна, не переходя к конкретному экрану, как мы делаем с обычными тестами пользовательского интерфейса.

Но…

  • Ремонтопригодность по-прежнему сложна. «Данное» разбросано по разным файлам и целям.
  • Мост для пересечения обоих процессов зависит от @objc, что не очень удобно и является важным ограничением для типов, которые мы можем использовать.
  • У нас по-прежнему очень медленная петля обратной связи. 12 секунд — это все еще довольно много времени для всего четырех тестов.
  • Сейчас мы связаны с EarlGrey, фреймворком Google, будущее которого неизвестно… Это то, что мы должны учитывать. Каковы последствия отказа Google от этой структуры?

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

Модульное тестирование представления

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

Если мы хотим провести модульное тестирование представления, нам нужно иметь способ выполнять над ним действия. В UIKit это было так же просто, как отображать подпредставления и выполнять эти действия через API, такие как button.sendActions(for: .touchUpInside) (даже если это не совсем правильно, поскольку подпредставления должны быть деталями реализации родительского представления).

Но SwiftUI отличается. У нас нет доступа к базовым подпредставлениям, поскольку они представляют собой детали реализации, которые фреймворк определяет на основе значения View.

К счастью, есть такие инструменты, как ViewInspector, которые мы можем использовать для доступа к элементам представления и имитации взаимодействия этих представлений.

Модульный тест представления выглядит следующим образом.

func testLoading() throws {
    // Given
    let databaseSpy = DatabaseManagerSpy()
    let sut = TodoListView(databaseManager: databaseSpy) {
        try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
        fatalError("Should never happen")
    }

    // When
    try sut.inspect().find(button: "Start").tap()

    // Then
    assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
    XCTAssertFalse(databaseSpy.called)
}

В то время как библиотека ViewInspector правильно извлекает и нажимает кнопку через try sut.inspect().find(button: "Start").tap(), окончательный снимок — это не вид загрузки, а вид ожидания. Почему?

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

Давайте посмотрим, что произойдет, если вместо обработки состояния через обёртку свойства @State у нас будет модель представления через @StateObject.

Давайте сначала создадим модель представления, извлекая всю логику из представления.

@MainActor
final class TodoListViewModel: ObservableObject {
    @Published private(set) var state: ListViewState = .idle {
        didSet {
            guard case .loaded(let todos) = state else {
                return
            }

            databaseManager.save(data: todos)
        }
    }

    private let databaseManager: DatabaseManagerProtocol
    private let loadTodos: () async throws -> [Todo]

    private var tasks: [Task<Void, Never>] = .init()

    deinit {
        for task in tasks {
            task.cancel()
        }
    }

    init(
        databaseManager: DatabaseManagerProtocol,
        loadTodos: @escaping () async throws -> [Todo] = loadTodos
    ) {
        self.databaseManager = databaseManager
        self.loadTodos = loadTodos
    }

    enum Message {
        case startButtonTapped
        case tryAgainButtonTapped
    }

    func send(_ message: Message) {
        switch message {
        case .startButtonTapped, .tryAgainButtonTapped:
            tasks.append(
                Task {
                    await refreshTodos()
                }
            )
        }
    }

    private func refreshTodos() async {
        state = .loading
        do {
            let todos = try await loadTodos().sorted { $0.title < $1.title }
            state = .loaded(todos)
        } catch {
            state = .error
        }
    }

    static func loadTodos() async throws -> [Todo] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Todo].self, from: data)
    }
}

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

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

struct TodoListView: View {
    @StateObject private var viewModel: TodoListViewModel

    init(
        databaseManager: DatabaseManagerProtocol,
        loadTodos: @escaping () async throws -> [Todo] = TodoListViewModel.loadTodos
    ) {
        _viewModel = .init(wrappedValue: .init(databaseManager: databaseManager, loadTodos: loadTodos))
    }

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle:
                Button("Start") {
                    viewModel.send(.startButtonTapped)
                }

            case .loading:
                Text("Loading…")

            case .error:
                VStack {
                    Text("Oops")
                    Button("Try again") {
                        viewModel.send(.tryAgainButtonTapped)
                    }
                }

            case .loaded(let todos):
                VStack {
                    List(todos) {
                        Text("\($0.title)")
                    }
                }
            }
        }
    }
}

Поскольку API просмотра не изменился, тест выглядит так же:

func testLoading() throws {
    // Given
    let databaseSpy = DatabaseManagerSpy()
    let sut = TodoListView(databaseManager: databaseSpy) {
        try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
        fatalError("Should never happen")
    }

    // When
    try sut.inspect().find(button: "Start").tap()

    // Then
    assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
    XCTAssertFalse(databaseSpy.called)
}

Как и следовало ожидать, тест по-прежнему терпит неудачу, но наличие модели представления дает нам гораздо больше информации, чем раньше.

try sut.inspect().find(button: "Start").tap() вызывает три инициализации модели представления. Затем assertSnapshot вызывает окончательную инициализацию модели представления. В целом, хотя представление инициализируется только один раз, его базовая модель представления инициализируется четыре раза. По какой-то причине модель представления неправильно сохраняется базовым представлением при выполнении тестов. Он уничтожается и воссоздается при многократном доступе. Фактически, мы можем видеть следующие предупреждения во время выполнения…

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

struct TodoListView: View {
    @StateObject private var viewModel: TodoListViewModel

    init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
        _viewModel = .init(wrappedValue: viewModel())
    }

    …
}

Теперь тест выглядит так.

func testLoading() throws {
    // Given
    let databaseSpy = DatabaseManagerSpy()
    let viewModel = TodoListViewModel(databaseManager: databaseSpy) {
        try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
        fatalError("Should never happen")
    }
    let sut = TodoListView(viewModel: viewModel)

    // When
    try sut.inspect().find(button: "Start").tap()

    // Then
    assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
    XCTAssertFalse(databaseSpy.called)
}

Мы по-прежнему получаем ошибку Accessing StateObject’s object without being installed on a View. This will create a new instance each time., но модель представления инициализируется только один раз, как и ожидалось, поэтому все работает правильно. Чтобы избежать вышеупомянутого предупреждения во время выполнения, мы можем отправлять сообщения непосредственно в модель представления, чтобы «имитировать» взаимодействие с пользовательским интерфейсом и удалить использование ViewInspector (что мне кажется хаком…).

Наличие модели представления в тесте также может быть удобно для подтверждения ее состояния, если это необходимо.

assertSnapshot(
    matching: viewModel.state,
    as: .dump
)

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

Окончательный набор тестов выглядит так.

@MainActor
final class TodoListViewUnitTests: XCTestCase {
    func testIdle() {
        // Given
        let (viewModel, _) = givenTodoListViewModel {
            fatalError("Should never happen")
        }
        let sut = TodoListView(viewModel: viewModel)

        // Then
        assertSnapshot(matching: sut, as: .image)
        assertSnapshot(matching: viewModel.state, as: .json)
    }

    func testLoading() async throws {
        // Given
        let (viewModel, todosSaved) = givenTodoListViewModel {
            try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
            fatalError("Should never happen")
        }
        let sut = TodoListView(viewModel: viewModel)

        // When
        viewModel.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut, as: .image)
        assertSnapshot(matching: viewModel.state, as: .json)
        XCTAssertFalse(todosSaved())
    }

    func testLoaded() async throws {
        // Given
        let (viewModel, todosSaved) = givenTodoListViewModel {
            // Load unsorted todos so we can verify that they are properly sorted in the view.
            Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        }
        let sut = TodoListView(viewModel: viewModel)

        // When
        viewModel.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut, as: .image)
        assertSnapshot(matching: viewModel.state, as: .json)
        XCTAssertTrue(todosSaved())
    }

    func testError() async throws {
        // Given
        let (viewModel, todosSaved) = givenTodoListViewModel {
            struct SomeError: Error {}
            throw SomeError()
        }
        let sut = TodoListView(viewModel: viewModel)

        // When
        viewModel.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut, as: .image)
        assertSnapshot(matching: viewModel.state, as: .json)
        XCTAssertFalse(todosSaved())
    }
}

private class DatabaseManagerSpy: DatabaseManagerProtocol {
    var called: Bool = false

    func save<T>(data: T) where T: Encodable {
        called = true
    }
}

@MainActor private func givenTodoListViewModel(loadTodos: @escaping () async throws -> [Todo]) -> (
    viewModel: TodoListViewModel,
    todosSaved: () -> Bool
) {
    let databaseSpy = DatabaseManagerSpy()
    let viewModel = TodoListViewModel(databaseManager: databaseSpy, loadTodos: loadTodos)
    return (viewModel, { databaseSpy.called })
}

Итак, мы перешли от 12 секунд к 0,11 секунды. Тем не менее, 110 мс всего для четырех модульных тестов можно считать большим числом, особенно когда у нас большая команда со многими разработчиками и экранами, где эти числа начинают быстро складываться, заканчиваясь набором тестов, для запуска которого требуется несколько минут. .

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

Тестирование макета представления и модели представления отдельно

Тестирование макета представления и модели представления по отдельности означает, что наш основной набор тестов может запускать только наши модели представления, при этом наша самая важная бизнес-логика будет работать очень быстро, в то время как тесты макета представления, которые выполняются медленнее, могут выполняться в другом target, в другом темпе, может только нашим CI и т.д. Они бы не интегрировались в процесс разработки (в нашем непрерывном CMD+U при изменении кода). В некотором смысле они были бы больше похожи на «тесты пользовательского интерфейса». Даже если они работают намного быстрее, чем тесты пользовательского интерфейса, они все равно работают медленнее.

Тесты модели представления выглядят так:

@MainActor
final class TodoListViewModelUnitTests: XCTestCase {
    func testIdle() {
        // Given
        let (sut, _) = givenTodoListViewModel {
            fatalError("Should never happen")
        }

        // Then
        assertSnapshot(matching: sut.state, as: .json)
    }

    func testLoading() async throws {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
            fatalError("Should never happen")
        }

        // When
        sut.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertFalse(todosSaved())
    }

    func testLoaded() async throws {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            // Load unsorted todos so we can verify that they are properly sorted in the view.
            Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        }

        // When
        sut.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertTrue(todosSaved())
    }

    func testError() async throws {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            struct SomeError: Error {}
            throw SomeError()
        }

        // When
        sut.send(.startButtonTapped)
        try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertFalse(todosSaved())
    }
}

Удаление скриншота представления значительно сократило время. От 110 мс до 20 мс.

Однако эти тесты все еще не идеальны. У нас есть эти Task.sleep из-за лежащей в основе асинхронности модели представления, которая в конечном итоге может привести к ненадежным тестам. У нас есть разные способы избежать этого:

1. Делаем API модели представления асинхронным

Наличие асинхронного метода send приведет к изменениям в API модели представления, нарушая представление. Кроме того, теперь нам нужно управлять жизненным циклом Task внутри слоя представления, если нам нужно, например, отменить его.

@MainActor
final class TodoListViewModel: ObservableObject {
    func send(_ message: Message) async {
        switch message {
        case .startButtonTapped, .tryAgainButtonTapped:
            await refreshTodos()
        }
    } 
    …
}

struct TodoListView: View {
    @StateObject private var viewModel: TodoListViewModel

    init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
        _viewModel = .init(wrappedValue: viewModel())
    }

    var body: some View {
        Group {
            switch viewModel.state {
            case .idle:
                Button("Start") {
                    Task {
                        await viewModel.send(.startButtonTapped)
                    }
                }
     …
    }
    …
}

Но тесты будут проще, без ожиданий.

func testLoaded() async {
    // Given
    let (sut, todosSaved) = givenTodoListViewModel {
        // Load unsorted todos so we can verify that they are properly sorted in the view.
        Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
    }

    // When
    await sut.send(.startButtonTapped)

    // Then
    assertSnapshot(matching: sut.state, as: .json)
    XCTAssertTrue(todosSaved())
}

К сожалению, не все тесты будут такими простыми. Загрузочный тест приведет к бесконечному ожиданию.

func testLoading() async {
    // Given
    let (sut, todosSaved) = givenTodoListViewModel {
        try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
        fatalError("Should never happen")
    }

    // When
    await viewModel.send(.startButtonTapped) // we wait forever here

    // Then
    assertSnapshot(
        matching: sut.state,
        as: .json
    )
    XCTAssertFalse(todosSaved())
}

Кроме того, я не уверен, что это правильный API. Похоже, мы раскрываем send метод async только из-за тестов… 🤔.

2. Использование ожиданий

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

@MainActor
final class TodoListViewModelTests: XCTestCase {
    func testLoading() async {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
            fatalError("Should never happen")
        }

        // When
        let expectation = expectation(description: "waiting for state")
        var cancellables = Set<AnyCancellable>()
        sut.$state.sink { state in
            guard case .loading = state else { return }
            expectation.fulfill()
        }.store(in: &cancellables)

        sut.send(.startButtonTapped)

        await fulfillment(of: [expectation], timeout: 5)

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertFalse(todosSaved())
    }

    func testLoaded() async throws {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            // Load unsorted todos so we can verify that they are properly sorted in the view.
            Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        }

        // When
        let expectation = expectation(description: "waiting for state")
        var cancellables = Set<AnyCancellable>()
        sut.$state.sink { state in
            guard case .loaded = state else { return }
            expectation.fulfill()
        }.store(in: &cancellables)

        sut.send(.startButtonTapped)

        await fulfillment(of: [expectation], timeout: 5)

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertTrue(todosSaved())
    }

    func testError() async throws {
        // Given
        let (sut, todosSaved) = givenTodoListViewModel {
            struct SomeError: Error {}
            throw SomeError()
        }

        // When
        let expectation = expectation(description: "waiting for state")
        var cancellables = Set<AnyCancellable>()
        sut.$state.sink { state in
            guard case .error = state else { return }
            expectation.fulfill()
        }.store(in: &cancellables)

        sut.send(.startButtonTapped)

        await fulfillment(of: [expectation], timeout: 5)

        // Then
        assertSnapshot(matching: sut.state, as: .json)
        XCTAssertFalse(todosSaved())
    }
}

3. Отделение логики от эффектов

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

enum ListViewState: Equatable, Codable {
    case idle
    case loading
    case loaded([Todo])
    case error

    enum Message {
        case input(Input)
        case feedback(Feedback)

        enum Input {
            case startButtonTapped
            case tryAgainButtonTapped
        }

        enum Feedback {
            case didFinishReceivingTodos(Result<[Todo], Error>)
        }
    }

    enum Effect: Equatable {
        case loadTodos
        case saveTodos([Todo])
    }

    mutating func handle(message: Message) -> Effect? {
        switch message {
        case .input(.startButtonTapped), .input(.tryAgainButtonTapped):
            self = .loading
            return .loadTodos

        case .feedback(.didFinishReceivingTodos(.success(let todos))):
            self = .loaded(todos.sorted { $0.title < $1.title })
            return .saveTodos(todos)

        case .feedback(.didFinishReceivingTodos(.failure)):
            self = .error
            return nil
        }
    }
}

Модель представления, которая после извлечения всей логики становится скромным объектом, теперь выглядит так (просто пересылка сообщений в тип состояния и интерпретация эффектов).

@MainActor
final class TodoListViewModel: ObservableObject {
    @Published private(set) var state: ListViewState = .idle

    private let databaseManager: DatabaseManagerProtocol
    private let loadTodos: () async throws -> [Todo]

    private var tasks: [Task<Void, Never>] = .init()

    deinit {
        for task in tasks {
            task.cancel()
        }
    }

    init(
        databaseManager: DatabaseManagerProtocol,
        loadTodos: @escaping () async throws -> [Todo] = loadTodos
    ) {
        self.databaseManager = databaseManager
        self.loadTodos = loadTodos
    }

    func send(_ message: ListViewState.Message.Input) {
        send(.input(message))
    }

    private func send(_ message: ListViewState.Message) {
        guard let effect = state.handle(message: message) else { return }
        tasks.append(
            Task {
                await perform(effect: effect)
            }
        )
    }

    private func perform(effect: ListViewState.Effect) async {
        switch effect {
        case .loadTodos:
            do {
                let todos = try await loadTodos()
                send(.feedback(.didFinishReceivingTodos(.success(todos))))
            } catch {
                send(.feedback(.didFinishReceivingTodos(.failure(error))))
            }

        case .saveTodos(let todos):
            databaseManager.save(data: todos)
        }
    }

    private static func loadTodos() async throws -> [Todo] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Todo].self, from: data)
    }
}

Тесты теперь проще, чем когда-либо.

@MainActor
final class TodoListViewModelUnitTests: XCTestCase {
    func testLoading() {
        // Given
        var sut = ListViewState.idle

        // When
        let effect = sut.handle(message: .input(.startButtonTapped))

        // Then
        assertSnapshot(matching: (sut, effect), as: .dump)
    }

    func testLoaded() {
        // Given
        var sut = ListViewState.loading

        // When
        let todos: [Todo] = Set(0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        let effect = sut.handle(message: .feedback(.didFinishReceivingTodos(.success(todos))))

        // Then
        let expectedTodos = todos.sorted { $0.title < $1.title }
        XCTAssertEqual(effect, .saveTodos(expectedTodos))
        XCTAssertEqual(sut, .loaded(expectedTodos))
    }

    func testError() async throws {
        // Given
        var sut = ListViewState.loading

        // When
        struct SomeError: Error {}
        let effect = sut.handle(message: .feedback(.didFinishReceivingTodos(.failure(SomeError()))))

        // Then
        XCTAssertNil(effect)
        XCTAssertEqual(sut, .error)
    }
}

Как вы можете видеть, у нас могут быть обычные утверждения XCTAssertEqual, или мы можем утверждать моментальный снимок кортежа (state, effect) (что может быть очень удобно, когда построение конечного значения утверждения громоздко). Учтите, что assertSnapshot на несколько порядков медленнее, чем XCTAssertEqual, поэтому используйте его с осторожностью.

Также мы сократили время до 10 мс. У нас нет ожиданий, все детерминировано, и мы тестируем логику представления, которая имеет самый высокий ROI в модульном тестировании.

Модульное тестирование состояния таким образом позволяет нам очень легко тестировать все крайние случаи. Затем мы можем провести несколько тестов «более высокого уровня» модели представления со всеми ее зависимостями, проверяя только несколько удачных случаев, чтобы перепроверить, что все вместе работает правильно.

В своей книге Владимир Хориков говорит, что самые важные части нашего кода, имеющие наибольшую доменную значимость, не должны иметь зависимостей. Именно это мы и сделали, выделив логику в тип ListViewState.

Примечание о рефакторинге модели представления

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

  • Ввод: сообщения/события, отправленные из представления.
  • Вывод: состояние (отображаемое представлением) и наблюдаемое поведение.

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

Давайте представим, что мы хотим провести рефакторинг модели представления, выделив некоторую часть функциональности, например алгоритм сортировки, в отдельный компонент, такой как вариант использования. Это совершенно разумно. Что может быть не очень хорошей идеей, так это проверить, что модель представления отправляет определенные сообщения этому варианту использования. Вариант использования — это просто деталь реализации того, как модель представления решила достичь своей бизнес-цели. Мы можем решить разделить все обязанности модели представления на нескольких соавторов, которых следует тестировать отдельно. Но при тестировании модели представления эти соавторы (стабильные зависимости) следует рассматривать как детали реализации. Если в будущем мы решим, что вариант использования должен обращаться к репозиторию для доступа к данным, это не должно нарушить тест, потому что входы и выходы «черного ящика модели представления» одинаковы. Мы по-прежнему должны утверждать:

  • Окончательное состояние (через общедоступный API).
  • Наблюдаемое поведение: задачи сохранены в базе данных или нет (через базу данных mock/spy).

Тестирование макета представления

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

1. Представления контейнера/представления

Именно Дэн Абрамов впервые популяризировал этот паттерн в React еще в 2015 году с этой статьей. Это довольно просто. Мы должны разделить наши обычные представления SwiftUI на две части:

  • Контейнерное представление: выполняет логику и побочные эффекты и взаимодействует с представлением презентации для целей отображения.
  • Представление в виде презентации: отображает данные и уведомляет о событиях просмотра.

В нашем примере это было бы так же просто, как создать внутренний тип Presentation. Ввод будет ListViewState, а вывод — сообщения, отправленные из представления.

struct TodoListView: View {
    @StateObject private var viewModel: TodoListViewModel

    init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
        _viewModel = .init(wrappedValue: viewModel())
    }

    var body: some View {
        Presentation(input: viewModel.state, output: viewModel.send)
    }

    struct Presentation: View {
        var input: ListViewState
        var output: (ListViewState.Message.Input) -> Void

        var body: some View {
            Group {
                switch input {
                case .idle:
                    Button("Start") {
                        output(.startButtonTapped)
                    }

                case .loading:
                    Text("Loading…")

                case .error:
                    VStack {
                        Text("Oops")
                        Button("Try again") {
                            output(.tryAgainButtonTapped)
                        }
                    }

                case .loaded(let todos):
                    VStack {
                        List(todos) {
                            Text("\($0.title)")
                        }
                    }
                }
            }
        }
    }
}

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

Тесты выглядят так.

final class TodoListViewTests: XCTestCase {
    func testIdle() {
        // Given
        let sut = TodoListView.Presentation(input: .idle) { _ in }

        // Then
        assertSnapshot(matching: sut, as: .image)
    }

    func testLoading() {
        // Given
        let sut = TodoListView.Presentation(input: .loading) { _ in }

        // Then
        assertSnapshot(matching: sut, as: .image)
    }

    func testLoaded() {
        // Given
        let todos: [Todo] = (0...9).map { .init(userId: $0, id: $0, title: "\($0)", completed: false) }
        let sut = TodoListView.Presentation(input: .loaded(todos)) { _ in }

        // Then
        assertSnapshot(matching: sut, as: .image)
    }

    func testError() {
        // Given
        let sut = TodoListView.Presentation(input: .error) { _ in }

        // Then
        assertSnapshot(matching: sut, as: .image)
    }
}

Мы даже можем комбинировать тесты состояния с тестами макета представления.

func testLoading() {
    // Given (a initial state)
    var state = ListViewState.idle

    // When (the user taps the start button)
    let effect = state.handle(message: .input(.startButtonTapped))

    // Then (both state and view layout are in the correct loading state)
    assertSnapshot(
        matching: (state, effect),
        as: .dump
    )
    assertSnapshot(
        matching: TodoListView.Presentation(input: state) { _ in },
        as: .image
    )
}

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

2. Замороженные модели просмотра

Потому что иногда мы не хотим создавать этот тип Presentation... Для этого нам нужно внедрить фиктивную модель представления, которая ничего не делает и может быть создана с определенным начальным состоянием. Это важно, потому что жизненный цикл представления может вызывать методы модели представления под капотом. Мы всегда можем заморозить модель представления, создав абстракцию модели представления ad-hoc с неоперативной реализацией, но это намного проще и эргономичнее (без повреждения дизайна, вызванного тестированием) при использовании архитектур, где мы полностью контролировать побочные эффекты, подобные описанному здесь.

Создать «фиктивный редуктор», который ничего не делает, очень просто.

extension ViewModel {
    static func freeze(with state: State) -> ViewModel<State, Input, Feedback, Output> {
        .init(state: state, reducer: DummyReducer())
    }
}

private class DummyReducer<State, Input, Feedback, Output>: Reducer where State: Equatable {
    func reduce(
        message: Message<Input, Feedback>,
        into state: inout State
    ) -> Effect<Feedback, Output> {
        .none
    }
}

А тест выглядит так.

func testLoading() {
    // Given
    let sut = TodoListView(viewModel: .freeze(state: .loading))

    // Then
    assertSnapshot(
        matching: sut,
        as: .image
    )
}

Заключение

Большое спасибо, что дошли до конца статьи. Это было еще одно длинное чтение 😅.

Мы видели разные способы тестирования нашего слоя представления, как логики представления, так и макета.

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

  • Должны ли мы тестировать представление и модель представления вместе и проверять только макет представления?
  • Должны ли мы тестировать модель представления и макет представления отдельно?
  • Должны ли мы переместить логику представления за пределы модели представления, на уровень значений без зависимостей, где ее можно легко протестировать?

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

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

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

Мне действительно интересно узнать, что вы думаете. У вас есть твердое мнение о том, как мы должны тестировать наш слой просмотра? Дай мне знать в комментариях.

Спасибо!