Модульное тестирование интерфейсов в Headless Chrome. Лекция Яндекса

Чтобы непрерывно улучшать большие клиентские интерфейсы, нужна мощная система автотестов. Разработчик Яндекса Дмитрий Андриянов dima117 кое-что про это знает — пару месяцев назад он поделился своим опытом на Я.Субботнике в Нижнем Новгороде.


 — Сегодня я расскажу, как мы в Директе пишем модульные тесты на веб-интерфейс. Мы в целом посмотрим, чем тесты на интерфейс отличаются от других тестов. Рассмотрим два подхода к написанию тестов: с помощью Selenium и с помощью Headless-браузеров. И в конце покажу инструмент, который мы написали в Директе для запуска тестов в Headless Chrome.

Это Директ — такая админка для рекламных объявлений.

ojhpidhjaewpfs81zehlxe4shhi.jpeg

А еще это второй по размеру проект в Яндексе после Поиска. Для примера, у нас в команде фронтенда 16 человек. На всех моих предыдущих местах работы было максимум четыре. Почему там так много народу? Ты же просто ввел объявление, ключевые фразы — что все эти люди там делают?

В Директе очень сложная предметная область. Это типичное объявление о продаже слонов.

yks8b6ewkhqj9x-q5m20mjdh2rk.jpeg

В интерфейсе Директа форма с настройками блока выглядит вот так:

lgxdyf3pabmqo0nfjgdkwherwws.jpeg

Там есть превью объявления, на каждую ссылку несколько полей, и еще они обвешаны сложной валидацией, вплоть до того, что специальная штука на сервере простукивает ссылку, которую вы ввели, и проверяет, что она вообще открывается. Там еще много другой логики, и эта форма — всего лишь для одного маленького блока, а блоков в объявлении много.

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

В целом получается такая картина.

fj7-ippepkyrvqyxzf5ssqygedw.png

У объявлений, которые вы видите на сайте или в поиске, очень много настроек и очень сложный интерфейс, с помощью которого они создаются. Чтобы реализовать этот сложный интерфейс, написано очень много кода. У нас в Директе используется БЭМ-стек. Проект разбит на блоки и этих блоков в проекте — около 800. Если вы пишете на React, представьте себе проект, в котором 800 React-компонентов. Это очень большой проект! Я пробовал померить список файлов в Web Storm, у меня получилась высота в 20 экранов. Понимаете, это очень много кода.

nxzhlilp0be3ngm8okfhzceh8zw.jpeg

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

Мы на всё пишем автотесты. В проекте около 7000 модульных тестов, мы их запускаем на каждый коммит, и они дают уверенность, что ничто не сломалось.

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

Это пример тестов, когда вы тестируете какую-то логику, какой-то класс или функцию.

tgtaf-zauka6zh8fqevexsqg6vy.jpeg

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

Как писать тесты на интерфейс, на всякие кнопочки? Точно так же!

arp_1wyvcc23kasbzk0mkps-ifs.jpeg

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

В чем проблема в этой схеме? Этим всем тестам нужен браузер. Это программа с графическим интерфейсом, где пользователь что-то вводит с клавиатуры, кликает по кнопкам, и результат работы любого браузера — пиксели, отрисованные на экране. У нас же автотесты, мы хотим запускать их автоматически, без участия человека. Для тестов на интерфейс нужен браузер, и нам это не подходит.

Что можно сделать? Есть два пути, как решить эту проблему. Первый — использовать какой-то инструмент для управления браузером. Selenium — это такая штука, которая делает те же действия, что пользователь в браузере, но вы можете этими действиями управлять программно, она будет их делать автоматически. Второй способ — использовать Headless-браузеры. Очень популярный — PhantomJS. Он умер, но все равно очень популярен.

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

cnegk_ati56afjnymhzt2b3b15o.png

Этот блокнот с галочками — запускалка тестов, в ней крутится примерно такой код, который вы видели на экране, и он дает команды в специальную штуку, которая называется «Selenium веб-драйвер». Это программная библиотека, которая предоставляет API для управления браузером. Там есть команды «открой страничку», «кликни по кнопке», «введи текст в поле ввода» и так далее.

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

yacwic5zvjlzij_lai4t8oen5bo.jpeg

Там есть переменная browser. В разных фреймворках она может называться по-разному, но суть одна: у вас есть объект, с помощью которого вы обращаетесь к API Selenium веб-драйвера. Подготовка, проверяемые действия, проверка — там есть все те же самые действия, которые были на слайде про тестирование функций/классов. Просто вы браузеру говорите, что делать.

Какие преимущества у этого подхода? Первое, Selenium веб-драйвер дает одинаковый способ управления разными браузерами. Например, вы гоняете с его помощью тесты в Firefox и Chrome. Потом вам сказали, что надо запускать тесты в IE или мобильном Safari. Вы в настройках добавляете IE8, и тесты начинают запускаться в IE. Для этого не нужно править код тестов. Единообразие управления браузерами — это большой плюс.

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

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

Как же запустить тесты с помощью Selenium?

48kqf6vug0hsb5s-lswz6xtfaho.jpeg

Ребята из Поиска написали специальный инструмент — «Гермиону». Это не просто запускалка тестов, там есть много возможностей. Но в первую очередь это инструмент, который запускает тесты в Selenium в куче браузеров.

lcad8xpa8hm5mlznorbxvpjlamo.jpeg

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

Тестирование в Selenium — хороший подход, он работает, его многие используют. Но у него есть одно существенное ограничение.

Нельзя писать модульные тесты. Только интеграционные. Чем отличаются модульные тесты от интеграционных?

smdbzmwskvqxemis43nc2j9a_vs.jpeg

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

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

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

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

hwc0k8gkkdboxbfkj5_gigfttui.jpeg

Что происходит в Selenium? Внизу файлики с кодом, которые выполняются в схеме. Слева, где запускалка тестов, выполняется код наших тестов, а справа, где браузер, выполняется код тестовой страницы и код, который мы тестируем. Между ними прослойка в виде Selenium, и код тестов не имеет прямого доступа к коду, который он тестирует. Код тестов не может поставить все заглушки, чтобы изолировать тестируемый код от зависимостей. Он может взаимодействовать только с API Selenium, кликать что-то. Он может выполнить какой-то JS, но все равно у него нет прямого доступа. Из-за этого нельзя писать модульные тесты.

Как вариант, можно было бы перенести этот код тестов тоже на сторону браузера и репортить данные о том, как тесты идут, в запускалку тестов, чтобы она там свои галочки и крестики отображала. Но с Selenium мы такого сделать не можем, потому что это API — одностороннее. Вы можете из теста что-то попросить у браузера, но вы не можете из браузера что-то отправить в программу, которая им управляет.

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

Давайте посмотрим на второй подход — тестирование в Headless-браузерах.

Headless-браузеры — это то же самое, что и обычные браузеры, но во время своей работы они не выводят ничего на экран. Вы можете запустить Headless-браузер как консольное приложение. Там внутри будет открыта страница, будет выполнен весь CSS, JS, но на экране вы ничего не увидите.

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

8qahc_u0p0pt3kief_popc5f8ig.jpeg

Получается, API имеет доступ ко внутренностям браузера. API Headless-браузеров предоставляют, как правило, намного больший набор возможностей, чем Selenium. В частности, там можно слушать события браузера типа page load, page error, можно перехватывать запросы к сети, перехватывать вывод в консоль и многое другое. Эти штуки делают возможным вариант, о котором я говорил: перенести код тестов на сторону браузера, чтобы он отправлял в test runner информацию о том, как тесты идут. Такой подход тоже очень популярен, многим нужны модульные тесты. Мы его около трех лет уже используем.

До недавнего времени единственным браузером, который нормально работал, был PhantomJS.

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

fdit6lzbgai6ki33odgf8za9fv8.jpeg

Это скриншот из Google Groups, там последний разработчик PhantomJS Виталий Слободин говорит примерно следующее: «скоро будет Headless Chrome, он лучше работает, не жрет память, как сумасшедший, поэтому я не буду разрабатывать PhantomJS, переходите на Chrome». Мы тоже стали смотреть на Chrome и перешли на него.

Это логотип инструмента под названием Puppeteer — для управления Chrome в Headless-режиме. Puppeteer (переводится как «кукловод») разрабатывает команда Chrome Dev Tools, есть некоторая уверенность, что они не бросят его поддержку. Это пакет NodeJS, у него JS API, и самое крутое, что он ставит Chrome вместе с собой как зависимость. Вам не нужно отдельно устанавливать браузер, чтобы в нем что-то запускать. Вы написали npm install — и у вас сразу все заработало.

8be4x2pzkfvrbowvcvlxyd2gkam.jpeg

Мы посмотрели в его сторону, попробовали, нам понравилось. Единственная проблема — не было инструментов, чтобы скрестить наши тесты и Headless Chrome. Для PhantomJS такие инструменты были, поскольку он давно существует, а Headless Chrome только появился, инструментов не было.

Мы написали свой инструмент mocha-headless-chrome.

l50yauofmh9qkcvr2datugw3raw.jpeg

У нас фантазии поменьше, чем в Поиске: их инструмент называется «Гермиона», а наш — «mocha-headless-chrome». Мы им пользуемся полгода, он работает. На примере маленького проекта покажу, как это происходит. (Демо из доклада лежит здесь — прим. ред.)

В тестовом проекте один файлик test-form.js. Несложно догадаться, что это поисковая форма, там есть input и кнопка. Класс SearchForm, у него есть метод render, почти как в React, и он добавляет на страницу form, input и кнопку. Кроме того, он подписывается на клик по кнопке, и когда вы кликнули по кнопке, он делает Ajax-запрос, отправляет содержимое формы на example.com, а после этого он чистит форму.

class SearchForm {

    onClick(e) {
        e.preventDefault();

        let xhr = new XMLHttpRequest();
        
        xhr.open("GET", "http://example.com");
        xhr.send(new FormData(this.form));

        this.form.reset();
    }

    render(parent) {
        this.form = document.createElement('form');
        this.form.innerHTML = 
            `
             `;

        this.input = this.form.querySelector('input[type=text]');
        this.button = this.form.querySelector('input[type=button]');

        this.button.addEventListener('click', this.onClick.bind(this));

        parent.appendChild(this.form);
    }

    destroy() {
        this.form.remove();
    }
}


Давайте напишем для этого простенький тест. Папка tests, в ней файлик test.js. Пока здесь нет никаких тестов, только describe и действие, которое нужно выполнить до каждого теста и после.

const assert = chai.assert;

describe('форма поиска', function() {
    let searchForm, server;

    beforeEach(function() {
        searchForm = new SearchForm();
        searchForm.render(document.body);

        server = sinon.createFakeServer({
            respondImmediately: true
        });
    });

    afterEach(function () {
        searchForm.destroy();
        server.restore();
    });
});


До каждого теста мы добавляем на страничку нашу форма, а после каждого теста мы ее удаляем.

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

Мы написали файл наших тестов, JS, там пока нет тестов, скоро напишем.

Но сначала давайте сделаем тестовую страничку, которая будет открываться в браузере.

В папке tests делаю файл test.html. Тут ничего сложного, мы подключаем три библиотеки, Mocha — тестовый фреймворк, думаю, все с ним знакомы. Sinon — это библиотека, которая позволяет автоматически создавать всякие заглушки, чтобы изолировать наш блок от его зависимостей. И библиотека chai со всякими ассертами, она дает API для различных проверок в тестах.



  
  

  
  
  
  


  


Мы подключили эти три библиотеки, дальше подключили код нашей формы, search-form, и подключили наш файлик с тестами, который мы только что создали. В конце позвали команду mocha-run, чтобы тесты запустились.

Запустим страничку в браузере и убедимся, что там все ок. Открылась страничка, в ней ноль тестов, что ожидаемо. Напишем пару тестов. Тест проверяет, что когда мы кликнули по кнопке, на сервер ушел Ajax-запрос на правильный адрес. Это searchForm, которую мы создали вначале. Тест заполняет форму данными, потом кликает по кнопке, потом с помощью заглушки в переменной server проверяет, что у последнего сделанного запроса был нужный url.

    it('должна отправлять запрос', function() {
        // подготовка
        searchForm.input.value = 'субботиник';
    
        // действие
        searchForm.button.click();
    
        // проверка
        assert.equal(server.lastRequest.url, 'http://example.com2');
    });


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

Давайте запустим все это в Headless Chrome.

npm install mocha-headless-chrome

Добавляю в package.json команду test, чтобы не писать каждый раз. Когда вы устанавливаете пакет mocha-headless-chrome, он добавляет утилиту с таким же названием. Ей надо передать параметр -f — путь к нашей тестовой страничке, которую мы открывали в браузере.

{ 
  ...
  "scripts": {
     "test": "mocha-headless-chrome -f tests/test.html"
  },
  ...
}


Теперь, если я теперь запущу npm test, все должно работать.

Мы поставили зависимость, оно само себе скачало Chrome, положило его локально в папку node_modules. Дальше мы просто вызываем его по имени как консольное приложение и передаем через параметр f тестовую страничку. Тест прошел, это наш тест, который мы написали.

Попробуем добавить еще один тест, который проверяет, что после отправки Ajax-запроса у нас почистилось значение в форме поиска.

    it('должно очищаться после запроса', function() {
        // подготовка
        searchForm.input.value = 'субботиник';
    
        // действие
        searchForm.button.click();
    
        // проверка
        assert.equal(searchForm.input.value, '');
    });


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

Мы пользуемся этим инструментом примерно полгода. 7000 тестов, которые раньше работали в PhantomJS, без проблем стали работать в Headless Chrome. Выполнение тестов ускорилось на 30%. Эта штука доступна во внешнем npm, вы тоже можете ее брать и пользоваться. Там уже 5000 загрузок в месяц, то есть есть люди вне Яндекса, которые ее используют.

© Habrahabr.ru