7 февраля 2013 г.

Автоматическое Selenium-тестирование при коммитах (без Jenkins)

Выполнение Selenium-тестов занимает много времени. Процесс тестирования сопровождается открытием окон браузера, что отвлекает от разработки, даже если они свёрнуты. В этом посте я запишу свой опыт сборки системы, которая проводит автоматический запуск Selenium тестов после каждого выталкивания изменений в репозиторий для веб-проекта, написанного на PHP с использованием Mercurial под Windows. Визуальная часть тестов проходит на виртуальной машине, а отчет об ошибках отправляется на электронную почту.
До этого у меня уже был настроен репозиторий Mercurial, который используется для совместной разработки. Сами файлы репозитория находятся на том же компьютере, что и версия, в которой происходит разработка. Для обработки pull, push использовано стандартное средство hg serve. Было подготовлено несколько Selenium-тестов для NetBeans.


1. Копия проекта для тестов

Желательно создать отдельную копию веб-проекта с отдельной базой данных, на котором будет происходить тесты, чтобы разработка и тестирование не мешали друг другу. Естественно, эта копия должна быть всегда актуальной, и актуализация должна быть автоматизирована.
Пусть
http://webpro:8000/portal - веб-адрес Mercurial-репозитория с вашим проектом, а
D:\www\projects\test_portal - путь, где будем хранить версию проекта для тестов.
Тогда командой
hg clone http://webpro:8000/portal D:\www\projects\test_portal
мы создадим копию проекта, актуальную на данный момент. Эта операция разовая.
Для последующих обновлений будем использовать вытягивание
cd /d D:\www\projects\test_portal
hg pull http://webpro:8000/portal
hg update
Вытягивание нужно проводить при каждом изменении. Для этого будем использовать hooks системы Mercurial. При желании можно автоматизировать и обновление схемы БД, так как она тоже должна быть актуальной, но здесь я это не рассматриваю.
Для проекта под тесты я настроил виртуальный хост с адресом portal_test.lh. 

2. Использование удалённой машины для проведения тестов в браузере

PHPUnit SeleniumTestCase поддерживает указание адреса Selenium-сервера. Для этого в методе setUp() тестов нужно вызвать метод
$this->setHost("192.168.56.102");
где аргумент - адрес удаленного сервера.
В качестве удаленного сервера я использую виртуальную машину. На неё нужно установить браузеры, на которых будет проходить тестирование, и запустить java-приложение Selenium-сервера. Кроме того, нужно убедиться, что проект для тестов у нас доступен в браузере (portal_test.lh).
Теперь для каждого теста нам нужно указать адрес сервера. Для упрощения поддержки создадим класс SeleniumTestEnv в одноименном файле в директории с Selenium-тестами со следующим содержимым

<?php

if (!class_exists("SeleniumTestEnv")) {

    class SeleniumTestEnv extends PHPUnit_Extensions_SeleniumTestCase {

        function setUp() {

            $this->setBrowser("*firefox");
            $this->setBrowserUrl("http://portal_test.lh");
            $this->setHost("192.168.56.102");

        }

    }

}
где 192.168.56.102 - адрес виртуальной машины с Selenium-сервером. Теперь нам нужно наследовать все тесты от этого класса, предварительно подключив его. Для этого начало каждого класса теста должно принять следующий вид
require_once 'SeleniumTestEnv.php';

class Example extends SeleniumTestEnv {
где Example - имя вашего теста.
Так как метод setUp() у нас определён в родительском классе с нужными нам операциями, то из тестов его нужно убрать.
Теперь при запуске тестов из NetBeans (Run Selenium Tests) открытие браузеров должно происходить на удалённом сервере (виртуальной машине).

3. Запуск тестов из командной строки

При ручном запуске тестов в NetBeans в окне "Вывод" показана команда, которая производит запуск. Из неё видно, что используется класс NetBeansSuite. Этот класс проводит поиск файлов для тестов в папке проекта, создает TestSuite, добавляет в него найденные тесты и запускает их выполнение. Для удобства скопируем этот файл себе в проект в директорию, отличную от тестов, например, в cli/NetBeansSuite.php. Файл можно взять в папке с NetBeans, либо здесь.
Сам запуск происходит из phpunit. Кроме всех прочих аргументов можно указать формат для логов:
phpunit.phar --log-json phpunit-log.json --log-junit phpunit-log.xml
Учитывая, что пути могут отличаться, получаем строку запуска:
php.exe D:\WebServer\php-5.4.4-Win32-VC9-x86\/phpunit.phar --log-junit C:\Temp\nb-phpunit-log.xml NetBeansSuite "D:\www\projects\test_portal\cli\NetBeansSuite.php" run=D:\www\projects\test_portal\tests_selenium

4. Отправка отчёта об ошибках при выполнении тестирования

Для того, чтобы определить, возникли ли ошибки тестирования, есть как минимум два пути:
  1. после выполнения тестирования запустить скрипт для анализа логов;
  2. подключить своего "слушателя" TestListener, который будет оповещать при сбоях или ошибках.
Второй вариант мне казался правильным, но при реализации возникло много проблем. Тем не менее запишу его.

Класс слушателя должен реализовывать интерфейс PHPUnit_Framework_TestListener. Базовый класс можно взять по ссылке выше из руководства PHPUnit. Я изменил методы addError(), addFailure() для сбора ошибок и endTestSuite() для отправки их после выполнения пакета тестов.
<?php


/**
 * http://www.phpunit.de/manual/3.6/en/extending-phpunit.html#extending-phpunit.PHPUnit_Framework_TestListener
 */

class SimpleTestListener implements PHPUnit_Framework_TestListener {

    protected $_errorMessages = array();

    protected function _email($message) {
        require_once 'Mailer.php';
        Mailer::getInstance()->send(array('some@asdasd.ru'), "Portal testing", $message);
    }    

    public function addError(PHPUnit_Framework_Test $test, Exception $e, $time) {
        printf(

                "Error while running test '%s'.\n", $test->getName()

        );

        $message = "<h2>Error</h2>Error while running test '".$test->getName()."'.<br><br>";
        $message .= "Exception: ".$e->getMessage() . "<br><br>";
        $message .= $e->getTraceAsString()."<br><br>";
        $message .= "Line: ".$e->getLine()."<br><br>";
        $this->_errorMessages[] = $message;
    }



    public function addFailure(PHPUnit_Framework_Test $test, PHPUnit_Framework_AssertionFailedError $e, $time) {
        printf(

                "Test '%s' failed.\n", $test->getName()

        );
        $message = "<h2>Failed</h2>Test '".$test->getName()."' failed.<br><br>";
        $message .= "Exception: ".$e->getMessage() . "<br><br>";
        $message .= $e->getTraceAsString()."<br><br>";
        $message .= "Line: ".$e->getLine()."<br><br>";
        $this->_errorMessages[] = $message;
    }



    public function addIncompleteTest(PHPUnit_Framework_Test $test, Exception $e, $time) {
        printf(

                "Test '%s' is incomplete.\n", $test->getName()

        );
    }



    public function addSkippedTest(PHPUnit_Framework_Test $test, Exception $e, $time) {
        printf(

                "Test '%s' has been skipped.\n", $test->getName()

        );

    }



    public function startTest(PHPUnit_Framework_Test $test) {

        printf(

                "Test '%s' started.\n", $test->getName()

        );

    }



    public function endTest(PHPUnit_Framework_Test $test, $time) {
        printf(

                "Test '%s' ended.\n", $test->getName()

        );
    }



    public function startTestSuite(PHPUnit_Framework_TestSuite $suite) {
        printf(

                "TestSuite '%s' started.\n", $suite->getName()

        );
    }



    public function endTestSuite(PHPUnit_Framework_TestSuite $suite) {
        printf(

                "TestSuite '%s' ended.\n", $suite->getName()

        );

        $num = count($this->_errorMessages);
        if ($num){
            $prefix = "<h1>TestSuite ".$suite->getName()." has errors($num)</h1><br>";
            $this->_email($prefix . nl2br((implode('<hr>', $this->_errorMessages))));
            $this->_errorMessages = array();
        }
    }

}

Метод _email() следует переписать под ваше окружение. Сам класс можно разместить рядом с NetBeansSuite.php.

TestListener можно подключить через командную строку, используя параметр --printer, но мне не удалось указать путь до файла с классом слушателя. Поэтому я подключил его через TestSuite. Подключение слушателя этим способом нужно делать до вызова метода run(). Так как я использовал готовый класс NetBeansSuite, то для подключения его нужно наследовать. К сожалению, не удалось его наследовать без внесения изменений в его код, а именно, нужно заменить модификатор доступа метода toRun() с private на protected, чтобы можно было написать класс testSuite, который подключает слушателя.

<?php
require_once 'NetBeansSuite.php';
class testSuite extends NetBeansSuite{

    public static function suite() {
        $suite = new testSuite();
        foreach (self::toRun() as $file) {
            $suite->addTestFile($file);
        }
        return $suite;
    }

    public function run(PHPUnit_Framework_TestResult $result = NULL){
        require_once 'SimpleTestListener.php';
        $result->addListener(new SimpleTestListener);
        return parent::run($result);
    }

}

Теперь в строке запуска нужно использовать testSuite вместо NetBeansSuite:
php.exe D:\WebServer\php-5.4.4-Win32-VC9-x86\/phpunit.phar --log-junit 
C:\Temp\nb-phpunit-log.xml testSuite 
"D:\www\projects\test_portal\cli\testSuite.php" 
run=D:\www\projects\test_portal\tests_selenium 

5. Один batch-файл, чтоб править всеми

Файл run_testing.bat:

@echo off
SET testDir=D:\www\projects\test_portal
SET repoPath=http://webpro:8000/portal
SET PHPPATH=D:\WebServer\php-5.4.4-Win32-VC9-x86
SET logDir=%~dp0


IF EXIST %~dp0/need_testing GOTO NEED_TESTING

GOTO EXIT

:NEED_TESTING
IF EXIST %testDir% GOTO DEPLOY
GOTO :ERROR



:DEPLOY
IF EXIST %testDir%\test_is_running GOTO ERROR_RUNNING
echo yes>%testDir%\test_is_running
cd /d %testDir%
hg pull %repoPath%
hg update
GOTO RUN_TEST



:RUN_TEST
echo RUN
echo.>>%logDir%/log.txt
echo %date% %time%>>%logDir%/log.txt
echo.>>%logDir%/log.txt
%PHPPATH%/php.exe %PHPPATH%/phpunit.phar --log-json %logDir%\phpunit-log.json --log-junit %logDir%\phpunit-log.xml testSuite "%testDir%\cli\testSuite.php" run=%testDir%\tests_selenium >>%logDir%/log.txt
GOTO SUCCESS



:ERROR
echo %testDir% not found
GOTO EXIT


:ERROR_RUNNING
echo test already started
GOTO EXIT


:SUCCESS
DEL %testDir%\test_is_running /f /q
DEL %~dp0\need_testing /f /q
echo Success




:EXIT

testDir - путь до проекта под тесты, который создавали с помощью hg clone;
repoPath - адрес веб-репозитория проекта;
PHPPATH - путь до php.exe;
logDir - путь, куда складывать файлы логов.

Пути указаны в соответствии с тем, что файл находится рядом с директорией самого проекта, который используется как веб-репозиторий через hg serve.
В скрипте используется два файла-флага:
  • need_testing - этот файл создает другой скрипт, который вызывается при push в web-репозиторий.
  • test_is_running - этот файл создает этот скрипт для того, чтобы не запускать обновление и тестирование, если предыдущее тестирование еще не закончилось.
Всё остальное - делаем pull изменений, update, запуск тестов с использованием нашего testSuite.php с перенаправлением вывода в файл log.txt.

6. Mercurial hook

В Mercurial можно выполнять произвольные скрипты при определенных событиях. Это называется hook. Для того, чтобы создать файл-флаг need_testing (а значит, чтобы потом произошло тестирование), нужно создать простой bat-файл:
echo yes>%~dp0\need_testing
сохранить его рядом с run_testing.bat под именем, например, hook.bat. Теперь в директории, который используется как веб-репозиторий, нужно найти файл .hg/hgrc и добавить в него:
[hooks]
changegroup = D:\repo\hook.bat
где changegroup - означает, что скрипт будет вызван при push в веб-репозиторий, а D:\repo\hook.bat - путь до скрипта.

Скачать заготовку файлов можно здесь: start.bat - запуск hg serve, webconf.ini - конфигурация для hg serve.

 7. Заключение

Скрипт run_testing.bat следует добавить в планировщик задач с необходимым интервалом запуска.
Теперь, если разработчик делает push в веб-репозиторий, создается специальный файл, при наличии которого следующий запуск run_testing.bat начнет тестирование, при этом будет использована виртуальная машина для запуска браузеров, а в случае ошибок, об этом будет отправлено сообщение на email.
При желании можно вынести на виртуальную машину еще больше обязанностей, но себе я такой цели не ставил.

Комментариев нет:

Отправить комментарий