Привет, Хабр! Меня зовут Дима, я iOS-разработчик в компании Doubletapp, и в прошлом году я вместе со своими коллегами и командой Яндекса участвовал в разработке приложения Яндекс Путешествия. В этом проекте мы выбрали фреймворком пользовательского интерфейса SwiftUI (подробнее о том, как мы его выбрали и что из этого получилось, рассказала наша iOS-Head Полина Скалкина здесь).

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

Что такое snapshot-тесты?

Это вид тестов, сравнивающий некоторое представление объекта с эталонным представлением. В нашем случае объект тестирования — это вью, а представление — скриншот вью. Алгоритм простой: сначала создаётся скриншот-эталон и записывается на диск, после чего при очередном запуске тестов генерируется новый скриншот и сравнивается с сохранённым. Если скриншоты отличаются или эталон не найден, то тест не пройдёт.

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

Скриншоты можно хранить в репозитории вместе с кодом приложения. Тогда в pull request будет попадать не только код вью, но и её snapshot’ы. Благодаря этому ревьюер может посмотреть, как выглядит вью в разных конфигурациях. Кроме этого, в GitHub, GitLab и других сервисах есть встроенные инструменты сравнения картинок, которые помогут проверить изменения в скриншотах.

Инструменты сравнения картинок в GitLab
Инструменты сравнения картинок в GitLab

Для snapshot-тестирования мы выбрали библиотеку SnapshotTesting. Тесты в ней аналогичны привычным unit-тестам:

import SnapshotTesting
import XCTest

final class ExampleViewTests: XCTestCase {

   func testExampleView() {
       assertSnapshot(matching: ExampleView(), as: .image)
   }
}

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

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

Также может быть разница в скриншотах, генерируемых компьютерами на процессорах Intel и Apple Silicon. Это известная проблема, и пока что полностью её исправить не удаётся. Мы обходили это уменьшением параметров precision и perceptualPrecision, отвечающих за проверяемую степень сходства изображений. Если вам известны другие способы, буду рад узнать о них из комментариев.

Объединение snapshot-тестов и превью

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

Нашу реализацию этой инфраструктуры можно разделить на две части: превью и snapshot-тесты. Соединяет эти части протокол Testable. Его роль — предоставить набор вариаций вью при разных состояниях модели (массив samples). Для этого определён associatedtype Sample, позволяющий минимизировать использование AnyView. Кроме того, в определении протокола можно заметить associatedtype Modifier, но о нём расскажу чуть позже.

import SwiftUI

public protocol Testable {
   associatedtype Sample: View
   associatedtype Modifier: PreviewModifier = IdentityPreviewModifier
  
   static var samples: [Sample] { get }
}

Если говорить про часть превью, то протокол Testable реализуется вместе с PreviewProvider и в некоторой степени заменяет его, поэтому для их связки задано следующее расширение:

extension PreviewProvider where Self: Testable {
   static var previews: some View {
       ForEach(samples, id: \.uuid) { $0 }
           .modifier(Modifier())
   }
}

private extension View {
   var uuid: UUID { UUID() }
}

Вот как выглядит определение превью в самом простом случае на примере экрана списка отелей:

struct HotelListView_Previews: PreviewProvider, Testable {
   static let samples = [
       HotelListView()
   ]
}

Вернёмся к associatedtype Modifier в протоколе Testable. Это модификатор, который применяется ко всем вью из массива samples. PreviewModifier, является расширением SwiftUI-протокола ViewModifier и определяет возможность инстанциировать его реализацию без знания её типа.

import SwiftUI

public protocol PreviewModifier: ViewModifier {
   init()
}

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

struct HotelView_Previews: PreviewProvider, Testable {
   static let samples = [
       HotelView(state: .moscowHotel)
   ]
  
   struct Modifier: PreviewModifier {
       func body(content: Content) -> some View {
           content
               .frame(maxHeight: .infinity)
               .background(Color.major)
       }
   }
}

Если же ничего модифицировать не нужно, то используется IdentityPreviewModifier, который оставляет вью без изменений (используется по умолчанию):

public struct IdentityPreviewModifier: PreviewModifier {
   public init() {}
  
   public func body(content: Content) -> some View { content }
}

На этом с частью превью всё, перейдем к части snapshot-тестов. Её две главные задачи — это интеграция с библиотекой SnapshotTesting и уменьшение дублирования кода при написании тестов. 

Как было сказано ранее, каждый скриншот — это вью при заданном состоянии модели, размере и цветовой схеме. Различные состояния модели уже хранятся в массиве samples (протокол Testable), поэтому мы определили отдельную структуру SnapshotEnvironment для хранения размера (layout) и цветовой схемы (traits):

import SnapshotTesting
import UIKit

struct SnapshotEnvironment {
   let layout: SwiftUISnapshotLayout
   let traits: UITraitCollection
   let descriptionComponents: [String]
}

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

Чтобы зафиксировать размеры экранов устройств, которые интересны нам для тестирования, и поддерживаемые цветовые схемы, определены перечисления Device и Theme:

extension SnapshotEnvironment {
   enum Device: String {
       case iPhone13, iPhone8, iPhoneSe
      
       fileprivate var viewImageConfig: ViewImageConfig {
           switch self {
           case .iPhone13: return .iPhone13
           case .iPhone8: return .iPhone8
           case .iPhoneSe: return .iPhoneSe
           }
       }
   }
  
   enum Theme: String, CaseIterable {
       case light, dark
      
       fileprivate var traitCollection: UITraitCollection {
           UITraitCollection(userInterfaceStyle: interfaceStyle)
       }
      
       private var interfaceStyle: UIUserInterfaceStyle {
           switch self {
           case .light: return .light
           case .dark: return .dark
           }
       }
   }
}

Эти перечисления, в свою очередь, используются в фабричных методах тестовых конфигураций:

extension SnapshotEnvironment {
   static func device(_ device: Device, theme: Theme) -> SnapshotEnvironment {
       SnapshotEnvironment(
           layout: .device(config: device.viewImageConfig),
           traits: theme.traitCollection,
           descriptionComponents: [device.rawValue, theme.rawValue]
       )
   }
  
   static func sizeThatFits(theme: Theme) -> SnapshotEnvironment {
       SnapshotEnvironment(
           layout: .sizeThatFits,
           traits: theme.traitCollection,
           descriptionComponents: ["sizeThatFits", theme.rawValue]
       )
   }
}

В зависимости от вью, может потребоваться определённый набор тестовых конфигураций (размер + цветовая схема). Используемые у нас наборы заданы кейсами перечисления SnapshotBatch:

enum SnapshotBatch {
   case regular, extended, component
  
   var snapshotEnvironments: [SnapshotEnvironment] {
       switch self {
       case .regular:
           return SnapshotEnvironment.Theme.allCases.map { .device(.iPhone13, theme: $0) }
       case .extended:
           return SnapshotEnvironment.Theme.allCases.map { .device(.iPhone13, theme: $0) } + [
               .device(.iPhone8, theme: .light),
               .device(.iPhoneSe, theme: .light)
           ]
       case .component:
           return SnapshotEnvironment.Theme.allCases.map(SnapshotEnvironment.sizeThatFits)
       }
   }
}

И, наконец, весь ранее описанный код используется в методе test, выполняющем snapshot-тестирование элементов массива samples протокола Testable. Имя файла скриншота автоматически формируется из названия тестовой функции, которое подставляется в параметр testName, и поля descriptionComponents структуры SnapshotEnvironment, которое я показывал ранее.

import SnapshotTesting

extension Testable {
   static func test(
       batch: SnapshotBatch,
       file: StaticString = #file,
       testName: StaticString = #function,
       line: UInt = #line
   ) {
       let name = testName.description.removingPrefix("test").removingSuffix("()")
       for sample in samples.map({ $0.modifier(Modifier()) }) {
           for environment in batch.snapshotEnvironments {
               assertSnapshot(
                   matching: sample,
                   as: .image(layout: environment.layout, traits: environment.traits),
                   file: file,
                   testName: assembleTestName(name, for: environment),
                   line: line
               )
           }
       }
   }
  
   private static func assembleTestName(_ testName: String, for environment: SnapshotEnvironment) -> String {
       ([testName] + environment.descriptionComponents).joined(separator: "-")
   }
}

Инфраструктура готова! Теперь для полного тестирования вью достаточно вызвать метод test и передать в него нужный SnapshotBatch. Например, в случае HotelListView snapshot-тестирование будет выглядеть так:

func testHotelListView() {
   HotelListView_Previews.test(batch: .extended)
}

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

  • HotelListView-iPhone13-light.1.png

  • HotelListView-iPhone13-dark.1.png

  • HotelListView-iPhone8-light.1.png

  • HotelListView-iPhoneSe-light.1.png

Цифра после точки в имени файла добавляется библиотекой автоматически и в нашем случае указывает на номер элемента в массиве samples.

Итог

Применение snapshot-тестов удовлетворило нашу потребность в контроле над изменениями, которые постоянно вносились во вью. Кроме того, хранящиеся в репозитории с кодом скриншоты экранов сделали процесс ревью UI-компонентов гораздо более удобным. Благодаря этому мы своевременно отлавливали ошибки верстки еще до вливания изменений в основную ветку проекта.

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

Узнать подробнее о долгосрочном опыте применения snapshot-тестов и результатах использования описанного инструмента в Яндекс Путешествиях можно на докладе от автора идеи, Николая Пучко, на Vertis Mobile Meetup 13 октября.

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