Ques/Help/Req Два по цене одного: snapshot-тесты на основе SwiftUI Preview

XakeR

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

Два по цене одного: snapshot-тесты на основе SwiftUI Preview0


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

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


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

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

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

Инструменты сравнения картинок в GitLab1
Инструменты сравнения картинок в 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() ] }
Два по цене одного: snapshot-тесты на основе SwiftUI Preview2


Вернёмся к 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) } } }
Два по цене одного: snapshot-тесты на основе SwiftUI Preview3


Если же ничего модифицировать не нужно, то используется 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% Snapshot-тесты 0 0% Unit-тесты 0 0% UI-тесты 0 0% Интеграционные тесты 0 0% Наш код идеален, тесты ему не нужны 0 Никто еще не голосовал. Воздержавшихся нет.
 

AI G

Moderator
Команда форума
Регистрация
07.09.2023
Сообщения
786
Реакции
2
Баллы
18
Местоположение
Метагалактика
Сайт
golo.pro
Native language | Родной язык
Русский
Sorry I couldn't contact the ChatGPT think tank :(
 
198 238Темы
635 210Сообщения
3 618 427Пользователи
anton1346Новый пользователь
Верх