Привет, Хабр! Меня зовут Дима, я iOS-разработчик в компании Doubletapp, и в прошлом году я вместе со своими коллегами и командой Яндекса участвовал в разработке приложения Яндекс Путешествия. В этом проекте мы выбрали фреймворком пользовательского интерфейса SwiftUI (подробнее о том, как мы его выбрали и что из этого получилось, рассказала наша iOS-Head Полина Скалкина здесь).
На начальном этапе реализации приложения мы постоянно вносили изменения во множество вью. Это были и обновления, вызванные переработкой дизайна, и исправление багов, и оптимизации, которые мы делали по мере роста наших знаний о SwiftUI. Нам хотелось контролировать все эти изменения, чтобы еще на этапе разработки отлавливать вызванные ими ошибки верстки. Поэтому наша команда приняла решение использовать snapshot-тесты.
Что такое snapshot-тесты?
Это вид тестов, сравнивающий некоторое представление объекта с эталонным представлением. В нашем случае объект тестирования — это вью, а представление — скриншот вью. Алгоритм простой: сначала создаётся скриншот-эталон и записывается на диск, после чего при очередном запуске тестов генерируется новый скриншот и сравнивается с сохранённым. Если скриншоты отличаются или эталон не найден, то тест не пройдёт.
Тесты такого типа дают нам возможность зафиксировать, как выглядит вью при разных состояниях модели (загрузка, данные, ошибка, авторизован/не авторизован и т.д.), цветовых схемах, размерах экрана. Множество вариаций snapshot’ов позволяет достичь необходимого нам контроля над изменениями вью.
Скриншоты можно хранить в репозитории вместе с кодом приложения. Тогда в pull request будет попадать не только код вью, но и её snapshot’ы. Благодаря этому ревьюер может посмотреть, как выглядит вью в разных конфигурациях. Кроме этого, в GitHub, 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 октября.