Привет, Хабр! Меня зовут Виталий Котов, я работаю в Badoo, в отделе QA. Большую часть времени занимаюсь автоматизацией тестирования. Недавно я столкнулся с задачей максимально быстро развернуть Selenium-тесты для одного из наших проектов. Условие было простое: код должен лежать в отдельном репозитории и не использовать наработки предыдущих автотестов. Ах, да, и нужно было обойтись без CI. При этом тесты должны были запускаться сразу после изменения кода проекта. Отчёт должен был приходить на почту.
Собственно, опытом такого развёртывания я и решил поделиться. Получился своего рода гайд «Как запустить тесты за пару часов».
Поехали!
![](https://habrastorage.org/r/w780q1/webt/dx/yh/gv/dxyhgvadu2qcde-jywg9lm1eowi.jpeg)
Прежде всего стоит декомпозировать задачу на несколько подзадач. Получается, что наша миссия, если мы возьмемся за её исполнение, заключается в следующем:
Вроде всё понятно.
В Badoo первые Selenium-тесты были написаны на PHP на основе фреймворка PHPUnit. Сервер Badoo по большей части написан на PHP и к моменту, когда появилась автоматизация, было решено не плодить технологии.
Для работы с Selenium тогда был выбран фреймворк от Facebook, но в какой-то момент мы так увлеклись добавлением туда своего функционала, что наша версия перестала быть совместимой с их.
Поскольку задача была срочная, я решил не экспериментировать с технологиями. Разве что выбрал фреймворк Facebook последней версии — интересно было, что там новенького.
Я скачал composer, с помощью которого собирать такой проект мне показалось удобнее:
Файл composer.json выглядел тогда так:
Первое, что требовалось сделать, — это написать свой TestCase-класс:
В нём появились функции setUp и tearDown, которые создавали и убивали Selenium-сессию, и функция onNotSuccessfulTest, которая обрабатывала данные упавшего теста:
В setUp всё довольно просто: мы создаём сессию, указав URL Selenium-фермы и желаемые capabilities. На этом этапе меня интересовал только браузер, на котором мы собирались гонять тесты.
C tearDown всё несколько хитрее.
Суть вот в чем. Для упавшего теста tearDown выполняется до того, как выполнится onNotSuccessfulTest. Следовательно, если мы хотим закрывать сессию в tearDown, все необходимые данные из неё стоит получить заблаговременно: текущая локация, скриншот и HTML-слепок, значения cookie и прочее. Все эти данные потребуются нам для формирования красивого и понятного отчёта.
Собирать данные, соответственно, следует только для упавших тестов, помня о том, что tearDown будет вызываться для всех тестов, включая успешно прошедшие, skipped и incomplete.
Сделать это можно как-то так:
Класс ArtifactsHelper, как нетрудно догадаться из названия, помогает собирать артефакты. Но о нём чуть позже. :)
А пока вернёмся к tearDown. В нём мы уже собрали все необходимые данные для упавшего теста, так что можно спокойно закрывать сессию.
Далее следует onNotSuccessfulTest, где нам понадобится ReflectionClass. Выглядит он так:
В сообщение исключения мы добавляем всю ту информацию, которую собирали перед закрытием сессии. Так нам будет намного удобнее разбираться в причине падения тестов.
Класс довольно простой, так что расскажу о нём очень коротко. Он создаёт файлы, которые называются как упавший тест плюс таймстемп, и помещает их в соответствующую папочку. После прогона всех тестов файлы добавляются в итоговый email с отчётом и удаляются. Выглядит это дело примерно так:
В конструкторе мы создаём нужную директорию, если её нет, и привязываем driver к локальному полю, чтобы было удобнее им пользоваться.
Методы takeLocalScreenshot и takeLocalSource создают файлы со скриншотом (.png) и HTML-слепком (.html). Они будут называться именем теста, только мы заменим часть символов на другие, чтобы название файла не смущало файловую систему.
Тесты у нас будут наследоваться от MyTestCase. Приводить примеры не буду — всё стандартно. Через $this->driver мы работаем с Selenium, а все assert’ы и прочее выполняем через $this.
Стоит сказать несколько слов про передачу параметров для запуска тестов. PHPUnit не даст при запуске теста из консоли добавить какой-то незнакомый ему параметр. А это было бы очень удобно, например, чтобы иметь возможность задавать желаемый браузер для тестов.
Я решил эту проблему следующим образом: создал папочку bin/ в корне проекта, куда положил исполняемый файл с названием phpunit следующего содержания:
А в классе MyCommand, соответственно, прописал желаемые параметры:
Теперь, если запускать тесты от нашего phpunit-файла, можно задавать параметры, которые будут передаваться в тесты в массив $GLOBALS['argv']. Дальше его можно парсить и как-то обрабатывать.
Итак, теперь у нас есть всё для того, чтобы начать запускать тесты по триггеру. К сожалению, репозитория с кодом проекта у нас нет, так что узнать, когда были совершены изменения в нём, не представляется возможным. Без помощи разработчиков тут не обойтись.
Мы договорились, что в тестовом окружении у приложения будет специальный адрес, по которому можно будет увидеть хеш последнего коммита (по сути, версия сайта).
Дальше всё просто: по cron запускаем специальный скриптик раз в пару минут. При первом запуске он идёт при помощи Curl по этому адресу и получает текущую версию сайта. Далее он создаёт в специальной директории файлик version.file, куда пишет эту версию. В следующий раз он получает версию и с сайта, и из файлика; если они отличаются, записывает новую версию в файл и запускает тесты, Если нет — не делает ничего.
В итоге всё выглядит примерно так:
К сожалению, в PHPUnit из класса TestCase невозможно определить, последний ли тест прошёл в сьюте или нет. Конечно, там есть метод tearDownAfterClass, но он выполняется после завершения тестов в одном классе. Если в сьюте указаны, например, два класса с тестами, tearDownAfterClass исполнится дважды.
Мне же нужно было где-то прописать логику, которая будет отправлять письмо гарантированно после прохождения всех тестов. И, конечно, делать это только один раз. Как вы уже догадались, я написал очередной хелпер. :)
Этот класс хранит в себе информацию о прошедших тестах: тексты ошибок, пути до файла со скриншотом и HTML-слепком. Он сделан по принципу Singleton, инстанцируется единожды при первом вызове. И не уничтожается принудительно. Понимаете, к чему я веду? :)
Всё верно, логику отправки письма я добавил в деструктор. Когда процесс прогона тестов завершается, приходит сборщик мусора и уничтожает мой класс. Следовательно, деструктор срабатывает в самый последний момент.
В зависимости от того, прошли тесты успешно или нет, меняется заголовок письма. Если версия сайта менялась в течение прогона, тесты запускаются повторно.
Ну и напоследок немного удобства. Так как вся эта система будет жить где-то на удалённом сервере, было бы удобно, если бы она сама умела делать git pull, чтобы случайно не забыть подмёржить важные изменения в тестах.
Для этого создаём исполняемый файлик следующего содержания:
Скрипт исполнит команду git pull, и, если что-то пойдёт не так и ему это не удастся, напишет письмо ответственному сотруднику.
Дальше добавляем скрипт в cron, запуская раз в пару минут, — и дело в шляпе.
Итоги обычно подводят с оглядкой на изначальную задачу. Вот что у нас получается:
Вроде ничего не упустили.
Такая вот история. Спасибо за внимание! Буду рад услышать ваши истории, пишите в комментариях. :)
Собственно, опытом такого развёртывания я и решил поделиться. Получился своего рода гайд «Как запустить тесты за пару часов».
Поехали!
![](https://habrastorage.org/webt/dx/yh/gv/dxyhgvadu2qcde-jywg9lm1eowi.jpeg)
Условия задачи
Прежде всего стоит декомпозировать задачу на несколько подзадач. Получается, что наша миссия, если мы возьмемся за её исполнение, заключается в следующем:
- нужен отдельный репозиторий;
- в нём должны лежать тесты;
- в нём должен лежать некий механизм, который будет запускать тесты по изменению кода проекта;
- отчёт должен быть читаемым, удобным и приходить на почту указанным людям.
Вроде всё понятно.
Стек
В Badoo первые Selenium-тесты были написаны на PHP на основе фреймворка PHPUnit. Сервер Badoo по большей части написан на PHP и к моменту, когда появилась автоматизация, было решено не плодить технологии.
Для работы с Selenium тогда был выбран фреймворк от Facebook, но в какой-то момент мы так увлеклись добавлением туда своего функционала, что наша версия перестала быть совместимой с их.
Поскольку задача была срочная, я решил не экспериментировать с технологиями. Разве что выбрал фреймворк Facebook последней версии — интересно было, что там новенького.
Я скачал composer, с помощью которого собирать такой проект мне показалось удобнее:
wget https://phar.phpunit.de/phpunit.phar
Файл composer.json выглядел тогда так:
{
"require-dev": {
"phpunit/phpunit": "5.3.*",
"facebook/webdriver": "dev-master"
}
}
Класс MyTestCase
Первое, что требовалось сделать, — это написать свой TestCase-класс:
require_once __DIR__ . '/../../vendor/autoload.php';
class MyTestCase extends \PHPUnit_Framework_TestCase
В нём появились функции setUp и tearDown, которые создавали и убивали Selenium-сессию, и функция onNotSuccessfulTest, которая обрабатывала данные упавшего теста:
/** @var RemoteWebDriver $driver */
protected $driver;
protected function setUp() {}
protected function tearDown() {}
protected function onNotSuccessfulTest($e) {}
В setUp всё довольно просто: мы создаём сессию, указав URL Selenium-фермы и желаемые capabilities. На этом этапе меня интересовал только браузер, на котором мы собирались гонять тесты.
protected function setUp()
{
$this->driver = RemoteWebDriver::create(
'http://selenium-farm:5555/wd/hub',
[WebDriverCapabilityType::BROWSER_NAME => WebDriverBrowserType::FIREFOX]
);
}
C tearDown всё несколько хитрее.
protected function tearDown()
{
if ($this->driver) {
$this->_prepareDataOnFailure();
$this->driver->quit();
}
}
Суть вот в чем. Для упавшего теста tearDown выполняется до того, как выполнится onNotSuccessfulTest. Следовательно, если мы хотим закрывать сессию в tearDown, все необходимые данные из неё стоит получить заблаговременно: текущая локация, скриншот и HTML-слепок, значения cookie и прочее. Все эти данные потребуются нам для формирования красивого и понятного отчёта.
Собирать данные, соответственно, следует только для упавших тестов, помня о том, что tearDown будет вызываться для всех тестов, включая успешно прошедшие, skipped и incomplete.
Сделать это можно как-то так:
private function _prepareDataOnFailure()
{
$error_and_failure_statuses = [
PHPUnit_Runner_BaseTestRunner::STATUS_ERROR,
PHPUnit_Runner_BaseTestRunner::STATUS_FAILURE
];
if (in_array($this->getStatus(), $error_and_failure_statuses)) {
$this->data['url'] = $this->driver->getCurrentURL();
$ArtifactsHelper = new ArtifactsHelper($this->driver);
$this->data['screenshot'] = $ArtifactsHelper->takeLocalScreenshot($this->current_test_name);
$this->data['source'] = $ArtifactsHelper->takeLocalSource($this->current_test_name);
}
}
Класс ArtifactsHelper, как нетрудно догадаться из названия, помогает собирать артефакты. Но о нём чуть позже. :)
А пока вернёмся к tearDown. В нём мы уже собрали все необходимые данные для упавшего теста, так что можно спокойно закрывать сессию.
Далее следует onNotSuccessfulTest, где нам понадобится ReflectionClass. Выглядит он так:
protected function onNotSuccessfulTest($e)
{
//prepare message
$message = $this->_prepareCuteErrorMessage($e->getMessage());
//set message
$class = new \ReflectionClass(get_class($e));
$property = $class->getProperty('message');
$property->setAccessible(true);
$property->setValue($e, PHP_EOL . $message);
parent::onNotSuccessfulTest($e);
}
В сообщение исключения мы добавляем всю ту информацию, которую собирали перед закрытием сессии. Так нам будет намного удобнее разбираться в причине падения тестов.
Класс ArtifactsHelper
Класс довольно простой, так что расскажу о нём очень коротко. Он создаёт файлы, которые называются как упавший тест плюс таймстемп, и помещает их в соответствующую папочку. После прогона всех тестов файлы добавляются в итоговый email с отчётом и удаляются. Выглядит это дело примерно так:
class ArtifactsHelper
{
const ARTIFACTS_FOLDER_PATH = __DIR__ . '/../artifacts/';
/** @var RemoteWebDriver */
private $driver;
public function __construct(RemoteWebDriver $driver)
{
if (!is_dir(self::ARTIFACTS_FOLDER_PATH)) {
mkdir(self::ARTIFACTS_FOLDER_PATH);
}
$this->driver = $driver;
}
public function takeLocalScreenshot($name)
{
if ($this->driver) {
$name = self::_escapeFileName($name) . time() . '.png';
$path = self::ARTIFACTS_FOLDER_PATH . $name;
$this->driver->takeScreenshot($path);
return $path;
}
return '';
}
public function takeLocalSource($name)
{
if ($this->driver) {
$name = self::_escapeFileName($name) . time() . '.html';
$path = self::ARTIFACTS_FOLDER_PATH . $name;
$html = $this->driver->getPageSource();
file_put_contents($path, $html);
return $path;
}
return '';
}
private static function _escapeFileName($file_name)
{
$file_name = str_replace(
[' ', '#', '/', '\\', '.', ':', '?', '=', '"', "'", ":"],
['_', 'No', '_', '_', '_', '_', '_', '_', '', '', '_'],
$file_name
);
$file_name = mb_strtolower($file_name);
return $file_name;
}
}
В конструкторе мы создаём нужную директорию, если её нет, и привязываем driver к локальному полю, чтобы было удобнее им пользоваться.
Методы takeLocalScreenshot и takeLocalSource создают файлы со скриншотом (.png) и HTML-слепком (.html). Они будут называться именем теста, только мы заменим часть символов на другие, чтобы название файла не смущало файловую систему.
Тесты
Тесты у нас будут наследоваться от MyTestCase. Приводить примеры не буду — всё стандартно. Через $this->driver мы работаем с Selenium, а все assert’ы и прочее выполняем через $this.
Стоит сказать несколько слов про передачу параметров для запуска тестов. PHPUnit не даст при запуске теста из консоли добавить какой-то незнакомый ему параметр. А это было бы очень удобно, например, чтобы иметь возможность задавать желаемый браузер для тестов.
Я решил эту проблему следующим образом: создал папочку bin/ в корне проекта, куда положил исполняемый файл с названием phpunit следующего содержания:
#!/local/php/bin/php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../lib/MyCommand.php';
MyCommand::main();
А в классе MyCommand, соответственно, прописал желаемые параметры:
class MyCommand extends PHPUnit_TextUI_Command
{
protected function handleArguments(array $argv)
{
$this->longOptions['platform='] = null;
$this->longOptions['browser='] = null;
$this->longOptions['local'] = null;
$this->longOptions['proxy='] = null;
$this->longOptions['send-report'] = null;
parent::handleArguments($argv);
}
}
Теперь, если запускать тесты от нашего phpunit-файла, можно задавать параметры, которые будут передаваться в тесты в массив $GLOBALS['argv']. Дальше его можно парсить и как-то обрабатывать.
Запуск по изменению кода проекта
Итак, теперь у нас есть всё для того, чтобы начать запускать тесты по триггеру. К сожалению, репозитория с кодом проекта у нас нет, так что узнать, когда были совершены изменения в нём, не представляется возможным. Без помощи разработчиков тут не обойтись.
Мы договорились, что в тестовом окружении у приложения будет специальный адрес, по которому можно будет увидеть хеш последнего коммита (по сути, версия сайта).
Дальше всё просто: по cron запускаем специальный скриптик раз в пару минут. При первом запуске он идёт при помощи Curl по этому адресу и получает текущую версию сайта. Далее он создаёт в специальной директории файлик version.file, куда пишет эту версию. В следующий раз он получает версию и с сайта, и из файлика; если они отличаются, записывает новую версию в файл и запускает тесты, Если нет — не делает ничего.
В итоге всё выглядит примерно так:
function isVersionChanged($domain)
{
$url = $domain . 'version';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
if ($proxy = SeleniumConfig::getInstance()->getProxy()) {
curl_setopt($ch, CURLOPT_PROXY, $proxy);
}
$response = curl_exec($ch);
curl_close($ch);
$version_from_site = trim($response);
$version_from_file = file_get_contents(VERSION_FILE);
return ($version_from_site != $version_from_file);
}
Отправка письма с отчётом
К сожалению, в PHPUnit из класса TestCase невозможно определить, последний ли тест прошёл в сьюте или нет. Конечно, там есть метод tearDownAfterClass, но он выполняется после завершения тестов в одном классе. Если в сьюте указаны, например, два класса с тестами, tearDownAfterClass исполнится дважды.
Мне же нужно было где-то прописать логику, которая будет отправлять письмо гарантированно после прохождения всех тестов. И, конечно, делать это только один раз. Как вы уже догадались, я написал очередной хелпер. :)
Класс Mailer
Этот класс хранит в себе информацию о прошедших тестах: тексты ошибок, пути до файла со скриншотом и HTML-слепком. Он сделан по принципу Singleton, инстанцируется единожды при первом вызове. И не уничтожается принудительно. Понимаете, к чему я веду? :)
public function __destruct()
{
if ($this->send_email) {
$this->sendReport($this->tests_failed, $this->tests_count);
}
}
private function sendReport(array $report, $tests_count)
{
$count = count($report);
$is_success_run = $count == 0;
// start message
if ($is_success_run) {
$message = "All tests run successfully! Total amount: {$tests_count}.";
$subject = self::REPORT_SUBJECT_SUCCESS;
} else {
$message = "Autotests failed for project. Failed amount: {$count}, total amount: {$tests_count}.";
$subject = self::REPORT_SUBJECT_FAILURE;
}
$message .= PHP_EOL;
$start_version = VersionStorage::getInstance()->getStartVersion();
$finish_version = VersionStorage::getInstance()->getFinishVersion();
if ($start_version == $finish_version) {
$message .= 'Application version: ' . $start_version . PHP_EOL;
foreach ($report as $testname => $text) {
$message .= PHP_EOL . $testname . PHP_EOL . trim($text) . PHP_EOL;
}
} else {
$message .= PHP_EOL;
$message .= "***APPLICATION VERSION HAS BEEN CHANGED***" . PHP_EOL;
$message .= "Version on start: {$start_version}" . PHP_EOL;
$message .= "Current version: {$finish_version}" . PHP_EOL;
$message .= "TESTS WILL BE RE-LAUNCHED IN FEW MINUTES.";
$subject = self::REPORT_SUBJECT_FAILURE;
}
// end message
foreach (self::$report_recipients as $email_to) {
$this->_sendMail($email_to, self::EMAIL_FROM, $subject, $message);
}
Всё верно, логику отправки письма я добавил в деструктор. Когда процесс прогона тестов завершается, приходит сборщик мусора и уничтожает мой класс. Следовательно, деструктор срабатывает в самый последний момент.
В зависимости от того, прошли тесты успешно или нет, меняется заголовок письма. Если версия сайта менялась в течение прогона, тесты запускаются повторно.
Автопул тестов
Ну и напоследок немного удобства. Так как вся эта система будет жить где-то на удалённом сервере, было бы удобно, если бы она сама умела делать git pull, чтобы случайно не забыть подмёржить важные изменения в тестах.
Для этого создаём исполняемый файлик следующего содержания:
#! /usr/bin/env bash
cd `dirname "$0"`
output=$(git -c gc.auto=0 pull -q origin master 2>&1)
if [ ! $? -eq 0 ]; then
echo "${output}" | mail -s "Failed to update selenium repo on selenium-server" username@corp.badoo.com
fi
Скрипт исполнит команду git pull, и, если что-то пойдёт не так и ему это не удастся, напишет письмо ответственному сотруднику.
Дальше добавляем скрипт в cron, запуская раз в пару минут, — и дело в шляпе.
Итоги
Итоги обычно подводят с оглядкой на изначальную задачу. Вот что у нас получается:
- появился отдельный репозиторий;
- там при помощи сomposer мы собрали проект: скачали PHPUnit и фреймворк Facebook;
- написали свой TestCase-класс, который умеет генерить удобные отчёты;
- написали тесты, которые можно запускать в разных браузерах и с разными параметрами;
- создали механизм, который будет запускать эти тесты при изменении версии тестируемого проекта;
- позаботились об отправке письма с отчётом и скриншотами;
- добавили скрипт, который автоматически всё это дело обновляет до нужной версии.
Вроде ничего не упустили.
Такая вот история. Спасибо за внимание! Буду рад услышать ваши истории, пишите в комментариях. :)