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

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

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

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


  1. Gemorroj
    14.02.2018 19:17
    +1

    Почему бы хотябы так не сделать, если прям именно такие версии нужны (совсем не понял про завязку на phpunit 5.3)?


    {
        "require-dev": {
            "phpunit/phpunit": "5.3.*",
            "facebook/webdriver": "1.5.*"
        }
    }


    1. nizkopal Автор
      14.02.2018 19:26

      Вы знаете, я думаю, что я тогда указал ту же версию PHPUnit, что у нас была для основных тестов. Просто чтобы «без сюрпризов». В итоге версия PHPUnit не сыграла существенной роли в этом проекте.


    1. nizkopal Автор
      14.02.2018 19:28

      Поменял на «5.3.*» в описании, спасибо!


  1. arkamax
    14.02.2018 21:38

    С легаси тестами все понятно, но если поднимать все с нуля с Selenium, то почему не на Codeception? В основе тот же phpunit, вплоть до того, что тесты на phpunit работают под Codeception — но там тот же WebDriver уже прикручен и обвешан хелперами, что экономит кучу времени. HTML source и скриншоты при ошибках тоже сохраняются. Также не совсем понятна причина отказа от CI — тот же Jenkins очень удобно пинать из BitBucket по триггерам (например, на каждый pull request), но это может оказаться вопросом бизнес-необходимости.

    P.S. При желании весь тестовый стек для Codeception, включая Selenium head, поднимается за час в виде кластера контейнеров на Docker. Результатом оказывается портативные и легко версионируемые тесты, которые можно запускать одновременно в нескольких копиях, если есть такое желание.


    1. nizkopal Автор
      14.02.2018 21:49

      Привет. Спасибо за Ваш комментарий.

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

      Я был уверен, что кто-то обязательно подумает про Codeception и потому добавил:

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


      Но меня это не спасло. :)

      Что касается отказа от CI — там все просто. Код проекта так или иначе находился в другом окружении, нежели тесты, следовательно, тестирование было по принципу black-бокса. CI не решил бы проблему отсутствия триггера, все равно пришлось бы лепить свой воркэраунт. А причастным к проекту людям первое время отчет было удобнее получать в виде писем. Со временем проект подрос и сейчас все крутится на том же ТимСити, где и остальные тесты.


      1. arkamax
        14.02.2018 23:04

        Всё понятно, спасибо. Я спросил потому, что за время работы с Codeception vs. phpunit не увидел чего-то такого, что шокировало бы меня разницей в подходах. Кроме того, phpunit тесты отлично импортируются напрямую в Codeception. Но тут, видимо, были внутренние причины так не делать. Спасибо за уточнения!


      1. Davert
        15.02.2018 17:45

        Вы бы сэкономили гораздо больше времени если бы взяли Codeception :)


        PHPUnit + WebDriver конечно надежная связка, но изначально было понятно, что без костылей там не обойдется.


        Собственно почему Codeception смог бы быстрее решить задачу:


        • сохранение артефактов и отчеты по умолчанию
        • возможность запуска тестов с разной конфигурацией из коробки
        • из коробки почту не шлет, но есть екстеншн который это делает https://github.com/Codeception/Notifier (его скорее всего не хватит и потому достаточно просто можно всё расширить через екстеншны).

        А чтобы это не казалось рекламой — закончу небольшой жизой. И Codeception и PHPUnit это сверхтяжелые фреймворки в которые порой бывает сложно куда-то впихнуться, в особенности когда нужно сделать что-то эдакое. И то и то надо изучать, а потом смотреть. Codeception в плане расширения предоставляет гораздо больше механизмов чтобы сделать всё что нужно и сделать это не костылями, а елегантно (элегантными костылями хе-хе). Да и API для PHPUnit по сути не обновлялся последние 10 лет...


        1. nizkopal Автор
          15.02.2018 18:03

          Хм. Интересное дополнение, спасибо.

          Мой опыт работы с Codeception маловат. Не потому, что я имею что-то против него. Просто у нас сейчас свой фреймворк, основанный на версии Facebook Webdriver библиотеке шестилетней давности. И запускаем мы эти тесты на PHPUnit, вокруг которого у нас существует множество своих решений, обвязок и прочих ништяков.

          Напомню, что задачка была срочно и «я решил не экспериментировать с технологиями». Однако я все больше склоняюсь к тому, чтобы повнимательнее поизучать Codeception либо в свободное время, либо на проекте с более свободным графиком.

          В любом случае еще раз спасибо за Ваш комментарий. :)


  1. jumale
    14.02.2018 21:50

    Берем codeception фреймворк. Минута на установку, еще минута на бутстрап проекта и 5 минут на конфигурацию в yml (если новичок, то пол часа на прочтение getting started). Получаем готовый к использованию сетап с понятным интересом, соединенный с селениумом, прекрасными html репортами, включая скриншоты браузера, и всеми нужными ивентами (before/after step, before/after test, before/after suite). Остается дописать свое расширение которое будет слать репорт по почте. Ну и крон можно по тому же принципу что и в статье реализовать.


    1. nizkopal Автор
      14.02.2018 21:55

      Спасибо за комментарий.

      В целом про Codeception я ответил выше. Из Вашего же описания выходит, что, установив Codeception, я бы все равно был бы вынужден писать всю ту же логику. Просто сэкономил бы время на описании tearDown, setUp и onNotSuccessful тест. Как мне кажется, это не так принципиально. :)

      Будет здорово, если Вы напишете свою статью про Ваш любимый стек. Я с интересом ее почитаю.


  1. yetanotherman
    15.02.2018 16:24

    Все же абсолютно любой CI-сервер решает проблемы с доставкой репорта, триггер в jenkins по изменению чего-либо на сайте настраивается на раз (если я ничего не забыл, пользовался этой фичей раз в жизни), скрипт в кроне с выкачиванием из git тоже становится не нужен.

    У вас, в принципе, тоже не сложно, но не понятно — в чем проблема была взять готовое :/


    1. nizkopal Автор
      15.02.2018 16:55

      Привет.

      Не очень понял Ваш комментарий. Скрипт в cron ничего не качал с git. Он только по http ходил по специальному адресу, где отдавался последний хеш-комит. Более того, в окружении тестов не было никакого доступа к репозиторию приложения. Ни доступов к его git, ни к исходникам, вообще ничего. CI не решил бы проблему триггерить что-либо по изменению в коде, к которому у него нет доступа.

      У меня получилось описать суть проблемы более понятно?


      1. yetanotherman
        16.02.2018 02:42

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

        Билд-триггер, который делает то, что вам нужно: plugins.jenkins.io/urltrigger (Считается md5 содержимого ответа, если оно изменилось с прошлого раза — стартует билд. Так же поддерживает триггер по заголовку last modified)

        Про гит я говорил в контексте того, что не нужно вытягивать тесты — то есть скрипт с git pull не нужен (CI сам вытянет). Что вы тестируете черный ящик я понял.

        Оповещение на мейл — вообще стандартная фича, а если стандартного не хватит — есть целая охапка на любой вкус.

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


        1. nizkopal Автор
          16.02.2018 12:31

          Хм, интересно. Значит я Вас не так понял.

          Честно говоря, никогда не слышал про триггеры по изменению содержимого ответа по URL. Это открытие для меня. Вообще, мы по большей части все тесты в ТимСити гоняем, на Дженкинс бы я точно не решился ради одного единого проекта. Ради интереса посмотрю, есть ли аналогичный плагин для ТимСити. Главное, чтобы история запусков не выглядела, как 10 запусков со статусом Success (или что он там пишет, если ответ не изменился), потом один нормальный, потом еще 15 Success и так далее. :)

          Но в целом, для меня написать пару строк кода на bash и развернуть CI задачи слегка разного масштаба. Возможно, дело только во мне и в том, что на второе рука у меня не набита.

          Еще раз спасибо за комментарий, есть над чем подумать. :)


          1. yetanotherman
            16.02.2018 19:39
            +1

            Нет, там все нормально (с историей) — проверка триггера не считается запуском, у триггера есть свой лог для отладки — если это нужно.

            Про teamcity сказать положа руку на сердце не могу, моя рука как раз на Jenkins набита (но видел что-то такое у коллег на teamcity). Но bash я тоже люблю! :)