Повышение производительности тестов Symfony

Improve Symfony Tests Performance

22 Ноября 2021 | Symfony 5

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

Повышение производительности тестов Symfony

Во-первых, давайте начнем с нашего базового плана для одного из проектов.

  • 2285 - общее количество тестов
  • 979 модульные тесты
  • 1306 функциональные тесты (Symfony WebTestCase, тестирование конечных точек API)
  • Symfony 5.3, PHP 8.1

Весь набор тестов перед тем оптимизации Time: 12:25.512, Memory: 551.01 MB.

Почему важно иметь быстрый и надежный набор тестов? Причин много, но две основные:

  1. Чем больше тестов нужно выполнить, тем больше это раздражает разработчика.
  2. Чем больше ресурсов (ЦП, память) занимает набор тестов, тем хуже для CI-сервера (он может замедлить другие задания / сборки).

Посмотрим, что мы можем здесь сделать.

Использование более простого хешера паролей

Хешеры паролей используются в Symfony для хеширования необработанного пароля во время сохранения пользователя в базе данных и для проверки действительности пароля. Для производства мы должны использовать более надежные алгоритмы хеширования, которые довольно медленные (Argon2, bcrypt и т. Д.).

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

Вместо использования упомянутых алгоритмов хеширования мы можем использовать md5 для test среды и увеличить скорость набора тестов.
 

# config/packages/security.yaml для dev & prod окружения
security:
    password_hashers:
      App\Entity\User:
        algorithm: argon2i


# override in config/packages/test/security.yaml для test окружения
security:
    password_hashers:
        App\Entity\User:
            algorithm: md5
            encode_as_base64: false
            iterations: 0


Давайте снова запустим phpunit и проверим результат:
 

vendor/bin/phpunit

# ...

Time: 05:32.496, Memory: 551.00 MB


уже лучше:
 

- Time: 12:25.512, Memory: 551.01 MB
+ Time: 05:32.496, Memory: 551.00 MB


Это в 2,25 раза быстрее, чем было раньше, просто за счет изменения функции хеширования. Это одна из самых ценных оптимизаций производительности, которую можно выполнить за считанные минуты, и, честно говоря, я не знаю, почему ее не используют по умолчанию в своих дистрибутивах API-Platform или Symfony.
 

Не использовать ведение журнала Doctrine по умолчанию

За время работы с набором тестов с отключенным ведением журнала Doctrine мы не испытали никаких неудобств. Когда возникает ошибка, трассировка стека в любом случае будет иметь неудавшийся SQL-запрос в журнале / выводе. Таким образом,

 

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

Отключим ведение журнала доктрины для test среды:

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

Снова запустите тесты и сравните с предыдущими результатами:
 

- Time: 05:32.496, Memory: 551.00 MB
+ Time: 04:13.959, Memory: 547.01 MB


Такая легкая перемена, и еще одна минута прошла. Это улучшение сильно зависит от того, как вы используете регистратор (монолог) для test среды. Общий совет: не записывайте слишком много для тестов. Например, настройка уровня журнала debug не требуется, а для тестов вы можете использовать конфигурацию, аналогичную производственной - fingers_crossed обработчик с action: error.

 

Установить APP_DEBUG false

Он был предложен еще в 2019 году @javiereguiluz , но не получил достаточной популярности. Хотя теперь в документации Symfony упоминается это улучшение в параграфе «Настройка тестовой среды» :

 

Рекомендуется запускать тест с debug установленным значением false на вашем CI-сервере, так как это значительно повышает производительность теста.


Чтобы отключить режим отладки, добавьте в phpunit.xml файл следующую строку :

<?xml version="1.0" encoding="UTF-8"?>

<phpunit >
    <php>
        <!--  ..... -->
        <server name="APP_DEBUG" value="false" />
    </php>
</phpunit>

Отключение debug режима также отключает очистку кеша. И если ваши тесты не запускаются каждый раз в чистой среде (например, тесты выполняются локально, где вы всегда изменяете исходные файлы), вам придется вручную очищать кеш при каждом запуске PHPUnit.

Вот как это выглядит внутри PHPUnit bootstrap файла:
 

<?php

use Symfony\Component\Filesystem\Filesystem;

require dirname(__DIR__).'/vendor/autoload.php';

// ...

(new Filesystem())->remove([__DIR__ . '/../var/cache/test']);

echo "\nTest cache cleared\n";


Мы можем смириться с этим «неудобством», особенно с пользой, которую оно получает. Готовы увидеть результаты?
 

- Time: 04:13.959, Memory: 547.01 MB
+ Time: 02:45.307, Memory: 473.00 MB


Помимо скорости, есть еще одно (я думаю, главное) преимущество использования APP_DEBUG=false. Функциональные тесты начинают отвечать, Internal Server Errorа не сообщением об исключении из исходного кода.

Это может стать серьезным препятствием в проектах Symfony. Я видел пару проектов, где люди использовали следующий код:
 

# App\Controller\SomeController.php

throw new ConflictHttpException('There is a conflict between X and Y');


Исключение в тестах, что ответ содержит именно это сообщение об исключении There is a conflict between X and Y в функциональных тестах, когда APP_DEBUG=true, хотя на самом деле ответное сообщение находится The server returned a "409 Conflict". с APP_DEBUG=false, и запуск теста завершается неудачно после использования APP_DEBUG=false.

Использование APP_DEBUG=falseс функциональными тестами - правильный путь с точки зрения ошибок / исключений, и он имитирует реальную производственную среду.

 

Полностью отключить Xdebug

Многие из нас устанавливают его Xdebug в целях отладки, добавляя его в базовые образы докеров разработки или прямо на локальный компьютер. Если вы используете pcov для сбора покрытия или даже если вы не собираете покрытие вообще , Xdebug все равно может повлиять на производительность тестов, даже если вы используете, xdebug.mode=debug, но не используете xdebug.mode=coverage.

Поэтому обязательно полностью отключите Xdebug перед запуском тестов:
 

XDEBUG_MODE=off vendor/bin/phpunit
- Time: 02:45.307, Memory: 473.00 MB
+ Time: 01:47.368, Memory: 449.00 MB

Нет необходимости устанавливать Xdebug на CI, если вы собираете покрытие с pcov, поэтому в нашем случае CI не пострадала.

 

Выполнение параллельных тестов с использованием Paratest

Каждый хороший инструмент имеет возможность выполняться параллельно (некоторые из них: Psalm, PHPStan, Infection). Чтобы получить всю мощность от многоядерного процессора вашего локального компьютера или CI-сервера, обязательно запускайте тесты параллельно.

Лично я рекомендую использовать Paratest. Это оболочка, PHPUnitкоторая просто работает, даже покрытие кода может быть собрано и объединено из разных потоков.

Если вы используете БД для своих функциональных тестов, вам придется настроить столько схем БД, сколько потоков вы хотите использовать Paratest. Эта библиотека предоставляет собой переменную окружения TEST_TOKEN=<int>, которая может использоваться для определения того, какое соединения использовать.

Представим, вы запускаете свои тесты с 4 потоками, поэтому вам нужно 4 схемы БД и 4 разных подключения к БД:

vendor/bin/paratest --processes=4 --runner=WrapperRunner

Чтобы настроить Doctrine на использование разных подключений, можно использовать следующую конфигурацию:
 

# config/packages/test/doctrine.yaml

parameters:
    test_token: 1

doctrine:
    dbal:
        dbname: 'db_%env(default:test_token:TEST_TOKEN)%'


В этом случае, в зависимости от переменной TEST_TOKEN, PHPUnit будет запускать приложения , подключенные к различным базам данных: db_1, db_2, db_3, db_4.

Зачем это нужно? Поскольку тесты, выполняемые одновременно для одной и той же БД, могут нарушать друг друга: они могут перезаписывать или удалять одни и те же данные, транзакции могут быть отключены или заблокированы по времени. Таким образом, изолированное выполнение тестов - когда каждый поток использует свою собственную БД - решает эту проблему.

Выполнение набора тестов с 4 потоками для нашего проекта дает следующий прирост производительности:
 

- Time: 01:47.368, Memory: 449.00 MB
+ Time: 00:34.256, Memory: 40.00 MB


Вы помните, с чего мы начали Time: 12:25.512, Memory: 551.01 MB?

После всех изменений это Time: 00:34.256, Memory: 40.00 MB! Это в 21 раз быстрее, чем было в начале.

 

Соберите покрытие, pcovесли возможно

Теперь давайте посмотрим, как мы можем повысить скорость набора тестов при сборе данных о покрытии. Чтобы сделать его более заметным, давайте сделаем шаг назад и запустим наш набор тестов без Paratest, используя 1 поток PHPUnitс, Xdebug а затем pcov в качестве драйвера покрытия.
 

- Time: 03:49.987, Memory: 575.00 MB # Xdebug
+ Time: 02:13.209, Memory: 519.01 MB # pcov


Как видим, для данного конкретного случая pcov в 1,72 раза быстрее, чем Xdebug. В зависимости от вашего проекта вы можете получить еще лучшие результаты (например, в 5 раз быстрее )

pcov имеет сопоставимую точность в отчетах о покрытии с Xdebug, поэтому это должно быть отличным выбором, если вам не требуется покрытие пути / ответвления (которое не поддерживается pcov).

 

Соберите покрытие с cacheDirectory

Как предлагается в репозитории Paratest:
 

Начиная с PHPUnit 9.3.4, настоятельно рекомендуется установить каталог кэша покрытия, см. Журнал изменений PHPUnit @ 9.3.4 .


Перед тем, как делать это обновление, давайте посмотрим, сколько времени потребуется для запуска PHPUnit со сбором метрик покрытия:
 

XDEBUG_MODE=off vendor/bin/paratest -p4 --runner=WrapperRunner --coverage-clover=reports/coverage.xml --coverage-html=reports

...

Time: 01:02.904, Memory: 478.93 MB
Generating code coverage report ... done [00:10.796]


Общее время создания отчетов о покрытии кода составляет 1 мин. 13 с.

Теперь давайте добавим cacheDirectoryв phpunit.xmlфайл:
 

- <coverage>
+ <coverage cacheDirectory=".coverage-cache">


и PHPUnit снова запустите со сбором покрытия кода. Вот результаты:
 

- Time: 01:02.904, Memory: 478.93 MB
- Generating code coverage report ... done [00:10.796]
+ Time: 00:43.759, Memory: 475.70 MB
+ Generating code coverage report ... done [00:05.394]


Хорошо, теперь намного быстрее. В действительно большом наборе тестов мы смогли сократить время CI с 11 до 5 минут благодаря cacheDirectoryнастройке.
 

Узнайте больше о том, как это работает под капотом, в сообщении Себастьяна Бергманна: https://thephp.cc/articles/caching-makes-everything-faster-right

 

Прочие рекомендации

 

Используйте dama/doctrine-test-bundle для отката транзакции после каждого теста

Есть много способов работы с базой данных в функциональных тестах, включая настройку схемы БД перед каждым тестовым примером (по setUp() методу), усечение только измененных таблиц после каждого тестового примера и так далее.

Что нам следует знать:

  1. Нам не нужно настраивать схему БД для каждого теста. Это разовая операция перед запуском тестов.
  2. Мы не должны вставлять необходимые для работы приложения данные для каждого теста. Примеры: справочные таблицы, пользователь-администратор, страны и штаты. По сути, все, что статично и хранится в БД, должно быть вставлено один раз перед запуском тестов. Эти данные следует повторно использовать во всех функциональных тестах.

Когда эти 2 пункта выполнены, все, что нам нужно сделать, это восстановить БД до того же состояния, в котором он был при запуске тестового примера. И вот когда dama/doctrine-test-bundle вступает в силу.

Он украшает соединение с базой данных Doctrine и запускает транзакцию перед каждым тестом, а затем откатывает ее после нее. Выполняя a ROLLBACK, каждый тест оставляет базу данных в исходном состоянии после выполнения, в то время как во время теста мы можем делать все, что захотим - вставлять, обновлять, удалять и искать.
 

Это приводит к повышению производительности, поскольку нет необходимости перестраивать схему, импортировать резервную копию SQL-дампа или повторно вставлять фикстуры перед каждым тестовым набором.


Как всегда, результаты зависят от вашего проекта, но вот пример повышения производительности на 40% с помощью этого пакета / подхода.

 

Комбинируйте функциональные и модульные тесты. Предпочитать модульные тесты

Функциональные тесты очень эффективны, поскольку они не только проверяют независимую единицу кода, но и проверяют, как все работает вместе. Например, если вы тестируете конечные точки API, вы можете протестировать весь поток своего приложения: от Requestдо Response.

Однако тестирование каждого отдельного условия и строки кода только с помощью функциональных тестов обходится дорого, так как требует слишком большого количества медленных тестов.

Представьте себе, у нас есть API для получения конечной детали заказа: GET /orders/{id}. И должны применяться следующие бизнес-правила:
 

  • Admin может просматривать детали заказа
  • Manager может просматривать детали заказа
  • Userкто разместил этот заказ, может просматривать детали заказа
  • Userкому был предоставлен общий доступ, но не разместил этот Заказ, может просматривать сведения о Заказе
  • Любой другой аутентифицированный User не может просматривать детали заказа
  • Не аутентифицирован, User не могу просмотреть детали заказа


Конечная точка API защищена проверкой безопасности:
 

#[IsGranted('ORDER_VIEW', object)]
public function viewOrder(Order $order) { /* ... */ }


Чтобы удовлетворить эти требования, нам нужно написать как минимум 6 тестов. Но вместо создания 6 медленных функциональных тестов мы можем создать 2, просто чтобы проверить, защищено ли действие в контроллере #[IsGranted] атрибутом.
 

public function test_guest_user_can_not_view_order_details(): void
{
    $order = $this->createOrder();

    // send request by guest user
    $this->sendRequest(Request::METHOD_GET, sprintf('/api/orders/%s', $order->getId()));

    $this->assertRequestIsForbidden();
}

public function test_admin_user_can_view_order_details(): void
{
    $this->logInAsAdministrator();

    $order = $this->createOrder();

    // send request by administrator user
    $this->sendRequest(Request::METHOD_GET, sprintf('/api/orders/%s', $order->getId()));

    $this->assertResponseStatusCodeSame(Response::HTTP_OK);
}


Все остальные случаи можно проверить в модульных тестах Security Voter и его логике. При таком подходе мы знаем, что наш избиратель вызывается во время вызова API (функциональный тест проверяет его), и все условия / ветки покрываются быстрыми модульными тестами.

Чтобы дать вам представление о том, насколько быстрые модульные тесты (из реального проекта, описанного выше):
 

XDEBUG_MODE=off vendor/bin/phpunit --testsuite=Unit

Time: 00:00.750, Memory: 66.01 MB

OK (979 tests, 2073 assertions)


Таким образом, 979 модульных тестов занимают меньше времени, чем 1s выполнение в 1 потоке, в то время как 1306 функциональных тестов занимают 1m 46s 1 поток. В этом случае модульные тесты выполняются в 105 раз быстрее. Пока выполняется 1 функциональный тест, мы можем запустить 100 модульных тестов!

Кроме того, наличие большего количества модульных тестов значительно ускоряет работу мутационного тестирования ( заражения ) для вашего проекта, в то время как функциональные тесты замедляют этот процесс.

Комментарии
Если у вас есть вопросы, не стесняйтесь оставлять комментарии ниже.
Загрузка комментариев...