company_banner

Code coverage в Badoo

    Несколько месяцев назад мы ускорили генерацию code coverage с 70 до 2,5 часов. Реализовано это было как дополнительный формат в экспорте/импорте coverage. А недавно наши pull requests попали в официальные репозитории phpunit, phpcov и php-code-coverage.

    Мы не раз рассказывали на конференциях и в статьях о том, что мы «гоняем» десятки тысяч юнит-тестов за короткое время. Основной эффект достигается, как несложно догадаться, за счёт многопоточности. И всё бы хорошо, но одна из важных метрик тестирования ― это покрытие кода тестами.
    Сегодня мы расскажем, как его считать в условиях многопоточности, агрегировать и делать это очень быстро. Без наших оптимизаций подсчёт покрытия занимал более 70 часов только для юнит-тестов. После оптимизации мы тратим всего 2,5 часа на то, чтобы посчитать покрытие по всем юнит-тестам и двум наборам интеграционных тестов общим числом более 30 тысяч.

    Тесты мы в Badoo пишем на PHP, используем PHPUnit Framework от Себастьяна Бергмана (Sebastian Bergmann, phpunit.de).
    Покрытие в этом фреймворке, как и во многих других, считается при помощи расширения Xdebug простыми вызовами:

    xdebug_start_code_coverage();
    //… тут выполняется код …
    $codeCoverage = xdebug_get_code_coverage();
    xdebug_stop_code_coverage();
    

    На выходе получается вложенный массив, содержащий файлы, выполнявшиеся во время сбора покрытия, и номера строк в файлах со специальными флагами: был ли вызван код, не был или вообще не должен был вызваться. Подробно про работу Xdebug с покрытием можно почитать на сайте проекта.

    У Себастьяна Бергмана имеется библиотека PHP_CodeCoverage, которая отвечает за сбор, обработку и вывод покрытия в разных форматах. Библиотека удобна, расширяема и нас вполне устраивает. У неё имеется консольный фронтенд phpcov.
    Но и в сам вызов PHPUnit для удобства уже интегрирован подсчёт покрытия и вывод в разных форматах:

     --coverage-clover <file>  Generate code coverage report in Clover XML format.
     --coverage-html <dir>     Generate code coverage report in HTML format.
     --coverage-php <file>     Serialize PHP_CodeCoverage object to file.
     --coverage-text=<file>    Generate code coverage report in text format.
    

    Опция --coverage-php ― это то, что нам нужно при многопоточном запуске: каждый поток подсчитывает покрытие и экспортирует в отдельный файл *.cov. Агрегацию и вывод в красивый html-отчёт можно сделать вызовом phpcov с флагом --merge.

    --merge                 Merges PHP_CodeCoverage objects stored in .cov files.
    

    Выходит всё складно, красиво и должно работать «из коробки». Но, видимо, далеко не все используют этот механизм, включая самого автора библиотеки, иначе быстро бы всплыла на поверхность «неоптимальность» механизма экспорта-импорта, используемая в PHP_CodeCoverage. Давайте разберём по порядку, в чём же дело.

    За экспорт в формат *.cov отвечает специальный класс-репортер PHP_CodeCoverage_Report_PHP, интерфейс которого очень прост. Это метод process(), принимающий на вход объект класса PHP_CodeCoverage и сериализующий его функцией serialize().

    Результат записывается в файл (если передан путь к файлу), либо возвращается как результат метода.

    class PHP_CodeCoverage_Report_PHP
    {
        /**
         * @param  PHP_CodeCoverage $coverage
         * @param  string           $target
         * @return string
         */
        public function process(PHP_CodeCoverage $coverage, $target = NULL)
        {
            $coverage = serialize($coverage);
    
            if ($target !== NULL) {
                return file_put_contents($target, $coverage);
            } else {
                return $coverage;
            }
        }
    }
    

    Импорт утилитой phpcov, наоборот, берёт все файлы в директории с расширением *.cov и для каждого делает unserialize() в объект. Объект затем передаётся в метод merge() объекта PHP_CodeCoverage, в который агрегируется покрытие.

        protected function execute(InputInterface $input, OutputInterface $output)
        {
            $coverage = new PHP_CodeCoverage;
    
            $finder = new FinderFacade(
                array($input->getArgument('directory')), array(), array('*.cov')
            );
    
            foreach ($finder->findFiles() as $file) {
                $coverage->merge(unserialize(file_get_contents($file)));
            }
    
            $this->handleReports($coverage, $input, $output);
        }
    

    Сам процесс слияния очень прост. Это слияние массивов array_merge() с небольшими нюансами вроде игнорирования того, что уже импортировалось, либо передано как параметр фильтра в вызов phpcov (--blacklist и --whitelist).

         /**
         * Merges the data from another instance of PHP_CodeCoverage.
         *
         * @param PHP_CodeCoverage $that
         */
        public function merge(PHP_CodeCoverage $that)
        {
            foreach ($that->data as $file => $lines) {
                if (!isset($this->data[$file])) {
                    if (!$this->filter->isFiltered($file)) {
                        $this->data[$file] = $lines;
                    }
    
                    continue;
                }
    
                foreach ($lines as $line => $data) {
                    if ($data !== NULL) {
                        if (!isset($this->data[$file][$line])) {
                            $this->data[$file][$line] = $data;
                        } else {
                            $this->data[$file][$line] = array_unique(
                              array_merge($this->data[$file][$line], $data)
                            );
                        }
                    }
                }
            }
    
            $this->tests = array_merge($this->tests, $that->getTests());
        }
    

    Именно использование подхода сериализации и десериализации и стало той самой проблемой, которая не давала нам быстро генерировать покрытие. Не раз сообщество обсуждало производительность функций serialize и unserialize в PHP:
    http://stackoverflow.com/questions/1256949/serialize-a-large-array-in-php;
    http://habrahabr.ru/post/104069 и т.д.

    Для нашего небольшого проекта, PHP-репозиторий которого содержит больше 35 тысяч файлов, файлы с покрытием весят немало, по несколько сот мегабайт. Общий файл, «смерженный» из разных потоков, весит почти 2 гигабайта. На таких объёмах данных unserialize показывал себя во всей красе ― мы ждали генерации покрытия по несколько суток.

    Поэтому мы и решили попробовать самый очевидный способ оптимизации ― var_export и последующий include файлов.

    Для этого в репозиторий php-code-coverage был добавлен новый класс-репортер, который делает экспорт в новом формате через var_export:

    class PHP_CodeCoverage_Report_PHPSmart
    {
        /**
         * @param  PHP_CodeCoverage $coverage
         * @param  string           $target
         * @return string
         */
        public function process(PHP_CodeCoverage $coverage, $target = NULL)
        {
            $output = '<?php $filter = new PHP_CodeCoverage_Filter();'
                . '$filter->setBlacklistedFiles(' . var_export($coverage->filter()->getBlacklistedFiles(), 1) . ');'
                . '$filter->setWhitelistedFiles(' . var_export($coverage->filter()->getWhitelistedFiles(), 1) . ');'
                . '$object = new PHP_CodeCoverage(new PHP_CodeCoverage_Driver_Xdebug(), $filter); $object->setData('
                . var_export($coverage->getData(), 1) . '); $object->setTests('
                . var_export($coverage->getTests(), 1) . '); return $object;';
    
            if ($target !== NULL) {
                return file_put_contents($target, $output);
            } else {
                return $output;
            }
        }
    }
    

    Формат файла мы скромно назвали PHPSmart. Расширение у файлов такого формата ― *.smart.

    Для того чтобы объект класса PHP_CodeCoverage позволял себя экспортировать и импортировать в новый формат, были добавлены сеттеры и геттеры его свойств.
    Немного правок в репозиториях phpunit и phpcov, чтобы они научились работать с таким объектом, и наше покрытие стало собираться всего за два с половиной часа.
    Вот так выглядит импорт:

        foreach ($finder->findFiles() as $file) {
            $extension = pathinfo($file, PATHINFO_EXTENSION);
            switch ($extension) {
                case 'smart':
                    $object = include($file);
                    $coverage->merge($object);
                    unset($object);
                    break;
                default:
                    $coverage->merge(unserialize(file_get_contents($file)));
            }
        }
    

    Наши правки вы можете найти на GitHub и попробовать такой подход на своем проекте.
    github.com/uyga/php-code-coverage
    github.com/uyga/phpcov
    github.com/uyga/phpunit

    Себастьяну Бергману мы отправили пулл-реквесты наших правок, надеясь вскоре увидеть их в официальных репозиториях создателя.
    github.com/sebastianbergmann/phpunit/pull/988
    github.com/sebastianbergmann/phpcov/pull/7
    github.com/sebastianbergmann/php-code-coverage/pull/185

    Но он их закрыл, сказав, что хочет не дополнительный формат, а наш вместо своего:



    Что мы с радостью и сделали. И теперь наши изменения вошли в официальные репозитории создателя, заменив использовавшийся до этого формат в файлах *.cov.
    github.com/sebastianbergmann/php-code-coverage/pull/186
    github.com/sebastianbergmann/phpcov/pull/8
    github.com/sebastianbergmann/phpunit/pull/989

    Вот такая небольшая оптимизация помогла нам ускорить сбор покрытия почти в 30(!) раз. Она позволила нам гонять не только юнит-тесты для подсчёта покрытия, но и добавить два набора интеграционных тестов. На время импорта-экспорта и мержа результатов это существенно не повлияло.

    P.S.:


    Илья Агеев,
    QA Lead
    Badoo
    423.31
    Big Dating
    Share post

    Comments 27

      +16
      Ради таких твитов стоит жить и работать, да.
        +5
        Классно. Я писал патч для issue10, отправил его на review, но ответа так и не получил. У вас проблем с выборочным запуском тестов нет? Не работаете над этим issue?

        Как вам код phpunit? На мой взгляд очень тяжелый для понимания-рефакторинга
          +1
          Спасибо за вопросы!

          Нет, над issue10 мы не работали. Проблем с выборочным запуском тестов так же не имели. --filter вполне себе справляется с нашими задачами.

          По поводу понятности кода phpunit — тоже особых проблем не испытывали. Код замечательно читается и дорабатывается + хорошая документация. С чем именно у вас были проблемы? Уверен что сообщество поможет ответить на ваши вопросы.
            +2
            Да, "--filter вполне себе справляется с нашими задачами", но суть в том что он при этом начинает дергать классы, методы, dataProvider'ы, которые точно под этот filter не попадают. Вот примерный тест, не знаю насколько он сейчас актуален: github.com/ewgRa/phpunit/commit/5b12236856d3c139b37ebf9a55d76733e8c08554

            В итоге допустим в Zend Framework (или Symphony, не помню точно) запускать тесты с --filter то еще удовольствие. Они в dataProvider запихивают достаточно емкую работу и все эти dataProvider будут выполнятся, даже если методы, которые фильтруются не учавствуют в тестах.

            > С чем именно у вас были проблемы?
            Я уже точно не помню, но там мне показалась архитектура, которая явно строится по принципу дерева от крупного к мелкому или тому подобное реализована очень, очень сложно. В итоге как-то это рефакторить было очень проблематично. Мне кажется то, что это issue уже три года как не закрывается именно из-за архитектуры и того кода, кторый там реализован, уж слишком сложно он поддается рефакторингу.
              +3
              Я понимаю о чем вы говорите. Действительно, с этой точки зрения архитектура выглядит не очень оптимальной.
              Мы, чтобы не натыкаться на подобные подводные камни, используем простое правило в нашем проекте — «data provider'ы должны возвращать только статичные данные». Никаких объектов, никакой хитрой логики. Если нужны различные объекты в тестах, то в датапровайдерах надо возвращать правила или параметры для создания таких объектов. А сами объекты надо создавать уже в тестах или setUp'е и очищать в tearDown'е.
              Это, конечно, не исключает сбор провайдеров при запуске тестов с фильтром, но существенно облегчает этот процесс.
              Более того, то, что провайдеры инициализируются задолго до выполнения тестов ведет к еще одной проблеме — нарушению изоляции. Объект, созданный перед всем набором тестов может быть изменен каким-нибудь тестом до нужного и в нужный тест объект может прийти измененным.
              Это еще одна причина, почему мы запрещаем инициализацию объектов в провайдерах.
              То же самое справедливо и для некоторых других методов фреймворка, вы совершенно правы.
              Для автоматического контроля ситуации мы даже добавили в нашу запускалку тестов специальный тест, который проверяет состояние объектов между тестами — «SanityCheck». Правило + автоматическая проверка спасают :-).
                0
                При вашем количестве тестов, даже такие «пустые» dataProvider'ы должны неплохо замедлять выполнение тестов с --filter + расход памяти (если я правильно помню там все эти результаты dataProvider складываются в память)

                С одной стороны я рад за вас, с другой жаль :) Может если бы для вас это было проблемой, ваших ресурсов хватило бы, чтобы сдвинуть это дело с мертвой точки.
                  0
                  А можно ли вообще отказаться от data provider'ов? Мне кажется они не добавляют читаемости в тест, а скорее наоборот.

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

                    На «тяжелые» провайдеры зенда и симфони тоже бы с удовольствием посмотрел. При беглом осмотре кода симфони я увидел только статичные данные в провайдерах. Может переписали уже?
                      0
                      К сожалению сейчас не могу сказать, давно дело было.
                      0
                      К сожалению не могу, ZF2 требует phpunit 3.7, запустить тесты посмотреть не могу. Дело давно было, с тех пор я не касался этого вопроса.
                        0
                        Добавляя дополнительные наборы данных можно увеличить покрытие кода без написания нового теста.
                    +2
                    >>> но суть в том что он при этом начинает дергать классы, методы, dataProvider'ы, которые точно под этот filter не попадают

                    --filter='/::FULL_CLASS_OR_METHOD_NAME( .*)?$/' /path/to/TestFile.php
                    


                    Для PHPStorm я сделал External Tool в настройках, который по хоткею запускает тесты в dev-системе (линукс через plink.exe), фильтруя по выделенному мышкой методу или классу. Самая длинная строка настройки (не считая пути к плинку):

                    -load PUTTY_PROFILE_NAME -pw PASSWORD phpunit --verbose --bootstrap=/path/to/Bootstrap.php --filter='/::$SelectedText$( .*)?$/' /path/to/document/root/$/FileRelativePath$
                    
                      0
                      Ну как вариант, да
                    0
                    В PHPUnit из коробки есть 4 возможности выборочного запуска тестов, подробнее в другом комментарии.
                  +4
                  Вы не перестаете радовать отличными постами по QA, это «свежие глотки воздуха» в пустоте этой тематике хабра. Прочитал с удовольствием. Задумываетесь ли Вы об особенностях этого линейного механизма подсчета покрытия. По факту это все крутится около «вызвалась строка — не вызвалась». Тематика ухода к более интеллектуальному подсчету CC очень интересна.
                  PS Улучшать что-то в общее благо это неоспоримо ЗДОРОВО!
                    0
                    Спасибо!
                    Да, вы правы — простой подсчет покрытия по строкам, которые были вызваны, это не панацея. Даже при огромном покрытии кода тестами нельзя считать что все хорошо и быть на 100% уверенным в том что все работает.

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

                    О более интеллектуальном подсчете покрытия пока не задумывались, но вполне возможно что к этому придем.
                    0
                    Более интеллектуальное покрытие можно реализовать на основе стандартных возможностей PHPUnit — Appendix B. Annotations — @covers. Позволяет указать что именно стоит учитывать как вызванные строки для каждого конкретного теста, весь остальной код в рамках теста будет считаться не исполненным.
                    0
                    Спасибо, как раз сегодня мы начали прикручивать code coverge и phpconv к нашим распределенным тестам, а тут такой open source подарок :)
                    Может подскажете, нет ли какой нибудь хитрости чтобы code coverage собирался быстрее? Или же проще дать CI серверу процессор побыстрее?
                      0
                      Быстрее — только многопоточный запуск может помочь. К сожалению с xdebug'ом, собирающим покрытие, тесты идут медленнее, это факт.
                      Тесты для сбора покрытия мы гоняем на виртуалке с 32 гигабайтами оперативной памяти и 24ю ядрами Intel Xeon 2.93GHz. При этом из упомянутых 2,5 часов процентов 70 времени уходит именно на прогон всех тестов.
                      Вероятно, скоро мы придем к вопросу оптимизации еще раз, так как количество тестов с каждым днем увеличивается. Если придумаем что-нибудь, обязательно напишем об этом и отдадим в opensource.
                        0
                        Спасибо за ответ. Мы думаем в свободное время попробовать разобраться с Gearman'ом, ведь в наличии есть около сотни девелоперских и обычных офисных компов, если бы удалось запускать паралельно тесты на них, все бы просто летало! :) А вы не пробовали что-то похожее?
                          0
                          Пока не пробовали, но идеи такие давно витают. Де-факто тесты (не для кавериджа, а для задач, билдов и т.д.) мы итак гоняем в облаке. Набор из десятка виртуалок, из которых берется самая свободная и запускаются на ней тесты.
                          В общем-то такой же принцип можно применить и к девелоперским машинам, но тут возникают проблемы с окружением. Если виртуалки еще более-менее можно продублировать с точки зрения продакшеновских машин — ресурсы, версии софта и библиотек, то с клиентскими машинами такое будет очень сложно провернуть.
                            0
                            Мы думали о том, чтобы поднимать на машинах виртуалки с помощью Vagrant и Chef, таким образом проблема различних окружений решается. Вопрос в том, стоит ли этим заниматься, если проще добавить ресурсы в облако :) Но возможность получить кластер с 100 нодов греет гиковское сердце :)
                            0
                            Success Story

                            При помощи --group или --fiter разделите существующий набор на части. --group потребует измений в файлах с тестами, --filter — подобрать правильные маски чтобы ничего не потерялось. Когда разделив запуск на несколько потоков вы добьетесь его выполнения (при определенном объеме тестов вы столкнетесь с проблемами не изолированности тестов друг от друга, зависимости от внешних ресурсов и т. п.) вы сможете ускорить процесс в десятки раз, все в конечном итоге упрется в коль-во процессов на которые хватит ресурсов (в основном памяти и процессора, но так же могут быть проблемы со скоростью записи на диск, это зависит от характера тестов) при параллельном запуске. Дальше запускаем сколько нам нужно потоков с помощью простого bash скрипта:

                            #!/bin/bash -x
                            
                            # Получаем данные покрытия для первой половины тестов выполняя в фоне
                            phpunit --group A --coverage-php coverage/data/group_A.cov &
                            
                            # Получаем данные покрытия для второй половины тестов  выполняя в фоне
                            phpunit --group B --coverage-php coverage/data/group_B.cov & 
                            
                            # Ждем пока завершатся оба потока выполняясь параллельно
                            wait                                                         
                            
                            # Объединяем даныне покрытия из двух потоков и генерируем HTML
                            phpcov --merge --html coverage/html coverage/data            
                            


                            P. S.

                            Разбить потоки на почти равные части чтобы все они выполнялись примерно одинаковое время можно при помощи Chapter 7. Organizing Tests — Composing a Test Suite Using XML Configuration или же можно не заморачиваться и просто раскидать все по директориям — Chapter 7. Organizing Tests — Composing a Test Suite Using the Filesystem.

                            P. P. S.

                            Есть идея расширить возможности PHPUnit и реализовать возможность запуска набора тестов на указанном кол-ве потоков с автоматическим распределением тестов по «свободным» потокам, но это уже совсем другая история.
                              0
                              Проекты с автоматическим распределенным Phpunit уже есть на gihub'е. Проблемы начинаются с появлением интеграционных и функциональных тестов. Например использование БД или файлов. Можно передавать в потоки Phpunit параметры, и с их помощью создавать уникальные БД, файлы, и т. д. но в проектах с фреймворками (SF2, Doctrine) очень трудно все предусмотреть и изменить. Проще(?) всего запускать тесты в параллель на различных виртуалках.
                        0
                        В PHPUnit есть известный bug с покрытием таких конструкций:
                        $var = false;
                        if($var == true)
                            return false;
                        if($var == true) return false;
                        

                        If в данном случае не отработает, однако Xdebug считает его покрытым. Как выход можно заключить операторы после if в фигурные скобки:
                        $var = false;
                        if($var == true) {
                            return false;
                        }
                        if($var == true) { return false; }
                        

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

                        Вопрос вот в чем, как в badoo справляетесь с этой проблемой?

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