Selenium Manager: история одного интерфейса
Привет, Хабр!
Меня зовут Виталий Котов и я работаю в компании Badoo. В одной из предыдущих статей я рассказывал, что у нас есть некий интерфейс, который помогает взаимодействовать с автотестами как тестировщикам, так и разработчикам.
Не раз и не два меня просили рассказать о нём подробнее.
Под катом я (наконец!) расскажу о том, как писал этот интерфейс и что он умеет. Расскажу о фичах, которые прижились, и о тех, которые оказались невостребованными по тем или иным причинам. Возможно, некоторые идеи вам покажутся интересными, и вы тоже задумаетесь о подобном «помощнике».
С чего всё началось
Итак, началось всё с Selenium-тестов. Отсюда название интерфейса — Selenium Manager. Хотя сейчас в нём можно запускать также Smoke-тесты.
В какой-то момент мы поняли, что наши тесты умеют много всего. А понятно это стало по количеству дополнительных ключей, которые можно было указать при запуске, чтобы тесты выполнялись тем или иным образом. Вот лишь некоторые из них:
- Platform — определяет, на какой платформе (девел, шот или стейджинг) мы запускаем тесты. О том, какие этапы тестирования есть у нас в компании можно почитать в этой статье.
- App — определяет, против какого из наших приложений будет запущен тест. Поскольку тест хранит в себе только бизнес-логику, отдельную от PageObject, этот ключ довольно полезный.
- Browser — задаёт браузер, на котором будет запущен тест.
- Local — если указать этот ключ, тест пойдёт на локально поднятый Selenium-сервер вместо Selenium-фермы.
- Help — если указать этот ключ, вместо запуска теста мы увидим инструкцию по всем ключам. :)
И так далее. В общей сложности я насчитал 25 ключей. И для 11 из них можно указать более двух вариантов значений. Это много, и не хотелось держать всё это в голове или пользоваться без конца командой «Help».
Первая версия
Первая версия интерфейса появилась в 2014 году. По моей задумке, он должен был просто парсить все возможные ключи и значения из конфига и рисовать соответствующий набор HTML-элементов в браузере. После того как пользователь выставлял необходимый набор параметров и нажимал на кнопку «получить команду запуска», он видел на экране соответствующую строчку, которую можно было скопировать в терминал.
За вечер «накидав» прототип, я пошёл показывать его ребятам. Конечно, всем понравилось, «но, может быть, можно сделать так, чтобы тест сразу и запускался из этого интерфейса?» — был ответ. Довольно ожидаемый, конечно.
Я начал думать в эту сторону…
Запуск теста из интерфейса
Наши тесты написаны на PHP. В качестве «пускалки» мы используем PHPUnit. Идея для запуска теста из консоли была следующая: мы выставляем необходимый набор параметров, указываем тест и AJAX-запросом отправляем на сервер, где будет выполняться код нашего теста.
На стороне сервера формируется строка-команда, которая будет запускаться при помощи команды exec. Не забываем отвязать команду от консоли, иначе exec будет выполняться столько, сколько выполняется сам тест. Нам это не нужно — нам нужно получить только PID процесса.
Примерно так:
function launchTest($cmd, $logfile)
{
$cmd = escapeshellcmd($cmd);
exec($command = 'sh -c "' . $cmd . '" > ' . $logfile . ' & echo $!', $out);
if (count($out) !== 1) {
die("Wrong launch command: {$command}"]);
}
return $out[0];
}
Как видно из контекста, мы предварительно создаём logfile, куда будем писать лог по ходу прохождения теста.
Получив PID, мы возвращаем его и путь до лог файла клиенту, который запускает простенький setInterval. Раз в N секунд клиент стучится на сервер и получает актуальное содержимое лог файла и статус PID. Если PID пропал, вызывается clearInterval. Совсем просто:
var interval_id;
function startPidProcessing() {
interval_id = setInterval(self.checkPid, 4500);
}
function stopPidProcessing() {
clearInterval(interval_id);
interval_id = null;
}
Таким образом мы видим прогресс прохождения теста. И знаем, когда он завершился.
Запуск нескольких тестов параллельно
В далёком 2013 году PHPUnit не умел запускать тесты параллельно. И это было серьёзной проблемой, поскольку уже тогда у нас было столько Selenium-тестов, что в один поток они могли идти не один час. Тогда же мой коллега Илья Кудинов писал статью о том, как мы начали решать эту задачу для юнит-тестов.
Но итоговое решение не очень подошло для Selenium-тестов.
Оптимальным решением стал Selenium Manager. Ведь если из него можно запустить один тест, то почему бы не открыть новую вкладку и не запустить второй? Шучу…
Это можно сделать из той же вкладки. Для этого достаточно хранить на стороне клиента список PID«ов для каждого из тестов и при запросе на сервер опрашивать их все, возвращая статус для каждого. С лог-файлами поступать аналогично.
Я добавил в интерфейс возможность выставить количество запускаемых одновременно тестов. По сути, это количество потоков, в которых будут запускаться тесты. Если потоков меньше, чем итоговое количество тестов, то тесты становятся в очередь и ждут, когда пройдёт один из запущенных, чтобы занять его место.
Запуск тестов по filter и group
Этого оказалось недостаточно. Теперь нужно было открывать терминал и копировать оттуда пути до всех необходимых файлов с тестами. Тем более, иногда хочется запустить не все тесты из файла, а только некоторые (для этого в PHPUnit существует параметр filter).
Для решения этой проблемы я на клиенте сделал простой HTML-элемент textarea, куда можно было писать только названия классов, а через »::» указывать фильтр. Например, так:
Туда можно было написать что угодно и даже скопировать список упавших тестов из TeamCity. Всё это отправлялось на сервер и парсилось. Как определить, похоже ли какое-то сочетание на название класса, в котором есть такой-то тест? А затем определить путь до файла с этим классом, чтобы запустить тест?
Мой метод получился примерно таким:
public static function parseTestNames($textarea)
{
$parts = preg_split('/[,\s\n]+/', $textarea);
$tests = [];
foreach ($parts as $part) {
$part_splited = explode('::', $part);
$filter = $part_splited[1] ?? false;
$testname = $part_splited[0];
if (is_file($testname)) {
$testname = $filter ? $testname . '::' . $filter : $testname;
$tests[] = $testname;
} else {
$found_tests = self::findPathByClassName($testname, $filter, $hard);
foreach ($found_tests as $test) {
$test = $filter ? $test . '::' . $filter : $test;
$tests[] = $test;
}
}
}
$result = array_values(array_unique($tests));
return $result;
Содержимое мы разбиваем по знакам пробела или переноса. Далее поочерёдно обрабатываем каждую из частей. В ней мы в первую очередь ищем сочетание »::», чтобы получить значение для фильтра.
Далее мы смотрим на первую часть (до знака »::»). Если она указана в виде пути до файла, запускаем его. Если нет (например, был указан только класс) — запускаем метод findPathByClassName, который умеет искать по названию классов в тестовых папках и возвращать путь до необходимого.
В PHPUnit есть возможность задавать тестам группы (об этом тут). И, соответственно, запускать тесты, указав эти группы. У нас эти группы привязаны к фичам на сайте, которые эти тесты покрывают. Чаще всего, когда требуется запустить много тестов (но не все), нужно запустить их для какой-то группы или групп.
Я добавил список групп, который получается интерактивно с помощью того же поиска, с возможностью выбрать несколько из них и запустить тесты, не указывая никаких путей и названий классов.
Интерфейс для запуска тестов по группам выглядит примерно так:
Теперь можно запускать тесты либо по группам, либо по списку названий этих тестов.
Сами же запущенные тесты выглядят так:
Тест помечается жёлтым цветом, если он уже запущен; зелёным — если он прошёл успешно, красным — если упал. Синим цветом помечены тесты, которые прошли успешно, но внутри которых есть skipped тесты.
Внутри ячеек — обычный лог, который мы видим при запуске теста через PHPUnit:
Определить, прошёл тест успешно или нет, можно довольно просто на стороне клиента, используя регулярные выражения:
function checkTestReport(reg_exp) {
let text = $(cell_id).html();
let text_arr = text.split("\n");
if (text_arr[0].search('PHPUnit') != -1) {
return reg_exp.test(text_arr[2]);
} else {
return -1;
}
}
this.isTestSuccessful = function(cell_id) {
return checkTestReport(/^[\.]+\s/);
};
this.isTestSkipped = function(cell_id) {
return checkTestReport(/^[\.SI]+\s/);
};
Это не самое отказоустойчивое решение. Если мы однажды перестанем использовать PHPUnit, или у него существенно изменится строка вывода, метод перестанет работать (об этом мы обязательно узнаем, получив ответ »-1»). Но ни то, ни другое в ближайшее время, скорее всего, не произойдёт.
Запуск тестов в «облаке»
Пока я делал интерфейс для запуска тестов, мы активно начали запускать тесты для шотов. Оказалось, что запустить столько тестов, сколько нам хочется, в TeamCity непросто. Агентов не хватает, а стоит это дорого.
Тогда мы перевели запуск тестов на «облачную» систему. О ней мы, возможно, напишем подробную статью в следующий раз. А пока я остановлюсь на том, что, поскольку эта система — «облачная», собирать логи с неё проблематично.
Мы создали простенькую MySQL-табличку, куда тесты в конце прогона начали логировать результаты: прошёл тест успешно или упал, с какой ошибкой, сколько он длился и так далее.
На основе этой таблицы на другой вкладке Selenium Manager«а мы начали рисовать результаты прогонов по названию шота. С возможностью прямо из интерфейса перезапустить упавший тест (если тест проходит успешно, он пропадает из списка упавших) или узнать, падал ли этот тест на других шотах с такой же ошибкой (определяется по трейсу ошибки):
Можно группировать тесты либо по типу ошибки (всё тот же трейс), либо расставлять в алфавитном порядке.
Система инвестигейтов
Помимо описанных выше страничек, я бы хотел подробно остановиться ещё на одной. Это страница инвестигейтов. Бывает так, что важная задача попадает в тестирование с незначительным багом. Скажем, JS-ошибкой, не влияющей на пользователя. Откатывать такую задачу нецелесообразно. Мы поступаем следующим образом: ставим на разработчика баг-тикет, а задачу пропускаем дальше.
Но что делать с тестом, который теперь будет честно падать и на стейджинге, и на шотах, мешая жить и засоряя логи? Мы знаем, почему он падает, мы знаем, что, пока задачу не сделают, он будет продолжать падать. Стоит ли тратить время на то, чтобы его запускать и получать ожидаемый результат?
Я решил, что можно решить проблему следующим способом: всё в той же MySQL создать табличку, в которую через интерфейс будет добавляться тест с указанием тикета, из-за которого он сломан.
Тесты перед запуском будут получать этот список и сравнивать своё название с названиями из него. Если тесту не надо запускаться, он будет помечен как skipped с указанием соответствующей задачи в логе.
Также я написал простенький скрипт, который по крону ходит в эту табличку, получает список задач и идёт в JIRA (наш багтрекер) за статусами для них. Если какая-то задача закрылась, запись из таблицы инвестигейтов удаляется, тест начинает запускаться автоматически.
Что ещё умеет Selenium Manager
Помимо перечисленного выше, Selenium Manager умеет ещё кучу интересных и не менее полезных вещей.
Например, получать список всех шотов для текущего билда и запускать указанный тест для каждого из них. Это крайне полезно, когда на стейджинге начал падать тест, а определить руками и глазами виновный тикет не удалось.
Ещё есть страничка с таблицей, где представлен список нестабильных тестов и самых частых ошибок (для разных тестов). Она полезна для инженеров по автоматизации, так как помогает держать тесты в порядке. Не стоит забывать, что Selenium-тесты, как и любые другие UI-тесты, по определению нестабильны, и это нормально. Но иметь статистику самых «плохих» тестов с целью их стабилизации — это хорошо.
Напротив каждого теста в этой табличке есть кнопка, при помощи которой можно в один клик создать и перевести на себя тикет по стабилизации теста. Ссылка на тикет останется в таблице, так что будет видно, если тестом уже кто-то занимается.
Выглядит это примерно так:
Есть страничка, на которой живут графики нод Selenium-фермы. Наш инженер по автоматизации Артём Солдаткин уже рассказывал о том, как пропатчить Selenium, чтобы по HTTP-запросу была возможность получить данные о количестве свободных браузеров, сгруппированных по этим браузерам и версиям. Подробнее об этом тут.
Я написал простенький скрипт, который по крону ходит на Selenium-ферму и собирает эту информацию, складывая её в табличку MySQL. На стороне клиента я использовал plotly.js, чтобы по этим данным рисовать графики.
Выглядит примерно так:
Так что есть возможность узнать, хватает ли нам мощностей на все наши нужды. :)
Какие фичи не прижились
Чаще всего нельзя заранее сказать, будет фича полезной или нет, пока не попробуешь. Иногда полезная на первый взгляд идея оказывается невостребованной.
Например, у нас была страница, где каждый желающий мог подписаться на конкретный тест или группу тестов. Если эти тесты начинали падать на стейджинге, всем подписавшимся приходили уведомления в чат.
Изначально задумывалось, что это будет полезно для задач, которые нельзя полностью протестировать на девеле или шоте, когда ручной тестировщик сам следит за состоянием тестов. Подписавшись, он мог понять, что подходящая под описание группа тестов сломалась на стейджинге и что с задачей есть какие-то проблемы.
На деле же оказалось, что предсказать группу тестов сложно. Чаще всего приходило много лишних уведомлений, а иногда необходимые уведомления не приходили, потому что упавшие тесты были из других групп.
В итоге мы вернулись к прежней схеме, где автоматизаторы сами оповещают ребят из отдела тестирования, что есть какие-то проблемы.
Итоги
Интерфейс был удачно интегрирован в процесс тестирования. Теперь можно легко, просто и быстро запускать тесты с любыми параметрами, следить за их стабильностью и за всей системой автотестов в целом.
В целом я доволен, что получилось уйти от консоли. Не потому, что в ней есть что-то плохое, а просто потому, что интерфейс в данном случае экономит кучу времени, которое можно потратить с пользой.
Какой вывод из этого можно сделать? Оптимизируйте и упрощайте работу с вашими инструментами — и будет вам счастье.
Спасибо за внимание.