Как стать автором
Обновить

PHPUnit && ordered tests

Время на прочтение 6 мин
Количество просмотров 5.6K
Все программисты ленивые. И каждый хочет не писать дополнительный код, а воспользоваться уже готовым. Тем более, что это хорошая практика.

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


Предусловие

Есть фасад системы, который умеет выполнять какие-либо действия. Есть зависимые действия, а есть независимые действия. Зависимые действия требует выполнения независимого действия (а иногда и не одного). Поэтому в итоге, мы должны получить так называемый упорядоченный список действий.

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

Условие

Итак, сейчас меня видно захотят забросать помидорами и сказать, что в PHPUnit есть dependency. И это действительно так. НО, как написано самим автором, dependencies не позволяет указать строгий порядок выполнения тестов.
PHPUnit supports the declaration of explicit dependencies between test methods. Such dependencies do not define the order in which the test methods are to be executed but they allow the returning of an instance of the test fixture by a producer and passing it to the dependent consumers.

Кроме того, как выяснилось мною позже, указать зависимость одного тестового метода в классе потомке на тестовый метод класса родителя, это всё равно что сразу указать, что тест должен игнорироваться.
Например:
Class ParentTestCase extends PHPUnit_Framework_TestCase 
{
    public function testOne()
    {
        self::assertTrue(true);
    }
    
    /**
     * @depends testOne
     */
    public function testTwo()
    {
        self::assertTrue(true);
    }
}

class ChildTestCase extends ParentTestCase
{
    /**
     * @depends testTwo
     */
    public function testThree()
    {
        self::assertTrue(true);
    }
}

Укажу, что это происходит за счёт использования reflection, потому что сначала собираются методы из класса потомка, а потом из класса предка.

В связи с этим, у меня родилась идея модифицировать PHPUnit таким образом, чтобы он поддерживал последовательность выполнения тестов и чтобы основой для этой последовательности лежала в указании зависимости на выполнения теста.

Решение

Для начала определим, где мы собираемся хранить порядок зависимостей теста. Для этого создадим потомка TestCase и добавим функции управления этим порядком.
class MagicTestCase extends PHPUnit_Framework_TestCase
{
    /**
     * @var    array
     */
    protected $order = array();
    /**
     * Sets the orderSet of a TestCase.
     *
     * @param  array $orderSet
     */
    public function setOrderSet(array $orderSet)
    {
        $this->order = $orderSet;
    }

    /**
     * Get the orderSet of a TestCase.
     *
     * @return  array $order
     */
    public function getOrderSet()
    {
        return $this->order;
    }
}


После этого, надо указать первоначальную зависимость для каждого теста. Для этого, переопределяем метод addTestMethod для класса PHPUnit_Framework_TestSuite в наследуемом классе. Устанавливаем зависимость:
$test->setOrderSet(PHPUnit_Util_Test::getDependencies($class->getName(), $name));

Теперь необходимо дополнить конструктор нашего TestSuite, чтобы он отсортировал все тесты в установленном нами порядке. Там же определяем для каждого теста рекурсивный порядок.
        foreach($this->tests as $test) {
            $test->setOrderSet(
                array_unique($this->getRecursiveOrderSet($test, $test->getName()))
            );
        }
        usort($this->tests, array("MagicUtilTest ", "compareTestOrder"));


Кроме того, функция addTestSuite создаёт экземпляр себя (PHPUnit_Framework_TestSuite), а необходимо, чтобы она создавала изменнённый TestSuite, потому что в первом случае, она не использует конструктор изменнённого TestSuite. Зависит это всё от одной строчки, которую и переопределяем внутри метода:
$this->addTest(new MagicTestSuite($testClass));


Получившийся класс:
class MagicTestSuite extends PHPUnit_Framework_TestSuite
{
    /**
     * Constructs a new TestSuite:
     *
     * @param  mixed  $theClass
     * @param  string $name
     * @throws InvalidArgumentException
     */
    public function __construct($theClass = '', $name = '')
    {
        parent::__construct($theClass, $name);
        
        foreach($this->tests as $test) {
            $test->setOrderSet(
                array_unique($this->getRecursiveOrderSet($test, $test->getName()))
            );
        }
        usort($this->tests, array("MagicUtilTest ", "compareTestOrder"));
    }

    /**
     * @param  $object
     * @param  $methodName
     * @return array
     */
    protected function getRecursiveOrderSet($object, $methodName)
    {
        $orderSet = array();
        foreach($this->tests as $test) {
            if ($test->getName() == $methodName && get_class($object) == get_class($test)) {
                $testOrderSet = $test->getOrderSet();

                if (!empty($testOrderSet)) {
                    foreach($testOrderSet as $orderMethodName) {
                        if(!in_array($orderMethodName, $orderSet)) {
                            $orderResult = $this->getRecursiveOrderSet($test, $orderMethodName);
                            $orderSet = array_merge($orderSet, $orderResult);
                        }
                    }
                }
                $orderSet = array_merge($orderSet, $testOrderSet);
            }
        }
        return $orderSet;
    }

    /**
     * @param ReflectionClass  $class
     * @param ReflectionMethod $method
     */
    protected function addTestMethod(ReflectionClass $class, ReflectionMethod $method)
    {
        $name = $method->getName();

        if ($this->isPublicTestMethod($method)) {
            $test = self::createTest($class, $name);

            if ($test instanceof PHPUnit_Framework_TestCase ||
                $test instanceof PHPUnit_Framework_TestSuite_DataProvider) {
                $test->setDependencies(
                  PHPUnit_Util_Test::getDependencies($class->getName(), $name)
                );
            }

            $test->setOrderSet(PHPUnit_Util_Test::getDependencies($class->getName(), $name));

            $this->addTest($test, PHPUnit_Util_Test::getGroups(
              $class->getName(), $name)
            );
        }

        else if ($this->isTestMethod($method)) {
            $this->addTest(
              self::warning(
                sprintf(
                  'Test method "%s" is not public.',

                  $name
                )
              )
            );
        }

    /**
     * Adds the tests from the given class to the suite.
     *
     * @param  mixed $testClass
     * @throws InvalidArgumentException
     */
    public function addTestSuite($testClass)
    {
        if (is_string($testClass) && class_exists($testClass)) {
            $testClass = new ReflectionClass($testClass);
        }

        if (!is_object($testClass)) {
            throw PHPUnit_Util_InvalidArgumentHelper::factory(
              1, 'class name or object'
            );
        }

        if ($testClass instanceof PHPUnit_Framework_TestSuite) {
            $this->addTest($testClass);
        }

        else if ($testClass instanceof ReflectionClass) {
            $suiteMethod = FALSE;

            if (!$testClass->isAbstract()) {
                if ($testClass->hasMethod(PHPUnit_Runner_BaseTestRunner::SUITE_METHODNAME)) {
                    $method = $testClass->getMethod(
                      PHPUnit_Runner_BaseTestRunner::SUITE_METHODNAME
                    );

                    if ($method->isStatic()) {
                        $this->addTest(
                          $method->invoke(NULL, $testClass->getName())
                        );

                        $suiteMethod = TRUE;
                    }
                }
            }

            if (!$suiteMethod && !$testClass->isAbstract()) {
                $this->addTest(new MagicTestSuite($testClass));
            }
        }

        else {
            throw new InvalidArgumentException;
        }
    }

}

Ну и соответственно добавим функцию-утилиту, которая будет сравнивать два теста и указывать, в какую сторону сортировать их.
class MagicUtilTest 
{
    /**
     * @static
     * @param PHPUnit_Framework_TestCase $object1
     * @param PHPUnit_Framework_TestCase $object2
     * @return int
     */
    public static function compareTestOrder(PHPUnit_Framework_TestCase $object1, PHPUnit_Framework_TestCase $object2)
    {
        if (in_array($object2->getName(), $object1->getOrderSet())) {
            return 1;
        }

        if (in_array($object1->getName(), $object2->getOrderSet())) {
            return -1;
        }

        return 0;
    }
}


Применение

Создаем файл запуска тестов:
require dirname(__FILE__) . DIRECTORY_SEPARATOR.' runSuite.php';
PHPUnit_Util_Filter::addFileToFilter(__FILE__, 'PHPUNIT');
require_once 'PHPUnit/TextUI/Command.php';

$tests= runSuite::suite();
PHPUnit_TextUI_TestRunner::run($tests);
exit;


Создаём общий класс TestSuite, который будет непосредственно запускать тесты. Наследуем его от нашего созданного:
require_once ‘MagicTestSuite.php’;

class runSuite extends MagicTestSuite
{
	public static function suite()
       {
		$suite  = new self();
		$suite->addTestSuite(“ChildTestCase”);
       }
}

Ну и TestCases, которые включают тесты и зависимости:
require_once ‘MagicTestCase.php’;
class ParentTestCase extends MagicTestCase
{
    public function testOne()
    {
        self::assertTrue(true);
    }
    
    /**
     * @depends testOne
     */
    public function testTwo()
    {
        self::assertTrue(true);
    }
}

require_once ‘ParentTestCase.php’;
class ChildTestCase extends ParentTestCase
{
    /**
     * @depends testTwo
     */
    public function testThree()
    {
        self::assertTrue(true);
    }
}


Ограничения

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

Конечно, можно было строить дерево зависимостей теста… но честно говоря не придумал, в каком случае (помимо человеческой ошибки) можно использовать замыкающиеся зависимости для тестов.
Теги:
Хабы:
+11
Комментарии 30
Комментарии Комментарии 30

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн