Не секрет, что чем больше проект, тем с большим количеством проблем он сталкивается даже в самых элементарных аспектах. В продукте Plesk, над которым я работаю, PHP является одним из основных языков, и количество кода на нем превышает 1 миллион строк. Соответственно, мы активно используем PHPUnit для тестирования. Кроме большого объема кода, поддержка двух платформ (Linux и Windows) доставляет нюансы, как и тот факт, что поддерживается несколько бранчей с приличной разницей возраста (крупные релизы), а активно вносят правки несколько десятков инженеров. В статье я хочу поделиться некоторыми практиками, которые мы используем при работе с PHPUnit.
Унификация
Первый момент, на котором нужно остановиться, — это унификация. Если вы единственный разработчик проекта, то, конечно, делать можно как угодно. Но если вы работаете над проектом не один, то лучше делать все единообразно в соответствии с принятыми практиками.
В мире PHP принято, чтобы зависимости устанавливались с помощью composer install
, а команда composer test
прогоняла набор тестов. В контексте PHPUnit это означает следующее. Зависимость на PHPUnit должна присутствовать в разделе "require-dev" в composer.json:
"require-dev": {
...
"phpunit/phpunit": "^9.5",
В разделе scripts, соответственно, должно присутствовать описание для команды test:
"scripts": {
...
"test": "phpunit",
Различные линтеры и статические анализаторы, если используются, тоже должны оказаться частью этой команды:
"scripts": {
...
"test": [
"@phpcs",
"@phpstan",
"@psalm",
"@phpunit"
],
Далее конфигурацию для PHPUnit нужно определить в phpunit.xml.dist, а файл phpunit.xml занести в .gitignore. Тем самым мы унифицируем опции запуска PHPUnit, оставляя возможность локального оверрайда для каких-то экспериментов. Репозиторий после клонирования, прогона composer install
и запуска composer test
не должен требовать каких-то дополнительных манипуляций. Поэтому в phpunit.xml.dist определяем, где искать тесты, что исключать, какие опции использовать и т.п.
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="common/php/tests/bootstrap.php"
executionOrder="random"
...
>
<php>
<ini name="memory_limit" value="-1"/>
<ini name="display_errors" value="true"/>
...
</php>
<testsuites>
<testsuite name="Plesk Common TestSuite">
<directory>common/php/tests</directory>
<exclude>common/php/tests/stubs</exclude>
...
</testsuite>
</testsuites>
<coverage includeUncoveredFiles="true">
...
</coverage>
</phpunit>
Осталось определиться с версией PHP, необходимыми расширениями и занести эту информацию в composer.json:
"require": {
"php": "^7.4",
"ext-fileinfo": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
...
}
Базовая подготовка закончена. С одной стороны, все просто. С другой стороны, регулярно попадаются проекты, где какой-то из моментов выше оказался проигнорирован.
Docker
А куда же без него? Раз уж мы заговорили об унификации, то неоценимую помощь оказывает и использование Docker’а. Речь не только о его необходимости для запуска тестов в рамках CI-процесса. Для тех, кто не использует PHP в ежедневной работе, например, для QA-инженера, может быть удобным запуск тестов в Docker. Удобным в первую очередь тем, что снимает необходимость в установке нужной версии PHP со всеми расширениями на локальную машину. Кроме того, это если в разных релизах использовалась разная версия PHP, то использование Docker’а облегчает бэкпорт патчей и прогон тестов в соответствующих бранчах.
Организовать все это можно в виде отдельного Dockerfile’а, например, Dockerfile-test со следующим содержанием:
FROM php:7.4-cli
RUN apt-get update \
&& apt-get install -y libxslt1-dev libzip-dev \
&& docker-php-ext-install xsl \
&& docker-php-ext-install intl \
&& docker-php-ext-install zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
Далее создаем инструкции для Docker Compose (в моем случае в файле docker-compose.test.yml):
version: '3'
services:
tests:
build:
context: .
dockerfile: Dockerfile-test
command: bash -c "cd /opt/plesk && composer install && composer test"
volumes:
- .:/opt/plesk
В итоге получается достаточно идиоматический запуск тестов:
docker-compose -f docker-compose.test.yml run tests
Разница по времени между между локальным прогоном и прогоном в Docker’е в моем конкретном случае составляет 3 раза. То есть примерно 30 секунд против 10 секунд для локального прогона.
PhpStorm
Для написания PHP кода обычно используется PhpStorm. Есть в нем и удобные инструменты по работе с PHPUnit.
Во-первых, это запуск тестов, выбирая конфигурацию из меню Run (или контекстного меню) phpunit.xml.dist или директорию, где расположены тесты. Накладные расходы на дополнительную визуализацию в PhpStorm на моей локальной машине в конкретном проекте (~4500 тестов.) плавают в диапазоне 10-30%, но в абсолютных цифрах — это 13 секунд, против 10 секунд при запуске в терминале, что совершенно несущественно.
Второй удобный момент при интенсивной работе с тестами и их активном написании — это режим “наблюдения”. Включаем его в соответствующей панели:
После каждого изменения будет происходить перезапуск тестов, а по его окончанию — всплывать соответствующее уведомление о результатах прохождения.
При наличии нескольких мониторов, очень удобно вынести окно с тестами на отдельный монитор и включить автоматический перезапуск. А чтобы тесты не перезапускались слишком часто, можно поиграться с настройкой задержки запуска:
Внешнее наблюдение за тестами
Функционал “наблюдения” за тестами в PhpStorm существует уже года 3. До этого задача решалась с помощью внешнего “наблюдателя”. Однако и сейчас по определенным причинам внешний “наблюдатель” может быть полезен (например, вы правите код в vim’е или VSCode).
Наиболее популярный и живой проект по данной теме это phpunit-watcher. Добавляем его с помощью composer и определяем phpunit-watcher.yml примерно следующего содержания:
watch:
directories:
- common/php
- ...
fileMask: '*.php'
phpunit:
binaryPath: common/php/plib/vendor/bin/phpunit
arguments: '--stop-on-failure'
Также в composer.json в раздел scripts добавляем еще одну команду:
"scripts": {
...
"test:watch": "phpunit-watcher watch",
...
Таким образом, для того, чтобы запустить тесты под “наблюдением”, используется команда composer test:watch
Отправляем ее жить в окошко терминала на отдельный монитор и получаем удобство наблюдения, аналогичное PhpStorm’у.
Контроль уровня покрытия
При росте кодовой базы и количества тестов возникает желание удерживать code coverage хотя бы на уже существующем уровне (а в идеале — увеличивать его). Можно сколько угодно пытаться контролировать это руками (например, силами какого-то конкретного инженера), а можно поручить эту задачу роботу.
Схема выглядит следующим образом. Сначала выполняем подсчет code coverage, смотрим процент покрытия и устанавливаем его как отправную точку. Далее создаем скрипт, который будет возвращать ненулевой код возврата (определяя падение), если текущий процент code coverage стал ниже отправной точки. Данный скрипт используется в проверках на pull request’ах, таким образом не давая замержить изменения, если процент code coverage упал. Добавил новый код? Нужно добавить тесты. С роботом-ревьювером уже нельзя договориться, мол, я чуть позже их добавлю. Он беспристрастно поставит блокировку.
Для подсчета code coverage используется расширение Xdebug. На данный момент, на версии 3.0 на всех проектах, которых смотрел, дело заканчивается segfault’ом (есть как “плавающие” баги, так и стабильно повторяемые проблемы), поэтому продолжаем пока использовать 2.9.0. Подключение расширения с настройками по умолчанию (xdebug.mode=develop) даже без подсчета code coverage приводит к 2-3 кратному замедлению прогона тестов. В конкретном случае с ~4500 тестами на моей локальной машине процесс замедляется с 10 секунд до 27 секунд. Пока еще не сильно критично, но уже довольно заметно. Если запустить прогон тестов вместе с подсчетом code coverage, то он займет в моем случае больше 30 минут. Если процент code coverage упал, вы добавляете новые тесты и несколько раз выполняете их прогон, то ждать несколько раз по 30 минут — это довольно долго.
Анализ показывает, что больше всего времени требуется для генерации отчета в HTML. Так как сам отчет нас не особо интересует, то можно воспользоваться опцией --coverage-php, а далее полученный файл проанализировать собственным скриптом. В итоге проверка текущего процента code coverage из 30 минут превращается в 2 минуты на прогон тестов и еще примерно 2,5 минуты на анализ репорта (напомню, что проект довольно большой, и файл занимает более 60 Мб). Есть еще поле для оптимизации, но текущий вариант уже устраивает. Например, сократить первую фазу с 2 минут до 1 минуты можно с помощью pcov.
В phpunit.dist.xml нужно определиться с секцией coverage. Также важно указать опцию includeUncoveredFiles, потому что процент покрытия нужно считать от всех файлов, а не только тех, которых “касались” тесты.
<coverage includeUncoveredFiles="true">
<include>
<directory suffix=".php">common/php</directory>
...
</include>
<exclude>
<directory>common/php/plib/locales</directory>
<directory>common/php/plib/vendor</directory>
<directory>common/php/tests</directory>
...
</exclude>
</coverage>
В composer.json формируем команду для проверки с учетом всего вышесказанного:
"scripts": {
...
"test-coverage-threshold": [
"@php -dzend_extension=xdebug.so -dxdebug.mode=coverage common/php/plib/vendor/bin/phpunit --coverage-php .phpunit.coverage.php",
"@php -dzend_extension=xdebug.so common/tools/coverage-threshold.php .phpunit.coverage.php 12.49"
],
...
Где магическая цифра 12.49 — это текущая отправная точка — процент code coverage, ниже которого опускаться нельзя. Она не должна сильно отставать от текущего состояния, и периодически при добавлении новых тестов нужно не забывать ее подкручивать.
Повышение качества кода тестов
Когда тестов становится сотни или тысячи, то возникает проблема их взаимозависимости. Не только код самих тестов, но и код продукта будет преподносить различные сюрпризы. По умолчанию запуск тестов идет в рамках одного PHP-процесса. Любое кэширование, static’и, синглтоны, регистры или, не дай бог, глобальные переменные и даже заглушки и моки обязательно станут проблемой. Нужно понимать, что в большинстве случаев связность возникает совершенно неосознанно, поэтому нужно превентивно бороться с человеческим фактором.
Один из стресс-методов для проверки является запуск тестов командой composer test -- --process-isolation
. В таком режиме каждый тест будет запускаться в рамках отдельного PHP-процесса. Изоляция — это прекрасно, но на практике в таком режиме возникает сразу несколько нюансов. Во-первых, работает это все крайне медленно. Вместо 10 секунд будет уже порядка 14 минут в моей конкретной ситуации. Во-вторых, не все вещи будут работать в такой конфигурации. Например, в data provider’ах можно использовать только сериализуемые структуры (а коллеги-программисты могли надобавлять туда уже замыканий, моков и других динамических радостей). С первой проблемой можно пытаться бороться с помощью ParaTest, однако у него есть еще дополнительные ограничения.
Относительной альтернативой опции --process-isolation
является запуск тестов в случайном порядке. Для этого можно использовать опцию командной строки --order-by=random
, либо указать в phpunit.xml.dist для корневого тега атрибут executionOrder="random"
. Локализовывать и отлаживать проблемы заметно сложнее, чем в случае с --process-isolation
, но вполне реально. Обращаем внимание на сгенерированный random seed в начале вывода от PHPUnit и повторяем прогон командой ниже:
composer test -- --order-by=random --random-order-seed=1617073223
Тест, который падает, может находиться за сотни тестов от того, который создает проблемы. Для того, чтобы по сути просимулировать поведение опции --process-isolation
, можно в рамках setUp/tearDown сбрасывать принудительно все кэши, приводить конфигурацию моков в исходное состояние и делать прочие инициализационные действия. В итоге создание взаимозависимых тестов сильно осложняется. Возможно, такой тест не будет пойман с первой попытки, но после несколько прогонов он обязательно всплывет.
Еще один момент, на который стоит обратить внимание, это скорость выполнения каждого отдельного теста. Один из вариантов ее узнать — это использование опции --log-junit
. В результате будет получен XML-файл с информацией о времени, затраченном на каждый тест. Можно написать простенький скрипт для анализа, а можно воспользоваться встроенным функционалом в PhpStorm и сортировкой по времени:
Код тестов — это тоже код, поэтому не стоит игнорировать проверки на соответствие принятого стиля или следования общепринятым практикам написания хорошего кода. Автоматизированные линтеры также должны проверять и директорию с тестами. На самом деле, тесты с низким качеством кода довольно часто в итоге оказываются “моргающими” (flaky) тестами.
Поддержка двух платформ (Linux и Windows)
Если вам “посчастливилось” писать продукт под две платформы (Linux и Windows), то в рамках тестов нужно учитывать то, каким образом проверяется платформозависимый код. “Вилки” по константе PHP_OS, использование PHP_EOL — все это обязательно создаст проблемы, а перебить их не получится даже с помощью runkit’а. В идеале, прогон тестов на PHP код для Windows должен иметь возможность сделать и разработчик, у которого рабочая машина под Linux или Mac. Поэтому механизм определения платформы лучше сразу сделать конфигурируемым. На поздних этапах вкручивать его довольно тяжело. Если платформозависимого кода довольно много, может оказаться проще использовать два запуска тестов, указывая платформу через переменную окружения:
PHP_OS=WINNT composer test
PHP_OS=Linux composer test
Поле для экспериментов
Периодически выходят новые “мажорные” версии PHP, и самый первый шаг и довольно быстрый способ проверки и поиска проблем — это прогон тестов. В этом сильно помогает Docker и упомянутый выше Dockerfile, чтобы не влиять на локальную машину. Ведь для большого проекта момент готовности кодовой базы к новой версии PHP и сам момент перехода на новую версию — довольно разнесенные по времени события. Соответственно, в первую очередь делаются “forward compatible” изменения, и проверяется работоспособность тестов на двух версиях (старой и новой версии PHP).
Проверку новых фичей языка и синтаксиса также очень удобно делать в рамках кода тестов. Это довольно безопасно, так как код тестов не идет в релиз, и при этом вы получаете возможность “прочувствовать” нововведения. На самом деле, это могут быть любые нововведения в рамках кодовой базы (необязательно новые фичи языка). Допустим, решили, например, следовать PSR-2 и убрать символ нижнего подчеркивания из имен protected и private переменных и методов. Первое, где можно попробовать обкатать скрипты замены и рефакторинга, это код тестов.
Заключение
В заключение отмечу основные моменты. Когда тестов становится много, то их качество выходит на первый план. Контроль уровня покрытия лучше отдать на откуп автоматическим проверкам pull request’ов, а код тестов — это очень удобное и безопасное поле для экспериментов.
Как говорится, да прибудут с вами всегда “зеленые” тесты :)