company_banner

Как мы тестируем CSS-регрессии с Gemini. Доклад на BEMup в Яндексе

    Всем привет! Меня зовут Сергей Татаринцев. В Яндексе я работаю в группе разработки общих интерфейсов. Наша группа занимается созданием интерфейсных библиотек, используемых во многих сервисах, — в том числе в Поиске. Мы поддерживаем четыре библиотеки, которые в общей сложности включают в себя 62 блока.

    Если посчитать все десктопные и мобильные браузеры всех версий, то получается, что у нас в поддержке их более 15. Около года назад их все мы тестировали вручную. Тестировщик просто брал и прокликивал все это во всех браузерах и смотрел, не поехало ли что-нибудь, работает ли так, как было задумано. Это приводило к тому, что процесс релиза очень затягивался. Вплоть до того что разработка и тестирование занимали приблизительно одинаковое время. Многие баги ускользали от глаз тестировщика или обнаруживались через достаточно продолжительное время.



    Мы решили, что дальше так жить нельзя и решили процесс тестирования как-то автоматизировать. Начали мы с инструментов статического анализа. Для проверки стиля кода у нас используется инструмент jscs, написанный нашим коллегой Маратом Дулиным. Для статического анализа кода применяется всем известный JSHint. А для отлова регрессий в JS мы пишем юнит-тесты. Это в какой-то мере помогло справиться с проблемой: анализаторы отлавливали совсем уж глупые ошибки, а тесты позволили проверять функциональность блока. А вот с регрессиями в CSS был пробел. Тестирование внешнего вида по-прежнему проводилось руками и глазами тестировщика. Мы стали искать инструменты, которые помогали бы нам в автоматизации.



    Рассмотрим пример. На картинке ниже типичный блок из нашей стандартной библиотеки. Там есть различные шрифты, подсветка и тени.



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

    Условия


    Кроме того, нужно соблюдать несколько условий. Во-первых, должна быть возможность тестировать сразу в нескольких браузерах. Ведь совпадение картинки с эталоном в Firefox вовсе не гарантирует, что в IE будет такая же правильная картинка. Во-вторых, скриншоты блоков нужно снимать в разных состояниях. Одна и та же кнопка при нажатии может выглядеть совсем иначе. Кроме того, должна быть возможность делать скриншоты отдельных блоков, а не всей страницы целиком. На странице может быть динамический контент, из-за которого будут ложные срабатывания. Еще одно преимущество — сразу видно, в каком именно блоке на странице есть проблема. В-третьих, хотелось бы хранить эталонные скриншоты в репозитории. Это позволяет версионировать их вместе с кодом, поддерживать несколько версий библиотеки с разным дизайном, а локальное хранение позволяет увеличить производительность тестирования, поскольку за эталонным скриншотом не нужно ходить на какой-то URL и снимать его повторно. Сами тесты нам хотелось бы писать на JavaScript, так как сами мы все веб-разработчики и язык этот знаем и любим.

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

    Второй такой инструмент — это casper.js + phantom.css: фреймворк под headless-браузер phantomjs для интеграционного тестирования и аддон для тестирования скриншотов, соответственно. Эта штука позволяет снимать фрагменты и тестировать в различных состояниях. Однако она очень привязана к phantom.js, и тестирование в других браузерах невозможно.

    Последний изученный нами инструмент — Huxley от Instagram. Ему также не нужен код для тестов, он при помощи специального плагина записывает все ваши действия и позволяет их потом проиграть. Но он умеет делать только скриншоты страницы целиком и запускает тесты только в одном браузере за раз.

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



    В итоге мы решили разрабатывать свой. Назвали мы его gemini — близнецы.

    Схема работы


    Немного расскажу о том, как он работает. Вы описываете несколько состояний блока. Для каждого состояния можно указать список действий, которые необходимо выполнить для перехода в него.



    Рассмотрим на конкретном примере, кнопке из библиотеки bem-components. У нее есть четыре состояния: исходное, с ховером, нажатая и в фокусе:



    Вот так предыдущая абстрактная схема будет выглядеть для конкретной кнопки:



    У нее есть исходное состояние, не требующее никаких действий. От него можно перейти к ховеру, для чего нужно навести на нее курсор. Из этого состояния, нажав ЛКМ, можно перейти в нажатое состояние. Когда мы отпустим ЛКМ, кнопка получит фокус.

    От схем перейдем непосредственно к коду. Тест для gemini — это обычный node.js-модуль, а сам gemini мы импортируем при помощи обычного нодовского require. Для начала нам нужно создать наш тестовый набор. Это делается командой gemini.suite. Мы передаем имя набора и функцию, в которой в дальнейшем будем производить настройку этого набора. Весь дальнейший код, который я буду приводить в примерах, происходит внутри этой функции.

    var gemini =require('gemini');
    
    gemini.suite('button', function(suite) {
    });
    

    Первый шаг настройки — задание URL, с которого мы будем снимать скриншоты.

    suite.setURL('/some/url');
    

    Далее нам нужно задать регион для съемки. Делается это при помощи списка CSS-селекторов. В примере элемент только один, но их может быть сколько угодно. Область захвата определяется как минимальный прямоугольник, в который попадают все перечисленные элементы.

    suite.setCaprureElements('.button');
    

    Закончив с настройкой, можно переходить к захвату скриншотов. Наше первое состояние — исходное (plain). нам не надо выполнять никаких действий, просто сделать захват скриншота командой capture, которой мы передаем имя состояния.

    suite.capture('plain');
    

    Для второго состояния — hovered — нам уже потребуется выполнить определенное действие, навести курсор. Сделать это можно во втором аргументе функции caprure.

    suite.capture('hovered', function(actions) {
        actions.mouseMove('.button');
    });
    

    Следующее состояние — нажатая кнопка. Нажатие производится командой mouseDown. В этом примере еще можно увидеть альтернативный способ задания элемента: не передача CSS-селектора напрямую, а оборачивание его в функцию find. Зачем это нужно и чем полезно, я расскажу чуть ниже.

    suite.capture('pressed', function(actions, find) {
        actions.mouseDown(find('.button'));
    });
    

    Последнее состояние — кнопка в фокусе: нажатую в прошлом примере кнопку нужно отпустить командой mouseUP.

    siute.capture('clicked', function(actions, find) {
        actions.mouseUp(find('.button'));
    });
    

    В принципе, на этом можно заканчивать тест, но можно еще провести небольшую оптимизацию. В каждом примере мы взаимодействовали с одной и той же кнопкой. И во всех примерах поиск элемента осуществляется каждый раз. Упростить это можно, выполнив поиск один раз в функции before при помощи функции find, сохранив результаты в переменную. В дальнейшем вместо поиска можно использовать ее. Финальный вариант теста будет выглядеть примерно так:

    var gemini = require('gemini');

   gemini.suite('button', function() {
        suite.setUrl('some/url') 
        .setCaptureElements('.button') 
        .before(function(actions, find) {
            this.button = find('.button');
        });
        .capture('plain')
        .capture('hovered', function(actions, find) {
            actions.mouseMove(this.button); 
        })
        .capture('pressed', function(actions, find){ 
            actions.mouseDown(this.button);
        })
       .capture('clicked', function(actions, find) {
            actions.mouseUp(this.button); 
        });
    );
    

    Также нам нужно создать файл конфигурации. В нем мы задаем корневой URL, от которого будут рассчитаны относительные URL, задаваемые в тестах. Второй параметр — это URL для Selenium Grid (так как gemini основан на Selenium, использование Grid обязательно). Ну и список браузеров, в которых мы будем тестировать. Конфиг выглядит приблизительно так:

    rootUrl: http://localhost:8000
    gridUrl: http://localhost:4444/wd/hub
    browsers:
        firefox-v30:
            browserName: firefox
            version:  30
        opera-v12:
            browserName: opera
            version: 12
    

    Отчет о прохождении тестов можно посмотреть прямо в консоли, либо создать html-отчет.

    Полезные советы


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

    Интеграция со сторонними сервисами


    Первый такой сервис — это Sauce Labs, что-то вроде облачного Selenium Grid. Для опенсорсных проектов он бесплатен. Чтобы интегрировать его с gemini, нам нужно задать две переменные среды: юзернейм и access key, который нам выдадут после регистрации.

    SAUCE_USERNAME=<USERNAME>
    SAUCE_ACCESS_KEY=<ACCESS KEY>
    

    А в конфиге вместо Selenium Grid нужно прописать вот такой URL: http://ondemand.saucelabs.com/wd/hub.

    Если у вас нет выделенного сервиса для тестирования страниц, вам потребуется утилита SauceConnect, которая откроет тоннель между вашим локалхостом и серверами Souce Labs.

    Еще один сервис — это всем известный Travis, обычно применяемый для непрерывной интеграции. Для работы с ним нужно установить несколько нативных зависимостей. В частности, gemini нужен graphicsmagick. Для запуска теста в gemini нужно в package.json нашего проекта прописать gemini test:

    "scripts": {
        "test": "gemini test"
    }
    


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

    Документация к gemini доступна по этой ссылке, а сам инструмент доступен на гитхабе. Туда же можно присылать ваши пожелания и пулл-реквесты, мы им всегда рады.

    Инструмент продолжает развиваться. Совсем недавно мы научили вычислять его покрытие вашего CSS тестам. Для этого в конфиге надо надо указать параметр coverage: true. После запуска тестов отчет будет лежать в папке gemini-coverage. Возможность пока экспериментальная, будем рады вашим отзывам.

    Также, в ближайшее время будет выпущена версия с программным API и графический интерфейс.
    Метки:
    Яндекс 661,64
    Как мы делаем Яндекс
    Поделиться публикацией
    Комментарии 13
    • 0
      А если перед снятием скриншота мне необходимо выполнить какое-то асинхронное действие, такое как-то можно реализовать?
      • +1
        Сейчас это можно сделать комбинацией из действий executeJS() и wait(). WebDriver в принципе позволяет сделать более умный асинхронный execute, эта возможность просто не проброшена в gemini. Я создал тикет об этом (https://github.com/bem/gemini/issues/66). В ближайшее время планируем пробросить недостающие команды WebDriver, туда попадет и эта.
        • 0
          Cool.
          И вот действительно, еще очень резонный вопрос, почему вы отказались от привычных интерфейсов, как у большинства библиотек для unit-тестирования, были ли на это какие-то серьезные причины? Мелочь, конечно, но пользователям было бы приятней.
          • +1
            Мы старались сохранить преемственность с обычными тестовыми фреймворками, там где инструмент работает также, как и они и сознательно делали интерфейс другим там, где gemini работает по другому. ИМХО, получить то что называется и вызывается также, как ты привык, но работает по другому было бы менее приятно.

            Из общих черт c юнит-тстовыми библиотеками: У нас также есть именованные тест-сьюты, которые выстраиваются в иерархию, есть хуки before и after, есть общий контекст между всеми тестами в сьюте, как у mocha.

            Отличия же в том, что нашему сьюту от программиста требуется вручную задать гораздо больше чем имя: как минимум нужна область скриншотов и URL. И это нужно для абсолютно всех тестов, поэтому требовать делать это в опциональном методе before/beforeEach было бы как-то неправильно.

            Второе отличие в том, что наши 'capture' всегда выполняются последовательно и каждый последующий спокойно может зависеть от предыдущего (к в примере из статьи с кнопкой). Для традицонных тестов это не всегда так и тесты там подразумеваются независимыми, поэтому мы решили объявлять их немного по другому.

            Ну и еще одно отличие – отсутствие assert проще всего объяснить. Assert у нас для любого теста всегда один: совпадение текущей и сохраненной картинки и писать его явно нет нужды.
      • 0
        Выглядит феерически круто. Скажите, я правильно понимаю, что первый раз при верстке элемента нужно просто прогнать gather и просмотреть глазами, правильно ли он выглядит во всех браузерах? А в дальнейшем уже считать получившийся результат эталонным и автоматически проводить тестирование на «не поломалось ли чего-то»?
        • +1
          Да, все именно так. Ну и когда сознательно меняете верстку надо тоже удостоверится, что поменяли только то, что хотели (просмотреть diff в тестах, например) и потом запустить gather повторно.
        • +1
          Мы в нашей компании используем phantomFlow — под капотом phantomcss и Casper.

          Для нас было не существенным различия в разных браузерах. Запускаем в связке grunt + teamcity. На все ушло всего 2-3 дня. Phantomflow пришлось немного модифицировать по дороге — сыровато немного конечно у них пока.

          Спасибо за новый open source проект!
          • +2
            есть ещё отличный webdriver.io/guide/plugins/webdrivercss.html, в нём можно даже делать скриншот вьюбокса конкретного элемента и закрашивать ненужное через exclude.
            • 0
              Выглядит как костыль по сравнению с Близнецами :)
              • 0
                 Судя по перовому тегу в webdrivercss, он вышел почти одновременно с gemini. Эксклуды действительно у него крутые, но и мы умеем пару вещей, которые он пока не умеет:

                — позволяем задать несколько элементов в одном тесте (webdrivercss, насколько я понял, умеет либо снимать всю страницу, либо один элемент, либо заданный координатами прямоугольник)
                — учитываем outline и box-shadow при снятии скриншота.
                — считаем coverage
              • 0
                Поэкспериментировал.
                Возник вопрос по работе.

                При переключении страниц из скрипта (например нужна авторизация, а потом навигация через post запросы), слетает браузерный часть клиента gemini, и не совсем понятно как его опять туда подгрузить. И происходит ошибка:
                JSONWire Error: clientScript is not defined


                Те когда можно попасть на страницу сразу по URL все вроде работает, а протестировать серверное приложение, у которого внутренняя архитектура сделано коряво, уже документации не хватает.
                • 0
                  Говорят, что лучше поздно, чем никогда. В версии 0.9.8, которая вышла пару минут назад, мы наконец-то это исправили.
                  • 0
                    Спасибо.
                    Тогда на неделе сделаю еще один заход, а то стал двигаться в сторону casper + phantom.css

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