Как стать автором
Обновить
42.21
Рейтинг

Внедряем Snapshot testing в UI-тесты iOS

Блог компании Vivid Money Тестирование IT-систем *Разработка под iOS *Swift *Тестирование мобильных приложений *
Tutorial

Хабр, привет!

Меня зовут Борис. Я AQA iOS-engineer в Vivid Money.

В этой статье я хочу рассказать про snapshot тесты и показать, как их можно эффективно применять в своём проекте.

Данная статья будет полезна начинающим iOS-автоматизаторам, либо разработчикам, которые решили изучить XCUITest и покрыть свой проект ui-тестами.

В рамках статьи мы разберем:

  1. Что такое snapshot тесты и когда их применять.

  2. Какие есть библиотеки.

  3. Поэтапно расскажу и покажу как внедрить их у себя в проекте.

Что такое Snapshot тесты?

Snapshot тесты — это тесты, которые делают скриншот экрана (эталонный скриншот) и сравнивают с актуальным скриншотом, который делается во время прогона тестов. Делать Snapshot тесты на все возможные проверки — плохая практика, так как если будет редизайн, ваши тесты моментально станут красными, и вам придется в срочном порядке менять эталонные скриншоты. Я советую использовать Snapshot тесты для проверки верстки, элементов, с которыми тяжело взаимодействовать по accessibilityidentifier (график цен, календарь и прочие).

Какие есть библиотеки?

В основном, при написании Snapshot тестов используют две библиотеки:

  1. iOSSnapshotTestCase (previously FBSnapshotTestCase).

  2. SnapshotTesting.

Их основные различия:

Фичи

iOSSnapshotTestCase

SnapshotTesting

Язык реализации фреймворка

Objective-C

Swift

Diff скриншоты

Есть

Есть

Скриншоты любого UI-элемента

Нужно реализовывать самому

Есть

Гибкая настройка погрешности совпадения скриншотов

2 параметра

1 параметр

Последняя дата обновления фреймворка

12 октября 2019

27 апреля 2021

Поддерживает менеджеры зависимостей

CocoaPods, Carthage

CocoaPods, Carthage, Swift Package Manager

Внедряем снэпшот тесты в проект

Перед выполнением разделов ниже, нужно сделать следующее:

  1. Добавить таргет ui-тестов в проект. Как это сделать, можно посмотреть в статье в разделе "Настраиваем среду".

  2. Установить библиотеку iOSSnapshotTestCase и настроить переменные. Инструкция есть в репозитории.

Разберем все поэтапно:

Пишем реализацию

Для начала нам нужно импортировать скачанную библиотеку и наследоваться от класса FBSnapshotTestCase, чтобы появился доступ к нужным методам.

import FBSnapshotTestCase

class BullseyeUITests: FBSnapshotTestCase {
    
    let app = XCUIApplication()

    override func setUp() {
        super.setUp()
        
        app.launch()
    }

}

Теперь нам нужно добавить метод для записи скриншота, чтобы мы могли использовать его в любой момент выполнения тестового сценария ui-тестов.

// Делаем скриншот всего экрана
func verifyView(identifier: String, perPixelTolerance: CGFloat = 0.0, overallTolerance: CGFloat = 0.05 ) {
        
        let screenshot = app.screenshot().image
        
        FBSnapshotVerifyView(UIImageView(image: screenshot), identifier: identifier, perPixelTolerance: perPixelTolerance, overallTolerance: overallTolerance)
    }

// Делаем скриншот определенного элемента    
func verifyElement(element: XCUIElement, identifier: String, perPixelTolerance: CGFloat = 0.0, overallTolerance: CGFloat = 0.05 ) {
        
        FBSnapshotVerifyView(UIImageView(image: element.screenshot().image), identifier: identifier, perPixelTolerance: perPixelTolerance, overallTolerance: overallTolerance)
    }

Разберем, что мы делаем в методах выше.

Обратите внимание на Tolerance.

Tolerance — это допустимое отклонение актуального скриншота от эталонного. Рассчитывается по шкале от 0 до 1, где = 0,05 - равно 5%. По дефолту аргументы: perPixelTolerance и overallTolerance = 0%.

  • overallTolerance — допустимое отклонение общего количества пикселей;

  • perPixelTolerance — допустимое отклонение каждого пикселя.

Для наглядности покажу пример:

Зачастую снэпшот тесты используют для прогона на разных девайсах и версиях ОС. Для этого нужно, чтобы название эталонных скриншотов отличалось. Библиотека позволяет это делать.

Для этого в setUp мы добавляем кусок кода ниже:

override func setUp() {
        super.setUp()
        
        fileNameOptions = [
            .OS,
            .device
        ]
        
        app.launch()
    }

На данный момент мы можем сформировать имя скриншота из 4 параметров:

  1. FBSnapshotTestCaseFileNameIncludeOption.device — тип девайса (iPhone, iPad и так далее). Берётся из UIDevice.currentDevice.model.

  2. FBSnapshotTestCaseFileNameIncludeOption.OS — версия операционной системы. Берётся из UIDevice.currentDevice.systemVersion.

  3. FBSnapshotTestCaseFileNameIncludeOption.screenScale — масштаб экрана. Берётся из UIScreen.main.scale.

  4. FBSnapshotTestCaseFileNameIncludeOption.screenSize — размер экрана. Берётся из UIApplication.sharedApplication.keyWindow.bounds.size.

Пишем свой первый snapshot тест

Для записи эталонного скриншота вам понадобится свойство recordMode.

  • Если recordMode = true, мы формируем эталонный скриншот, либо перезаписываем старый;

  • Если recordMode = false, мы сверяем текущий скриншот, который делается во время прогона с эталонным.

Добавляем его в setUp(), чтобы записать эталонный скриншот для теста, и присваиваем ему значение = true.

override func setUp() {
        super.setUp()

        recordMode = true
        
        fileNameOptions = [
            .OS,
            .device
        ]
        
        app.launch()
    }

Теперь пишем сам тест:

func testCheckLeaderBoardScreen() {
        app.buttons["LeaderboardButton"].waitForExistence(timeout: 5)
        app.buttons["LeaderboardButton"].tap()
        Thread.sleep(forTimeInterval: 2)
        verifyView(identifier: "imageLeaderboardScreen")
    }

Тест отобразится с статусом failed. В описании ошибки будет информация, что эталонный скриншот сформирован.

Теперь изменим значение recordMode на false, чтобы проверить, что наш тест правильно отрабатывает.

override func setUp() {
        super.setUp()

        recordMode = false
        
        fileNameOptions = [
            .OS,
            .device
        ]
        
        app.launch()
    }

Тест отобразился с статусом passed. Поздравляю! Вы написали свой первый Snapshot тест.

Свойство recordMode по умолчанию false, поэтому после записи эталонного скриншота можете удалить его из метода setUp.

Но есть одна проблема, которая может разрушить наши планы по покрытию приложения снэпшотами — Status bar. В нем отображается заряд батареи и время, которые не статичны! В разделе ниже мы разберем способы, как это одолеть.

Убираем Status bar

Эту проблему можно решить, отрезав на скриншоте Status bar.

Основная проблема в том, что начиная с iPhone серии «X» у нас нестандартный размер Status bar:

Модель устройства

Высота Status Bar

iPhone XS Max, iPhone 11 Pro Max, iPhone X, iPhone XS, iPhone 11 Pro, iPhone 12 Pro Max, iPhone 12 Pro

132 px

iPhone XR, iPhone 11, iPhone 12

88 px

iPhone 6 Plus, iPhone 6S Plus, iPhone 7 Plus, iPhone 8 Plus

54 px

iPhone 6, iPhone 6S, iPhone 7, iPhone 8, iPhone 5, iPhone 5S, iPhone 5C, iPhone SE, iPhone 4, iPhone 4S

40 px

Для этого напишем два расширения:

UIDeviceExtension — будем определять текущий девайс.

import UIKit

extension UIDevice {
    
    static let modelName: String = {
        var systemInfo = utsname()
        uname(&systemInfo)
        let machineMirror = Mirror(reflecting: systemInfo.machine)
        let identifier = machineMirror.children.reduce("") { identifier, element in
            guard let value = element.value as? Int8, value != 0 else { return identifier }
            return identifier + String(UnicodeScalar(UInt8(value)))
        }

        func mapToDevice(identifier: String) -> String {
            switch identifier {
            case "iPhone3,1", "iPhone3,2", "iPhone3,3":     return "iPhone 4"
            case "iPhone4,1":                               return "iPhone 4s"
            case "iPhone5,1", "iPhone5,2":                  return "iPhone 5"
            case "iPhone5,3", "iPhone5,4":                  return "iPhone 5c"
            case "iPhone6,1", "iPhone6,2":                  return "iPhone 5s"
            case "iPhone7,2":                               return "iPhone 6"
            case "iPhone7,1":                               return "iPhone 6 Plus"
            case "iPhone8,1":                               return "iPhone 6s"
            case "iPhone8,2":                               return "iPhone 6s Plus"
            case "iPhone8,4":                               return "iPhone SE"
            case "iPhone9,1", "iPhone9,3":                  return "iPhone 7"
            case "iPhone9,2", "iPhone9,4":                  return "iPhone 7 Plus"
            case "iPhone10,1", "iPhone10,4":                return "iPhone 8"
            case "iPhone10,2", "iPhone10,5":                return "iPhone 8 Plus"
            case "iPhone10,3", "iPhone10,6":                return "iPhone X"
            case "iPhone11,2":                              return "iPhone XS"
            case "iPhone11,4", "iPhone11,6":                return "iPhone XS Max"
            case "iPhone11,8":                              return "iPhone XR"
            case "iPhone12,1":                              return "iPhone 11"
            case "iPhone12,3":                              return "iPhone 11 Pro"
            case "iPhone12,5":                              return "iPhone 11 Pro Max"
            case "iPhone12,8":                              return "iPhone SE (2nd generation)"
            case "iPhone13,1":                              return "iPhone 12 mini"
            case "iPhone13,2":                              return "iPhone 12"
            case "iPhone13,3":                              return "iPhone 12 Pro"
            case "iPhone13,4":                              return "iPhone 12 Pro Max"
            case "i386", "x86_64":                          return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))"
            default:                                        return identifier
            }
        }

        return mapToDevice(identifier: identifier)
    }()
    
}

UIImageExtension — будем обрезать status bar для девайса.

import UIKit

extension UIImage {

    var removingStatusBar: UIImage? {
        guard let cgImage = cgImage else {
            return nil
        }
        
        var yOffset: CGFloat = 0
        
        switch UIDevice.modelName {
        case "Simulator iPhone 11 Pro",
             "Simulator iPhone XS Max",
             "Simulator iPhone X",
             "Simulator iPhone 12 Pro",
             "Simulator iPhone 12 Pro Max",
             "Simulator iPhone XS":
            yOffset = 132
        case "Simulator iPhone XR",
             "Simulator iPhone 12",
             "Simulator iPhone 11":
            yOffset = 88
        case "Simulator iPhone 6 Plus",
             "Simulator iPhone 6S Plus",
             "Simulator iPhone 7 Plus",
             "Simulator iPhone 8 Plus":
            yOffset = 54
        default:
            yOffset = 40
        }

        let rect = CGRect(
            x: 0,
            y: Int(yOffset),
            width: cgImage.width,
            height: cgImage.height - Int(yOffset)
        )

        if let croppedCGImage = cgImage.cropping(to: rect) {
            return UIImage(cgImage: croppedCGImage, scale: scale, orientation: imageOrientation)
        }

        return nil
    }
    
}

Теперь нам достаточно в методе verifyView() добавить свойство removingStatusBar.

func verifyView(identifier: String, perPixelTolerance: CGFloat = 0.0, overallTolerance: CGFloat = 0.05 ) {
        
        guard let screenshotWithoutStatusBar = app.screenshot().image.removingStatusBar else {
            return XCTFail("An error occurred while cropping the screenshot", file: #file, line: #line)
        }
        
        FBSnapshotVerifyView(
            UIImageView(image: screenshotWithoutStatusBar),
            identifier: identifier,
            perPixelTolerance: perPixelTolerance,
            overallTolerance: overallTolerance
        )
    }

Смотрим отчет

Мы разобрались, как написать снепшот тест. Если тест не прошел, то в отчете будет следующее:

У нас есть 3 скриншота:

  • Reference Image — эталонный скриншот, который мы сделали в надежде, что так будет выглядеть экран приложения всегда (ожидаемый результат);

  • Failed Image — актуальный скриншот экрана во время прогона теста (фактический результат);

  • Diffed Image — наложение эталонного скриншота на актуальный. На нем мы можем видеть, что именно изменилось на экране.

Самое важное

  • Используйте Snapshot тесты для проверки верстки, элементов, с которыми тяжело взаимодействовать по accessibilityidentifier;

  • Используйте библиотеку iOSSnapshotTestCase, если поймете что его функционала вам мало, перейдите на SnapshotTesting;

  • Для достоверных результатов используем overallTolerance = 0,05; если выставите больше, Snapshot тесты не будут достаточно достоверными.

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

Тестовый проект с снэпшот тестами можете посмотреть в моём репозитории.

Вдохновлялся статьями:


Интересуешься автоматизацией на iOS? Подписывайся на мой телеграмм-канал, в котором я публикую материалы, которые будут полезны как начинающим, так и опытным iOS-автоматизаторам.

Теги: iosтестированиеавтоматизация тестированияxcuitestqaqa automationmobile qamobile testingsnapshot-тестированиеsnapshot
Хабы: Блог компании Vivid Money Тестирование IT-систем Разработка под iOS Swift Тестирование мобильных приложений
Всего голосов 4: ↑4 и ↓0 +4
Комментарии 2
Комментарии Комментарии 2

Похожие публикации

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Германия
Сайт
vivid.money
Численность
101–200 человек
Дата регистрации

Блог на Хабре