Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

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

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

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

Поехали!



Условия задачи


Прежде всего стоит декомпозировать задачу на несколько подзадач. Получается, что наша миссия, если мы возьмемся за её исполнение, заключается в следующем:

  • нужен отдельный репозиторий;
  • в нём должны лежать тесты;
  • в нём должен лежать некий механизм, который будет запускать тесты по изменению кода проекта;
  • отчёт должен быть читаемым, удобным и приходить на почту указанным людям.
Вроде всё понятно.

Стек


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

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