Одной из замечательных особенностей разработки в 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.
Как работает SnapshotTesting
Библиотека SnapshotTesting позволяет разработчикам писать тестовые утверждения о внешнем виде их вью. Утверждая(*заявляя), что вью соответствует эталонным изображениям на диске, разработчики могут быть уверены, что со временем вью не изменятся неожиданным образом.
Пример кода на рис. 2 сравнивает короткую и длинную версии MessageView с эталонными изображениями, хранящимися на диске как testSnapshots.1 и testSnapshots.2 соответственно. Первоначально снапшоты были записаны с помощью SnapshotTesting и автоматически названы по имени тестовой функции и позиции утверждения внутри функции.
Проблема совместного использования 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.
PreviewSnapshots обеспечивает некоторую дополнительную структуру для небольшого вью, построить которое несложно, но мало что делает для сокращения количества строк кода в превью. Основное преимущество небольших вью проявляется, когда приходит время писать тесты снапшотов для превью.
final class NameList_SnapshotTests: XCTestCase {
  func test_snapshot() {
    NameList_Previews.snapshots.assertSnapshots()
  }
}Это единственное утверждение выполнит снапшот‑тест каждой конфигурации в PreviewSnapshots. На рис. 4 показан код примера вместе с эталонными изображениями в Xcode. Кроме того, если в превью будут добавлены какие‑либо новые конфигурации, к ним автоматически будет применен снапшот‑тест без изменения тестового кода.
Для более сложных вью с большим количеством аргументов — еще больше преимуществ.
Использование 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.
После того, как мы определили набор 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.
 
          