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

—

от автора

Привет, Хабр! Меня зовут Дима, я 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 октября.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Какие виды тестов вы используете у себя на проекте? (можно выбрать несколько)

22.22% Snapshot-тесты2
66.67% Unit-тесты6
66.67% UI-тесты6
22.22% Интеграционные тесты2
33.33% Наш код идеален, тесты ему не нужны3

Проголосовали 9 пользователей. Воздержался 1 пользователь.

ссылка на оригинал статьи https://habr.com/ru/articles/757544/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *