Визуальное автотестирование сайтов с помощью Codeception

Автоматические end-to-end тесты хороши тем, что позволяют сымитировать действия пользователя на сайте. Мы можем запрограммировать в скрипте теста действия типа открыть страницу, нажать на кнопку, ввести данные в поля ввода, нажать галочки и радиокнопки, отправить форму, и ждать на выходе нужный результат. Увидел текст «Ваше сообщение принято. Спасибо» — тест пройден. В ином случае — не пройден. Все прозрачно и понятно. Можно написать автотесты на все критично важные модели поведения пользователя на сайте, перед каждым обновлением кода на боевом сервере прогонять их и таким образом значительно повысить качество разработки. Но мы пойдем еще дальше.

Проблема

Тест проверяет логику, а значит, он не сможет выявить негативный эффект на сайте, вызванный изменениями в стилях: съехала шапка, скрылся важный блок, картинки улетели за пределы экрана, текст в соседних плашках наложился друг на друга. Особенно если регресс произошел на тех страницах, где его никто не ждет. Проводить визуальный осмотр всего сайта после каждой правки фронтенда — очень дорогое удовольствие. К тому же малоэффективное, ведь тестировщик должен держать в памяти все, что было на этой странице в прошлый раз. В большинстве случаев человек просто не заметит изменений, если они не были фатальными. А надо бы.

Идея

Нам нужен такой автотест, который будет проходиться по всем критично важным страницам сайта, делать скриншот каждой из них и сохранять куда-то в свое хранилище. При последующем тесте повторять эти действия и сравнивать два скриншота — прошлый и настоящий, лучше еще и с подсветкой изменений. Далее реализовать интерактивный веб-интерфейс, в котором отображались бы результаты тестов по всем нашим проектам, можно было зайти на страничку каждого из них, просмотреть скриншоты его страниц, сделанные с десктопного браузера, планшета и мобильного устройства. Если изменения, что произошли на страничке, являются фичей, а не багом, то нужна возможность отметить скриншот как некий эталон и при последующих прогонах теста сравнивать уже с ним. Если же произошел регресс страницы, то дать команду ответственному разработчику исправить баг. Затем снова прогнать тест, убедиться, что баг исправлен и при этом верстка не поехала где-то в другом месте. Возможность запускать автотест должна быть у каждого сотрудника компании.

Решение

Мы в своей практике давно и успешно пользуемся фреймворком Codeception для тестирования логики. Как оказалось, у него есть интересный плагин VisualCeption. Подробно изучив его возможности, мы поняли, насколько это крутая штука, и спешим поделиться с вами готовым решением на его основе.

Итак, у нас есть сайт — для примера возьмем вот эту бесплатную тему для админки с просторов интернета. Выберем штук 7 страниц, перед каждым деплоем на боевой сайт будем снимать с них скриншоты и смотреть на визуальный регресс. Сайт должен отображаться корректно на разрешениях экрана, соответствующих экрану десктопа (1920×1080 px), планшета (768×1024 px) и мобильного телефона (375×812 px). Рабочим браузером будет Firefox.

Техническая часть

Практика показала, что запускать автотесты лучше на компьютере с видеопамятью. Для примера: тест на 100 страниц отрабатывает 50–60 минут на сервере и 12–15 минут на обычном офисном компьютере. Лучше выделить специальный компьютер под автотесты (далее — агент), сделать его доступным из веб-браузера, настроить доступ по SSH. Установить php с расширением imagick, а также docker, composer и docker-compose.

В корневой папке веб-сервера агента будет находиться React-приложение, которое будет тем самым веб-интерфейсом для управления результатами визуального тестирования. Склонируйте на агент вот этот репозиторий, запустите сборку. Содержимое папки dist просто переместите в корневую папку веб-сервера:

image-loader.svg

Далее нам нужно где-то организовать непосредственно сами скрипты автотестов, по директории на каждый проект. Тут же создаем папку visual-autotesting, клонируем туда этот репозиторий. Устанавливаем зависимости Composer. В файле tests/acceptance.suite.yml видим адреса боевого сайта, препрода, тестового и дев-стенда:

Обязательным является лишь адрес самого сайта WebDriver - url,  остальные адреса нужны для удобства перехода на различные стенды прямо из веб-интерфейса.Обязательным является лишь адрес самого сайта WebDriver — url, остальные адреса нужны для удобства перехода на различные стенды прямо из веб-интерфейса.

В файле tests/acceptance/VisualRegressCest.php в методе pageProvider() прописаны те самые 7 страниц сайта:

Все страницы вбиваем жестко, как на скрине.  Они не должны откуда-то считываться или вычисляться по какому-либо алгоритму.Все страницы вбиваем жестко, как на скрине. Они не должны откуда-то считываться или вычисляться по какому-либо алгоритму.

Смотрим на метод tryToTest() — это и есть непосредственно сам скрипт автотеста. Он открывает в браузере указанную страницу, ждет 3с, чтобы загрузились разные интерактивные элементы. Далее идет проверка на 404ю ошибку, и, если тест после этого не упал, идет непосредственно снятие скриншота.

Вы можете легко вставить свои проверки.  Просто укажите скрипту, чего и где он не должен видеть, как в строках 18-19. Мы в других проектах так отлавливаем, например, 500ю ошибку и ошибку компонентов Битрикса.Вы можете легко вставить свои проверки. Просто укажите скрипту, чего и где он не должен видеть, как в строках 18–19. Мы в других проектах так отлавливаем, например, 500ю ошибку и ошибку компонентов Битрикса.

В первый раз скриншот сразу идет в хранилище эталонов. Во второй и последующий разы скрипт видит, что скриншот эталона существует, делает новый скриншот и сравнивает его с эталоном. Результат сравнения в виде еще одной картинки .png также складывает в хранилище:

Скриншоты последнего прогона. Результаты наложения хранятся на уровень выше.  Эталоны лежат в /var/www/html/visual-autotesting/tests/_data/VisualCeptionСкриншоты последнего прогона. Результаты наложения хранятся на уровень выше. Эталоны лежат в /var/www/html/visual-autotesting/tests/_data/VisualCeption

Запуск автотестов для удобства организован в docker-контейнерах для возможности параллельной работы. Команды прописаны в package.json:

Если на вашем компьютере установлен Docker, можно запускать автотесты локально прямо из IDEЕсли на вашем компьютере установлен Docker, можно запускать автотесты локально прямо из IDE

Интеграция с Jenkins

Чтобы инструмент стал действительно народным, важно чтобы все сотрудники, даже далекие от разработки, могли легко запускать тесты. Для этого нужно организовать процесс в какой-либо системе непрерывной доставки типа Jenkins. Иначе сотрудники будут вынуждены работать в недружелюбной командной строке, а это мало кому понравится. Они всячески будут ее избегать, при каждом запуске будут отвлекать разработчиков, чтобы случайно не ввести что-нибудь не то. Тестирование люди хотят запускать нажатием кнопки.

Создаем новый item в Jenkins, тип — pipeline. Ставим галочку параметризированной сборки, добавляем параметры:
1. REPOSITORY_NAME_BACK, тип — Choice parameter, варианты — visual-autotesting. По мере добавления новых проектов будете добавлять их сюда как новые варианты
2, 3, 4. desktop, tablet, mobile, тип — Boolean parameter. Это указания, в каких разрешениях экрана снимать скриншоты для каждой страницы проекта.

Наконец, сам Groovy-скрипт пайплайна:

Много кода

//
//  Job for running autotest by codeception
//  requirements:
//      yum install php php-imagick php-zip
//      curl -sL https://rpm.nodesource.com/setup_12.x | bash -
//      yum install -y nodejs
//      mv /etc/php.d/20-curl.ini.disabled /etc/php.d/20-curl.ini
//      wget -O /etc/yum.repos.d/docker-ce.repo https://download.docker.com/linux/centos/docker-ce.repomv docker-ce.repo
//      yum install docker-ce docker-ce-cli containerd.io docker-compose
//      systemctl enable --now docker
//      usermod -aG docker Jenkins.App


pipeline {


    agent {
        label "codeception_test"
    }
    stages {
        stage('Parameters') {
            steps {
                script {
                    properties([
                        parameters([

                            choice(
                                choices: ['visual-autotesting'],
                                name: 'REPOSITORY_NAME_BACK'
                            ),
                            booleanParam(defaultValue: true, name: 'desktop'),
                            booleanParam(defaultValue: true, name: 'mobile'),
                            booleanParam(defaultValue: true, name: 'tablet')

                        ])
                    ])
                    PROJECT_PATH = "/var/www/html"
                    BRANCH_NAME = "master"
                    REPOSITORY_NAME_FRONT = "visual-autotesting.frontend"
                }
            }
        }
        stage('get_front') {
            steps {
                sh "sudo chown -R Jenkins.App:Jenkins.App ${PROJECT_PATH}"
                echo "get sources for back"
                checkout([$class: 'GitSCM',
                        branches: [[name: "${BRANCH_NAME}"]],
                        doGenerateSubmoduleConfigurations: false,
                        extensions: [[$class: 'RelativeTargetDirectory',
                        relativeTargetDir: "${PROJECT_PATH}/${REPOSITORY_NAME_FRONT}"]],
                        submoduleCfg: [],
                        userRemoteConfigs: [[url: "https://github.com/MaDRaGe/${REPOSITORY_NAME_FRONT}"]]])
                sh "sudo chmod g+w -R ${PROJECT_PATH}/${REPOSITORY_NAME_FRONT}/."
                sh "sudo chown -R Jenkins.App ${PROJECT_PATH}/${REPOSITORY_NAME_FRONT}/."
            }
        }
        stage('get_back') {
            steps {
                echo "get sources for back"
                checkout([$class: 'GitSCM',
                        branches: [[name: "${BRANCH_NAME}"]],
                        doGenerateSubmoduleConfigurations: false,
                        extensions: [[$class: 'RelativeTargetDirectory',
                        relativeTargetDir: "${PROJECT_PATH}/${REPOSITORY_NAME_BACK}"]],
                        submoduleCfg: [],
                        userRemoteConfigs: [[url: "https://github.com/Pum-purum/${REPOSITORY_NAME_BACK}.git"]]])
                sh "sudo chmod g+w -R ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}/."
                sh "sudo chown -R Jenkins.App ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}/."
                sh "rm -rf ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}@tmp"
            }
        }
        stage('build_front') {
            steps {

                echo "build front project"


                sh '''
                        cd ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''
                        npm install --prefix ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''
                        npm run build --prefix ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''
                        mv ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''/dist/* ''' +PROJECT_PATH+ '''/
                        rm -rf ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''
                        rm -rf ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_FRONT+ '''@tmp''
                        ls -la .
                        '''
             }
        }
        stage('build_back') {
            steps {

                echo "build back project"
                sh "composer update --working-dir=${PROJECT_PATH}/${REPOSITORY_NAME_BACK}"
             }
        }
        stage('testing') {
            steps {
                parallel (
                    "desktop" : {
                    
                        script {
                            if (params.desktop) {
                                echo "desktop"
                                try {
                                    sh '''
                                        cd ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        sudo npm run desktop --prefix ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        ls -la .
                                    '''
                                } catch (err) {
                                    echo err.getMessage()
                                }
                            }
                        }
                    },
                    "tablet" : {
                   
                        script {
                            if (params.tablet) {
                                sleep(time:10,unit:"SECONDS")
                                echo "tablet"
                                try {
                                    sh '''
                                        cd ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        sudo npm run tablet --prefix ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        ls -la .
                                    '''
                                } catch (err) {
                                    echo err.getMessage()
                                }
                            }
                        }
                    },
                    "mobile" : {
                 
                        script {
                            if (params.mobile) {
                                sleep(time:20,unit:"SECONDS")
                                echo "mobile"
                                try {
                                    sh '''
                                        cd ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        sudo npm run mobile --prefix ''' +PROJECT_PATH+ '''/''' +REPOSITORY_NAME_BACK+ '''
                                        ls -la .
                                    '''
                                } catch (err) {
                                    echo err.getMessage()
                                }
                            }
                        }
                    }
                )
            }
        }
    }
    post {
        always {
            sh "sudo chmod g+w -R ${PROJECT_PATH}/."
            sh "sudo chown -R 600:600 ${PROJECT_PATH}/."
            sh "sudo chmod o+rw -R ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}/json/."
            sh "sudo chmod o+rw -R ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}/tests/_data/."
            sh "sudo chmod o+rw -R ${PROJECT_PATH}/${REPOSITORY_NAME_BACK}/tests/_output/."
        }
    }
}

Суть скрипта в том, что пользователь Jenkins.App подключается к агенту codeception_test, заходит в папку /var/www/html, и выполняет все те действия, что мы руками делали в разделе Техническая часть. Чтобы пайплайн корректно запустился в вашем Jenkins, нужно наши значения заменить на ваши. Скорее всего, тут понадобится помощь системного администратора.

Результат

Заходим в браузере по адресу нашего веб-интерфейса и видим одинокий (пока) автотест visual-autotesting:

Не забудьте для каждого автотеста в корень положить красивую иконку logo.pngНе забудьте для каждого автотеста в корень положить красивую иконку logo.png

Нажимаем на его иконку и видим такую красоту:

image-loader.svg

У каждой страницы есть значок в левом меню, показывающий результат теста:

  • Зеленая галочка означает, что текущий скриншот полностью идентичен эталону;

  • Желтый восклицательный знак — что есть изменения;

  • Оранжевый крестик — что страница выдает 404ю ошибку, причем эта же ошибка была и в прошлый раз;

  • Красная молния означает, что страница выдает 404ю ошибку, но этой ошибки не было в прошлый раз;

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

А еще в верхнем меню есть ссылки на все наши стенды с этой страницей.  Дата снятия скриншота соответствует часовому поясу из браузера пользователя.А еще в верхнем меню есть ссылки на все наши стенды с этой страницей. Дата снятия скриншота соответствует часовому поясу из браузера пользователя.

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

Круто, правда?Круто, правда?

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

Проходим по всем страницам, отмечаем эталоны, по итогу тестирования ставим задачи разработчикам на исправление багов.

Всем продуктивной разработки и качественного тестирования!

© Habrahabr.ru