Разворачиваем автоматизацию за пару часов: PHPUnit, Selenium, Composer

Привет, Хабр! Меня зовут Виталий Котов, я работаю в Badoo, в отделе QA. Большую часть времени занимаюсь автоматизацией тестирования. Недавно я столкнулся с задачей максимально быстро развернуть Selenium-тесты для одного из наших проектов. Условие было простое: код должен лежать в отдельном репозитории и не использовать наработки предыдущих автотестов. Ах, да, и нужно было обойтись без CI. При этом тесты должны были запускаться сразу после изменения кода проекта. Отчёт должен был приходить на почту.

Собственно, опытом такого развёртывания я и решил поделиться. Получился своего рода гайд «Как запустить тесты за пару часов».

Поехали!

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();
            $ArtefactsHelper = new ArtefactsHelper($this->driver);
            $this->data['screenshot'] = $ArtefactsHelper->takeLocalScreenshot($this->current_test_name);
            $this->data['source'] = $ArtefactsHelper->takeLocalSource($this->current_test_name);
        }
    }


Класс ArtefactsHelper, как нетрудно догадаться из названия, помогает собирать артефакты. Но о нём чуть позже. :)

А пока вернёмся к 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);
    }


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

Класс ArtefactsHelper


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

class ArtefactsHelper
{
    const ARTEFACTS_FOLDER_PATH = __DIR__ . '/../artefacts/';
    /** @var RemoteWebDriver */
    private $driver;

    public function __construct(RemoteWebDriver $driver)
    {
        if (!is_dir(self::ARTEFACTS_FOLDER_PATH)) {
            mkdir(self::ARTEFACTS_FOLDER_PATH);
        }
        $this->driver = $driver;
    }

    public function takeLocalScreenshot($name)
    {
        if ($this->driver) {
            $name = self::_escapeFileName($name) . time() . '.png';
            $path = self::ARTEFACTS_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::ARTEFACTS_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


А в классе 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-класс, который умеет генерить удобные отчёты;
  • написали тесты, которые можно запускать в разных браузерах и с разными параметрами;
  • создали механизм, который будет запускать эти тесты при изменении версии тестируемого проекта;
  • позаботились об отправке письма с отчётом и скриншотами;
  • добавили скрипт, который автоматически всё это дело обновляет до нужной версии.


Вроде ничего не упустили.

Такая вот история. Спасибо за внимание! Буду рад услышать ваши истории, пишите в комментариях. :)

© Habrahabr.ru