PHPUnit && ordered tests

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

    Вот и у меня появилась задачка, при которой хотелось не делать 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);
        }
    }
    


    Ограничения

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

    Конечно, можно было строить дерево зависимостей теста… но честно говоря не придумал, в каком случае (помимо человеческой ошибки) можно использовать замыкающиеся зависимости для тестов.
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 30

      0
      Хабрака-а-ат!
        0
        да уж, занял практически всю страницу)))
          +3
          ну упс ;)
          0
          Вот это простыня. Кат не?
            +3
            При такой частой ошибке разработчикам сайта давно стоило бы автоматически добавлять кат в статью без оного…
            +14
            … каждый следующий тест зависел от данных предыдущего

            Неправильно, дядя Федор.
              +3
              Я после процитированного текста и бросил читать статью. Подход изначально неверен, к чему под него еще костыли подставлять?
              +10
              Независимость тестов друг от друга — хорошо.
              Зависимость — зло, потому что тест из черного ящика превращается в ящик с внешним контекстом.
              Крайне не рекомендую так делать.
                0
                То есть, проще заложить простыню внешних данных (сделать несколько Mock-объектов), чтобы протестировать один метод?

                А если необходимо протестировать общее поведение объекта?
                  +3
                  1. Нужно писать по возможности код, не зависящий от внешних данных. То есть использовать «чистые» функции, которые зависят только от своих аргументов, а не от внешнего контекста. Их очень легко оттестить.
                  Понятно, что от состояния никуда не деться, но свести его к минимуму можно.

                  2. Да, закладывать простыню, если по-другому не получается, абстрагировать ее в удобные методы, называть их по-другому и дергать в каждом отдельном тесткейсе. Тогда при случайном удалении/изменении тесткейса или кода у вас свалится ровно то, что должно свалиться, а не вообще все.
                    0
                    Согласен ровно наполовину.

                    Любой код зависит от внешних данных, передаваемых параметров и т.д.

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

                    Я предлагаю ИНСТРУМЕНТ, которого, по моему мнению, не хватало.
                    Как его использовать и использовать ли вообще — дело каждого

                      0
                      ну как задолбаетесь потом искать ошибки, когда вместо одного теста у вас свалится 25, тогда ждем вторую статью — как писать независимые друг от друга тесты :)
                        0
                        в случае с @depends — сваливается только один тест, остальные игнорируются.
                        Это поведение уже заложено в PHPUnit.
                        Я только упорядочил тесты с учётом зависимостей.

                        Что касается статьи, посмотрим.
                          0
                          А получится запустить только один тест, какой-нибудь средний, например?
                +7
                Изолированость тестов — это суть ли не самый главный принцип в юнит-тестировании.

                Они же даже так и называются «юнит» тесты. Т.е тестирование чего-то одного. Какого-то юнита.
                А вы сломали идеологию этим, простите, костылем.
                  0
                    0
                    Во-первых, нигде в статье не указывалось, что речь идёт исключительно о юнит-тестировании.
                    PHPUnit — достаточно полноценный инструмент, чтобы проводить и другие виды тестирования.

                    Во-вторых, я не спорю про изолированность тестов, и не стараюсь сломать эту идеологию!

                    Но как пример:
                    надо создать и промодифицировать объект.
                    1. создать объект — результатом получить идентификатор объекта (object_id)
                    2. сохраняем object_id
                    3. передаем object_id в качестве параметра (плюс дополнительные параметры функции)

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

                    А дальше — каждый выбирает тот инструмент и решение, которое ему больше подходит (нравится)
                      0
                      После шага 2 куда сохраняется объект? В какое-то персистентное хранилище?
                      А по каким событиям и когда это хранилище подчищается? Получается, что в tearDown теперь этого делать нельзя.
                        0
                        Персистентное хранилище

                        И да, в tearDown необходимо использовать флаги для очистки
                        внутри теста можно указать, когда можно очищать данные
                          0
                          Какой ужас :-S
                          Т.е. теперь тесты ещё и должны беспокоиться о подготовке правильного окружения сами для себя же.

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

                            Геморность для поддержки — вопрос спорный.
                            Один флаг для очистки после выполнения в последнем тесте — куда проще реализовать, чем создавать, наполнять и удалять каждый раз один полноценный объект
                        +1
                        Да, видимо всё-таки речь идёт не о модульных, а об интеграционных тестах. Но даже в этом случае принято состояние системы эмулировать напрямую (например, записью нужных данных в базу), а не вызовом последовательности тестов.
                          0
                          Не всегда можно напрямую записать данные в базу

                          API системы с черным ящиком внутри
                            0
                            В случае с API принято использовать mock-обьекты
                      0
                      Все программисты ленивые. И каждый хочет не писать дополнительный код, а воспользоваться уже готовым. Тем более, что это хорошая практика.

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

                        Я не говорил о чужих решениях и чужих ошибках
                        Речь шла о том, чтобы не использовать копию своего кода
                        +2
                        Для решения описанных проблем имеет смысл посмотреть в сторону паттернов Object Mother и Test Data Builder. Врочем, тесты — это обычный код, для устранения дублирования (в тестовых методах, фикстур и т. д.) следует применять те же правила, что и в обычном рабочем коде.

                        А делать зависимые тесты и, тем более, завязывать их на порядок выполнения — это, как уже сказали выше, не самая хорошая затея.
                          0
                          Хорошее противопоставление.

                          0
                          Проблемы начнутся тогда, когда в длинной цепочке тестов, где-то допустим в первой трети цепочки, у тебя будет не учтённы какой-то кейс, а клина вся эта система поймает изза этого неучтённого кейса ближе к концу цепочки. Попробуй найди тогда в чём была причина. Я не спец в тестировании, но это мне кажется очевидным. Это зло.
                            0
                            BDD в общем и Behat в частности быть использованы тобой должны были.

                            Only users with full accounts can post comments. Log in, please.