Как ускорить разработку и тестирование в SwiftUI с помощью PreviewSnapshots

26 января 2023

Одной из замечательных особенностей разработки в SwiftUI является Xcode Previews, которые обеспечивают быструю UI-итерацию путем визуализации изменений кода в режиме реального времени наряду с кодом SwiftUI. В DoorDash мы активно используем Xcode Previews вместе с библиотекой SnapshotTesting от Point-Free, чтобы убедиться, что экраны выглядят так, как мы ожидаем, при их разработке, и гарантировать, что они не изменятся неожиданным образом с течением времени. SnapshotTesting можно использовать для захвата визуализированного изображения VIEW и создания XCTest - сбоя, если новое изображение не соответствует эталонному изображению на диске. Xcode Previews в сочетании с SnapshotTesting можно использовать для обеспечения быстрых итераций, при этом гарантируя, что вью продолжают выглядеть так, как они задуманы, не опасаясь неожиданных изменений.

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

 

Как работают Xcode Previews

Xcode Previews позволяют разработчикам возвращать одну или несколько версий View из PreviewProvider, а Xcode визуализирует “живую” версию View вместе с кодом реализации.

Начиная с Xcode 14 вью с несколькими превью представлены в виде выбираемых вкладок в верхней части окна превью, как показано на рисунке 1.

Рисунок 1: Редактор Xcode, показывающий код SwiftUI View для отображения простого сообщения вместе с холстом Xcode Preview, отображающим две версии этого представления.  Один с коротким сообщением и один с длинным сообщением.
Рисунок 1: Редактор Xcode, показывающий код SwiftUI View для отображения простого сообщения, и окно Xcode Preview, отображающее две версии этого вью. Одна с коротким сообщением и одна с длинным сообщением.


Как работает SnapshotTesting

Библиотека SnapshotTesting позволяет разработчикам писать тестовые утверждения о внешнем виде их вью. Утверждая(*заявляя), что вью соответствует эталонным изображениям на диске, разработчики могут быть уверены, что со временем вью не изменятся неожиданным образом.

Пример кода на рис. 2 сравнивает короткую и длинную версии MessageView с эталонными изображениями, хранящимися на диске как testSnapshots.1 и testSnapshots.2 соответственно. Первоначально снапшоты были записаны с помощью SnapshotTesting и автоматически названы по имени тестовой функции и позиции утверждения внутри функции.

Рис. 2. Редактор Xcode, показывающий код SwiftUI View с использованием PreviewSnapshots для создания предварительных просмотров Xcode для четырех различных состояний ввода, а также холст Xcode Preview, отображающий представление с использованием каждого из этих состояний.Рис. 2. Редактор Xcode, показывающий код SwiftUI View, использующий PreviewSnapshots для создания Xcode Previews для четырех различных состояний ввода, а также окно Xcode Preview, визуализирующее(* отрисовывающее) вью с использованием каждого из этих состояний.

 

Проблема совместного использования Xcode Previews и SnapshotTesting

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

 

Представляем PreviewSnapshots

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

 

Использование PreviewSnapshots для простого вью

Допустим, у нас есть вью, которое принимает список имен и отображает их каким-то интересным нам способом.

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

struct NameList_Previews: PreviewProvider {
  static var previews: some View {
    NameList(names: [])
      .previewDisplayName("Empty")
      .previewLayout(.sizeThatFits)

    NameList(names: [“Alice”])
      .previewDisplayName("Single Name")
      .previewLayout(.sizeThatFits)

    NameList(names: [“Alice”, “Bob”, “Charlie”])
      .previewDisplayName("Short List")
      .previewLayout(.sizeThatFits)

    NameList(names: [
      “Alice”,
      “Bob”,
      “Charlie”,
      “David”,
      “Erin”,
      //...
    ])
    .previewDisplayName("Long List")
    .previewLayout(.sizeThatFits)
  }
}


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

final class NameList_SnapshotTests: XCTestCase {
  func test_snapshotEmpty() {
    let view = NameList(names: [])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotSingleName() {
    let view = NameList(names: [“Alice”])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotShortList() {
    let view = NameList(names: [“Alice”, “Bob”, “Charlie”])
    assertSnapshot(matching: view, as: .image)
  }

  func test_snapshotLongList() {
    let view = NameList(names: [
      “Alice”,
      “Bob”,
      “Charlie”,
      “David”,
      “Erin”,
      //...
    ])
    assertSnapshot(matching: view, as: .image)
  }
}


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

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

Так выглядит Xcode превью с использованием PreviewSnapshots:

struct NameList_Previews: PreviewProvider {
  static var previews: some View {
    snapshots.previews.previewLayout(.sizeThatFits)
  }

  static var snapshots: PreviewSnapshots<[String]> {
    PreviewSnapshots(
      configurations: [
        .init(name: "Empty", state: []),
        .init(name: "Single Name", state: [“Alice”]),
        .init(name: "Short List", state: [“Alice”, “Bob”, “Charlie”]),
        .init(name: "Long List", state: [
          “Alice”,
          “Bob”,
          “Charlie”,
          “David”,
          “Erin”,
          //...
        ]),
      ],
      configure: { names in NameList(names: names) }
    )
  }
}


Чтобы создать коллекцию PreviewSnapshots, мы создаем экземпляр PreviewSnapshots с массивом конфигураций вместе с функцией configure для настройки вью для данной конфигурации. Конфигурация состоит из имени и экземпляра State, которое будет использоваться для настройки представления. В этом случае тип состояния для массива имен будет [String].

Для создания превью мы возвращаем snapshots.previews из стандартного статического свойства превью, как показано на рис. 3. snapshots.previews создаст превью с правильным именем для каждой конфигурации PreviewSnapshots.

Рис. 3. Редактор Xcode, показывающий код SwiftUI View с использованием PreviewSnapshots для создания предварительных просмотров Xcode для четырех различных состояний ввода вместе с холстом Xcode Preview, отображающим представление с использованием каждого из этих состояний.
Рис. 3. Редактор Xcode, показывающий код SwiftUI View с использованием PreviewSnapshots для генерации  Xcode Previews для четырех различных состояний ввода вместе с окном Xcode Preview, отображающим вью с использованием каждого из этих состояний.

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

final class NameList_SnapshotTests: XCTestCase {
  func test_snapshot() {
    NameList_Previews.snapshots.assertSnapshots()
  }
}


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

Рисунок 4: Модульный тест Xcode с использованием PreviewSnapshots для проверки четырех различных состояний ввода, определенных выше, с одним вызовом `assertSnapshots`Рисунок 4: Модульный тест Xcode с использованием PreviewSnapshots для проверки четырех различных состояний ввода, определенных выше, с помощью одного вызова assertSnapshots

Для более сложных вью с большим количеством аргументов - еще больше преимуществ.

 

Использование PreviewSnapshots для более сложного вью

Во втором примере мы рассмотрим FormView, который принимает несколько Bindings, опциональное сообщение об ошибке и замыкание действия в качестве аргументов в своем инициализаторе. Этот пример покажет возросшие преимущества PreviewSnapshots в ситуации, когда увеличивается сложность построения вью.

struct FormView: View {
  init(
    firstName: Binding<String>,
    lastName: Binding<String>,
    email: Binding<String>,
    errorMessage: String?,
    submitTapped: @escaping () -> Void
  ) { ... }

  // ...
}

 

Поскольку PreviewSnapshots является дженериком для состояния ввода, мы можем объединить различные входные параметры в небольшую вспомогательную структуру для передачи в блок configure, и лишь один раз нужно будет cоставить FormView. В качестве дополнительного удобства PreviewSnapshots предоставляет протокол NamedPreviewState для упрощения создания конфигураций входа путем группировки имени превью вместе(*в соответствии) с состоянием превью.

struct FormView_Previews: PreviewProvider {
  static var previews: some View {
    snapshots.previews
  }

  static var snapshots: PreviewSnapshots<PreviewState> {
    PreviewSnapshots(
      states: [
        .init(name: "Empty"),
        .init(
          name: "Filled",
          firstName: "John", lastName: "Doe", email: "john.doe@doordash.com"
        ),
        .init(
          name: "Error",
          firstName: "John", lastName: "Doe", errorMessage: "Email Address is required"
        ),
      ],
      configure: { state in
        NavigationView {
          FormView(
            firstName: .constant(state.firstName),
            lastName: .constant(state.lastName),
            email: .constant(state.email),
            errorMessage: state.errorMessage,
            submitTapped: {}
          )
        }
      }
    )
  }
  
  struct PreviewState: NamedPreviewState {
    let name: String
    var firstName: String = ""
    var lastName: String = ""
    var email: String = ""
    var errorMessage: String?
  }
}


В коде примера мы создали структуру PreviewState, соответствующую по форме NamedPreviewState и содержащую имя превью наряду с именем, фамилией, адресом электронной почты и опциональным сообщением об ошибке для создания вью. Затем, на основе переданного состояния конфигурации, в блоке configure мы создаем один экземпляр FormView. Возвращая snapshots.preview из PreviewProvider.previews,  PreviewSnapshots будет перебирать входные состояния и создаст превью Xcode с надлежащим именем для каждого состояния, как показано на рисунке 5.

Рисунок 5: Редактор Xcode, показывающий код SwiftUI View с использованием PreviewSnapshots для создания предварительных просмотров Xcode для трех различных состояний ввода вместе с холстом Xcode Preview, визуализирующим представление с использованием каждого из этих состояний.
Рисунок 5: Редактор Xcode, показывающий код SwiftUI View с использованием PreviewSnapshots для создания превью Xcode для трех различных состояний ввода вместе с окном Xcode Preview, визуализирующим вью с использованием каждого из этих состояний.

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

final class FormView_SnapshotTests: XCTestCase {
  func test_snapshot() {
    FormView_Previews.snapshots.assertSnapshots()
  }
}


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


Заключение

В этой статье обсуждались определенные преимущества использования Xcode Previews и SnapshotTesting при разработке с помощью SwiftUI. Он также продемонстрировал некоторые болевые точки и дублирование кода, которые могут возникнуть в результате совместного использования этих двух технологий, и то, как PreviewSnapshots позволяет разработчикам сэкономить время, повторно используя усилия, которые они вложили в написание превью Xcode для тестирования снапшотов.

Инструкции по включению PreviewSnapshots в ваш проект, а также пример приложения, использующего PreviewSnapshots, доступны на GitHub.
 

Содержание