Your browser is not supported anymore. Please update to a more recent one.


Download Chrome

Download Firefox

Download
Internet Explorer

Download Safari

Code coverage в Badoo

5 сентября 2013 | Илья Агеев
Несколько месяцев назад мы ускорили генерацию 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 и попробовать такой подход на своем проекте.
https://github.com/uyga/php-code-coverage
https://github.com/uyga/phpcov
https://github.com/uyga/phpunit

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

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



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

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

P.S.: