С самого начала работы над одним из web-проектов мы стремились к высокому уровню покрытия кода тестами, и на начальном этапе разработки я не задумывался об оптимизациях скорости их выполнения. Как результат, с ростом проекта, всё большим покрытием его тестами и ростом команды время выполнения тестов выросло с нескольких секунд до десятков минут. А наличие быстрых тестов может быть также важно как и производительность всего приложения.


Как я с этим боролся и что получилось в итоге?


Что было до оптимизаций


Приложение разрабатывается на PHP 7.2 и Symfony 4.2. Для написания тестов пользуемся PHPUnit. Большая часть тестов представляет собой интеграционные тесты для REST API.


Запускаем тесты:


> bin/phpunit
...
Time: 20.17 minutes, Memory: 400.25 MB
OK (1494 tests, 5536 assertions)

На все тесты ушло около 20 минут. Это приводило к следующим проблемам:


  • автоматический процесс CI стал блокироваться на время выполнения тестов, приводя к исчерпанию свободных CI runners,
  • разработчики были вынуждены выстраиваться в виртуальную очередь, ожидая завершения CI pipelines,
  • на время выполнения всех тестов локально можно было пойти заварить чашечку кофе и полистать новости, что только подогревало желание просто игнорировать их запуск.


Начинаем искать основные узкие места и экспериментировать!


DAMADoctrineTestBundle


Эта библиотека позволяет изолировать работу с базой данных для каждого теста, в результате чего нам нужно подготовить тестовые данные и схему БД только один раз перед запуском тестов.


С помощью специального PHPUnit Listener перед запуском каждого теста открывается новая транзакция и откатывается после завершения теста. Также библиотека предоставляет средство кэширования метаданных и запросов для всех EntityManager. Пример конфигурации:


dama_doctrine_test:
    enable_static_connection: true
    enable_static_meta_data_cache: true
    enable_static_query_cache: true

Мы уже использовали эту библиотеку, но если у вас её нет, то обязательно попробуйте.


Рекомендуемая конфигурация Symfony


У фреймворка Symfony прекрасная документация, в которой в частности есть раздел про производительность. Советую ознакомиться с ним и проверить вашу конфигурацию.


Если вы уже используете PHP 7.4 и последние версии Symfony, то обязательно попробуйте предварительную загрузку классов.


У нас уже были выставлены все рекомендуемые параметры OPcache и PHP, поэтому возникла идея обновиться до PHP версии 7.4 и Symfony 4.4, проверив будет ли прирост производительности в тестах, тем более это не должно было занять много времени.


PHP 7.4 + Symfony 4.4


Заодно обновим PHPUnit до текущей последней версии — 9.1.5.


Перед обновлением ознакомимся с документацией по переходу на новые минорные версии Symfony:



Обновляем зависимости, исправляем проблемные места и начинаем настраивать OPcache и предварительную загрузку классов.


Вносим изменения в php.ini для dev, test и prod окружений:


;php.ini
opcache.preload=/var/www/web/var/cache/dev/srcApp_KernelDevDebugContainer.preload.php

И services.yaml:


parameters:
    container.dumper.inline_factories: true
    container.dumper.inline_class_loader: true

Запускаем наши тесты и...


Time: 17:15.483, Memory: 451.00 MB
OK (1494 tests, 5536 assertions)

Ускорились на 3 минуты, но нам этого мало.


XDebug


Для сборки и запуска приложения используется Docker с multistage сборкой. Для локальной отладки кода в dev сборку подключается PHP-расширение XDebug, которое также влияет на скорость выполнения кода. Во время прогона всех тестов отладка кода нам не нужна, поэтому для этого случая отключаем расширение с помощью небольшого скрипта (спасибо stackoverlow):


#!/bin/sh

php_no_xdebug() {
  temporaryPath="$(mktemp -t php.XXXXXX).ini"

  # Using awk to ensure that files ending without newlines do not lead to configuration error
  php -i | grep "\.ini" | grep -o -e '\(/[a-z0-9._-]\+\)\+\.ini' | grep -v xdebug | xargs awk 'FNR==1{print ""}1' | grep -v xdebug >"$temporaryPath"

  php -n -c "$temporaryPath" "$@"
  rm -f "$temporaryPath"
}

php_no_xdebug $@

Проверяем:


> php-no-xdebug.sh bin/phpunit
Time: 14:11.561, Memory: 445.00 MB
OK (1494 tests, 5536 assertions)

Отлично! Выиграли дополнительные 3 минуты.


Алгоритм хэширования паролей


Я уже упоминал, что большая часть тестов — интеграционные, которые начинаются с создания клиента:


protected function createAuthenticatedClient($username, $password): HttpKernelBrowser
{
    $client = static::createClient();
    // Отправляем HTTP запрос на аутентификацию, получаем JWT токен и устанавливаем его для последующих запросов.
    ...
    $client->setServerParameter('HTTP_Authorization', sprintf('Bearer %s', $data['token']));
    return $client;
}

Посмотрим конфигурацию Symfony:


# config/packages/security.yaml
security:
    encoders:
        App\Entity\User: bcrypt

Encoder описывает каким образом будут проверяться и храниться пользовательские пароли.
А теперь получим полную актуальную конфигурацию с учётом стандартных параметров:


> php bin/console debug:config security

Current configuration for extension with alias "security"
=========================================================

security:
    encoders:
        App\Entity\User:
            algorithm: bcrypt
            migrate_from: {  }
            hash_algorithm: sha512
            key_length: 40
            ignore_case: false
            encode_as_base64: true
            iterations: 5000
            cost: null
            memory_cost: null
            time_cost: null
            threads: null

Для тестов нам абсолютно не нужна такая криптоустойчивость, попробуем использовать md5 без каких-либо итераций:


# config/packages/test/security.yaml
security:
  encoders:
    App\Entity\User:
      algorithm: md5
      encode_as_base64: false
      iterations: 0

Проверяем:


Time: 02:49.090, Memory: 439.00 MB
OK (1494 tests, 5536 assertions)

Hoooraaay! Давайте посмотрим сможем ли мы побежать еще быстрее...


Настраиваем логирование


В Doctrine ORM есть возможность логировать каждый выполняемый SQL запрос:


doctrine:
    dbal:
        logging: '%kernel.debug%'

Явно отключаем логирование для тестовой среды и изменяем уровень для остальных логов с debug на critical:


# config/packages/test/doctrine.yaml
doctrine:
    dbal:
        logging: false

# config/packages/test/monolog.yaml
monolog:
    handlers:
        docker:
            type: stream
            path: "php://stderr"
            level: critical
            channels: ["!event"]

Как результат, получаем ускорение в 20-30 секунд:


Time: 02:21.818, Memory: 445.00 MB
OK (1494 tests, 5537 assertions)

ParaTest


Все тесты запускаются последовательно один за другим и я решил посмотреть возможность запускать тесты параллельно. Для PHPUnit есть библиотека ParaTest, которая как раз делает то, что мне нужно. Устанавливаем через composer, дополнительной конфигурации не требуется, поэтому запускаем и смотрим:


> php-no-xdebug.sh vendor/bin/paratest

Running phpunit in 8 processes with /var/www/web/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/web/phpunit.xml.dist

..............................

Time: 02:03.122, Memory: 12.00 MB

OK (1494 tests, 5537 assertions)

К сожалению, большого прироста скорости здесь не получили. Возможно, это связано с самими тестами и, если у вас много unit-тестов, то результаты будут лучше.


Заключение


Используя все эти оптимизации, играясь с настройками и библиотеками я достиг оптимального времени выполнения всех тестов, снизив его с 20 до 2 минут.


Если у вас есть советы как ещё можно ускориться, буду рад их услышать и опробовать.