Беги, PHPUnit, беги: как я оптимизировал время выполнения тестов
С самого начала работы над одним из 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 минут.
Если у вас есть советы как ещё можно ускориться, буду рад их услышать и опробовать.