PHPUnit перед продакшеном: чек-лист, что успеть проверить
Тесты зелёные, но это не значит, что код готов к деплою. Разбираем, что проверить в PHPUnit-наборе перед выкатом: изоляцию, детерминизм, покрытие критичных путей и скорость прогона.

«У нас же 800 тестов, все зелёные» — и через час после деплоя падает оформление заказа, потому что тест мокал платёжный шлюз так, что реальный ответ туда не пролезал. Зелёный прогон PHPUnit говорит только одно: код прошёл ровно те проверки, которые вы написали. Перед продакшеном имеет смысл прогнать не сам код, а собственный набор тестов — по чек-листу ниже. Ориентируемся на PHPUnit 10.x и 11.x; отличия версий отмечены отдельно.
Это не гайд «как написать первый тест» и не разбор внутренностей раннера. Угол узкий: у вас уже есть набор, он проходит, и вы решаете, можно ли ему доверять на релизе. Организацию CI и стратегию покрытия в целом здесь не трогаем — только то, что проверяют глазами и командами перед выкатом.
Что должно быть под рукой
- PHPUnit 10.x или 11.x (проверьте
vendor/bin/phpunit --version); - конфиг
phpunit.xmlилиphpunit.xml.distв корне; - для покрытия — включённый Xdebug в режиме
coverageили PCOV; - доступ к CI-логам последних прогонов, чтобы сравнить время и flaky-падения.
1. Тесты изолированы друг от друга
Главная ложь зелёного прогона — порядок. Тесты, которые проходят подряд, но падают при случайном порядке, делят между собой состояние: статические свойства, синглтоны, записи в БД, переменные окружения.
Проверяется одной командой — прогоном в случайном порядке:
vendor/bin/phpunit --order-by=random
PHPUnit выведет seed, с которым запускался прогон. Если что-то упало — повторите с тем же seed, чтобы воспроизвести:
vendor/bin/phpunit --order-by=random --random-order-seed=1234
Нашли зависимость — ищите статическое состояние. Типичный источник: класс с private static $instance, который один тест инициализировал, а второй считает уже готовым. Сбрасывайте такое в tearDown() или разносите по процессам.
Тест, который проходит только вторым в файле, — это не тест, а совпадение. В проде порядок вызовов вы не контролируете.
2. Нет тестов, которые ничего не проверяют
Тест без единого ассерта PHPUnit по умолчанию помечает как «рискованный», но многие команды это игнорируют. Включите строгий режим в конфиге, чтобы такие тесты падали, а не молчали:
<phpunit
failOnRisky="true"
failOnWarning="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true">
</phpunit>
После этого прогон покажет тесты, которые дошли до конца без проверок или что-то печатали в вывод. Второе особенно коварно: var_dump(), забытый после отладки, ломает вывод в CI и иногда роняет тесты, работающие с заголовками.
3. Замоканы внешние вызовы, а не бизнес-логика
Частая ошибка — замокать слишком много. Если вы подменяете сам сервис, который тестируете, тест проверяет мок, а не код.
Мокать нужно границы: HTTP-клиенты, платёжные шлюзы, отправку почты, обращения к сторонним API. А вот метод собственного класса, ради которого написан тест, мокать нельзя.
Быстрая проверка глазами: в тесте на метод OrderService::place() не должно быть ->method('place'). Если он есть — вы замокали то, что собирались проверить.
Отдельно проверьте, что моки строгие. createStub() вернёт на любой незаданный вызов дефолт (null, пустую строку), и тест не заметит лишних обращений. Там, где важно число и порядок вызовов, используйте createMock() с expects().
4. Тесты детерминированы
Тест, который зависит от текущего времени, случайного числа или порядка ключей в массиве из БД, рано или поздно упадёт в CI без изменений в коде. Это те самые flaky-тесты, которые все привыкли перезапускать.
Источники недетерминизма перед релизом стоит вычистить:
- Время. Не сравнивайте с
new DateTime('now')внутри теста. Прокидывайте время как зависимость (интерфейс часов) или используйте фиксированную дату. - Случайность. Тест на генерацию токена не должен проверять конкретное значение — проверяйте длину, формат, уникальность.
- Порядок из БД.
SELECTбезORDER BYне гарантирует порядок строк. Сортируйте результат перед сравнением или сравнивайте как множество.
Прогоните набор три-четыре раза подряд. Если хоть раз что-то моргнуло без изменений кода — это кандидат на разбор, а не на перезапуск.
5. Покрыты критичные пути, а не проценты
Цифра общего покрытия обманчива: 85% могут покрывать геттеры и не трогать ветку с проверкой прав или расчётом суммы. Перед релизом смотрите не число, а какие именно строки в критичных классах остались непокрытыми.
vendor/bin/phpunit --coverage-html build/coverage
Откройте отчёт и пройдите глазами по классам, отвечающим за деньги, доступ и запись данных. Красные строки в ветке «отказ платежа» опаснее, чем недобранный процент по проекту.
Если Xdebug и PCOV не установлены, отчёт покрытия не соберётся — команда завершится предупреждением. Установка и режим покрытия описаны в документации PHPUnit по code coverage.
Не гонитесь за 100%
Порог покрытия полезен как защита от регресса, но требование «100% или деплой запрещён» толкает людей писать тесты ради строки, а не ради поведения. Договоритесь о пороге для критичного слоя и живите с меньшим числом по остальному коду.
6. Набор укладывается в приемлемое время
Медленный набор перестают запускать локально, и он ловит ошибки только в CI — то есть поздно. Найдите самые тяжёлые тесты:
vendor/bin/phpunit --log-junit build/junit.xml
В JUnit-отчёте у каждого теста проставлено время. Обычно 5% тестов съедают половину времени прогона. Чаще всего это тесты, которые лезут в реальную БД или файловую систему там, где хватило бы мока.
Разделите набор на группы через #[Group] (атрибут PHPUnit 10+) или @group (аннотация), чтобы быстрый unit-набор гонять на каждый коммит, а тяжёлые интеграционные — реже:
// быстрый прогон без интеграционных
vendor/bin/phpunit --exclude-group integration
Сводный чек-лист
| Проверка | Команда или настройка | Сигнал тревоги |
|---|---|---|
| Изоляция | --order-by=random | Падение зависит от порядка |
| Пустые тесты | failOnRisky="true" | Тест без ассертов |
| Границы моков | Ревью вручную | Мок на тестируемый метод |
| Детерминизм | 3–4 прогона подряд | Flaky без изменений кода |
| Покрытие критичного | --coverage-html | Красные строки в денежной ветке |
| Время прогона | --log-junit | 5% тестов держат половину времени |
Если что-то падает нестабильно
Самое частое перед релизом — «локально проходит, в CI падает». Причины по убыванию частоты:
- Разное окружение. В CI другая версия PHP, другой часовой пояс, другая локаль. Зафиксируйте PHP-версию в CI под ту, что в проде, а часовой пояс — в
php.iniили бутстрапе тестов. - Общая БД между параллельными джобами. Два прогона пишут в одну таблицу. Разводите по отдельным схемам или БД на джобу.
- Кеш зависимостей. Локально стоит старая версия пакета, в CI подтянулась новая. Коммитьте
composer.lockи ставьте с--no-devтолько там, где dev-зависимости не нужны.
Что делать дальше
После того как чек-лист пройден, зафиксируйте строгие настройки (failOnRisky, failOnWarning) в общем phpunit.xml.dist, чтобы они действовали у всех и в CI, а не только на вашей машине. Добавьте случайный порядок в CI-прогон — так регресс по изоляции поймается сразу, а не через полгода. И заведите отдельную группу для медленных тестов: быстрый набор на каждый коммит держит культуру запускать тесты локально, а не «дождёмся CI».
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: PHPUnit Documentation — Code Coverage, PHPUnit Documentation — Command-Line Test Runner
Частые вопросы
У меня PHPUnit 9, часть команд из статьи не работает — что делать?
Прогон с --order-by=random иногда падает, иногда нет. Это нормально?
Отчёт покрытия не собирается, пишет что-то про driver — почему?
Обязательно ли гнать все тесты перед каждым деплоем, если их тысячи и это долго?
Насколько высоким должно быть покрытие перед продакшеном?
Тесты зелёные локально, но падают в CI — с чего начинать разбор?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.