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


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

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



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

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



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



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

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

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



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



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

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

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

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



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

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



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

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

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

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



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

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



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

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

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

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

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



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



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

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

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



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

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

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

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



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

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

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

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

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

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



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

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

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



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

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



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

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



У нас фантазии поменьше, чем в Поиске: их инструмент называется «Гермиона», а наш — «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 = 
            `<input type="text" name="query" />
             <input type="button" value="Найти" />`;

        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 для различных проверок в тестах.

<html>
<head>
  <meta charset="utf-8">
  <link href="../node_modules/mocha/mocha.css" rel="stylesheet" />

  <script src="../node_modules/mocha/mocha.js"></script>
  <script src="../node_modules/sinon/pkg/sinon.js"></script>
  <script src="../node_modules/chai/chai.js"></script>
  <script>mocha.setup('bdd');</script>
</head>
<body>
  <div id="mocha"></div>

  <script src="../src/search-form.js"></script>
  <script src="../tests/test.js"></script>

  <script>mocha.run();</script>
</body>
</html>

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

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

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

Посмотрим страничку в браузере, она обновилась, и мы видим, что один тест прошел. В браузере есть код, который делает 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 загрузок в месяц, то есть есть люди вне Яндекса, которые ее используют.

Комментарии (13)


  1. vintage
    08.04.2018 11:59

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


    Вам не удалось показать сложность предметной области. Те пара приведённых вами скриншотов не выглядят чем-то архи сложным. Тем не менее я немного знаком с БЭМ стеком, поэтому охотно верю, что вам действительно пришлось написать 800 компонент, накопипастить 250 000 CLOS и 56Мб кода, на поддержку которых требуется 16 разработчиков с утро до ночи добавляющих новый код без надежды на рефакторинг. Но это особенность вашего инстумента, а не предметной области. Добавьте какой-нибудь Redux и кода волшебным образом станет ещё больше, без какого-либо прироста функциональности.


    Это большое заблуждение, что тесты всегда соответствуют (или по крайней мере стемятся) к паттерну "подготовка/действие/проверка".


    Часто никакая подготовка не нужна. Например, когда тестируется функция:


    console.assert( Math.pow( 2 , 3 ) === 8 )

    Не менее часто действие заключается именно в подготовке:


    wizard.nextStep().nextStep()
    console.assert( wizard.passport.isVisible === true )

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


    wizard.nextStep().nextStep()
    console.assert( wizard.passport.isVisible === false )
    
    wizard.toggleRegistration()
    console.assert( wizard.passport.isVisible === true )

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


    localStorage // local storage is available

    Последний пример, кстати, демонстрирует, почему плохо тестировать в headless chrome. Сафари в порно-режиме, например, кидает исключение при попытке доступа к локальному хранилищу. Поэтому прогонять тесты лучше через реальные бразузеры в реальных режимах использования. И делать это можно в том числе и через selenium — просто открываете селениумом страницу с тестранером, дожидаетесь появления отчёта и возвращаете его.


    1. dima117
      08.04.2018 12:18

      Привет! Вы правы, что не показаны сложные кейсы. Идея была — на примере простых кейсов показать, как тестировать код, зависящий от API браузера.

      это особенность вашего инстумента, а не предметной области

      Здесь не могу с вами согласиться. Можете привести аргументы?


      1. vintage
        08.04.2018 20:50
        -1

        Берём первый попавшийся пример и видим:


        {
            block: 'button',
            mods: { theme: 'islands', size: 's' },
            text: 'button',
            icon: { block: 'icon', mods: { action: 'download' } }
        },
        ' ',
        {
            block: 'button',
            mods: { theme: 'islands', size: 's' },
            icon: { block: 'spin', mods: { theme: 'islands', size: 'xs', visible: true } },
            text: 'Loading...'
        },

        Как это могло бы быть:


        <= Download $mol_button sub /
            <= Download_icon $mol_icon_load_down
            <= Download_text @ \button
        <= Loading $mol_button sub /
            <= Loading_icon $mol_icon_spin
            <= Loading_text @ \Loading...


    1. sky2high0
      08.04.2018 18:01

      Вам не удалось показать сложность предметной области.

      Хорошо же, когда сложная тема просто объясняется?)


  1. MiKXMan
    08.04.2018 18:22

    Зачем нужен headless chrome, если есть jsdom и тесты можно запускать без имитации браузера вообще? Более того решаются сразу проблемы с бандлингом, поскольку мы выполняем тесты в nodejs — все реквайры и импорты (после бейбла) работают нативно.

    И в этом случае можно использовать абсолютно все инструменты для моков — proxyquire, jest, rewire, fetch-mock, nock и т.д. с полным контролем. Более того — скорость запуска и работы этих тестов будет значительно выше + не надо тащить хром и кучу всякого дополнительного добра.


    1. dima117
      08.04.2018 18:45

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


    1. justboris
      08.04.2018 21:17
      +1

      В jsdom нет поддержки css. Например, element.offsetWidth всегда возвращает 0. Аналогично нули по всем параметрам возвращает element.getBoundingClientRect(). Если вам нужно тестировать что-то завязанное на эти значения, то jsdom вам не подойдет.


      1. MiKXMan
        09.04.2018 10:59

        Если у вас прямо все завязано на динамические вычисления — то да, но в большинстве случаев это не так. Вы ведь тестируете функциональность. Все эти методы (в том числе getBoundingClientRect()) можно довольно просто определить на возвращение не нулевого значения, причем для разных тестов разные, что дает хорошую гибкость.


        1. justboris
          09.04.2018 12:13

          Если вас устраивает константное значение, то можно зарефакторить свой код, использовать чистые функции, например: getPosition(element.getBoundingClientRect()). Такой код легко тестировать, передавая разные варианты через аргумент. В этой ситуации даже jsdom не понадобится.


          Обычно, когда говорят "нам нужен getBoundingClientRect", то действительно нужен более-менее честно работающий метод.


          1. MiKXMan
            09.04.2018 16:20

            Ну, например в интеграционных тестах иногда нужно проверить 3-rd party библиотеку, которой нужны размеры (virtualised table или responsive графики) и в большинстве случаев достаточно константного значения.
            Но соглашусь, что бывают кейсы, когда нужно большее. Но на мой взгляд их меньшинство, для них можно использовать настоящий браузер. Для всего остального более чем достаточно jsdom-а.


            1. justboris
              09.04.2018 19:54

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


              Если это вызывается где-то под капотом и напрямую на ваши тесты не влияет, то JSDOM нормально подойдет.


    1. nd0ut
      09.04.2018 10:27
      +1

      В jsdom практически весь Browser API реализован криво и не по спеке. Тот же HTMLImageElement, например.


      1. justboris
        09.04.2018 13:03

        Команда JSDOM прикладывает большие усилия по следованию спецификации. Главный ментейнер проекта, Доменик Деникола так же по совместительству является членом рабочей группы WHATWG.


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