Unit-тесты и PHPUnit

Первая проблема — тесты падают без видимой причины: нестабильность окружения и устаревшие зависимости
Клиенты часто жалуются, что одни и те же Unit-тесты иногда проходят, иногда нет. Это классический признак проблем с окружением: плавающие зависимости, разная версия PHP на локальной машине и сервере CI, нефиксированные версии PHPUnit. В 2026 году PHPUnit 11.x требует строго PHP 8.3+, а старые проекты на PHP 7.4 просто не смогут запустить новые тесты корректно. Стандартный выход — всегда фиксировать версию PHPUnit в composer.json и использовать файл .phpunit.dist.xml с явным указанием параметров окружения.
- Фиксируйте версию PHPUnit в секции require-dev композера:
"phpunit/phpunit": "^11.0". Это предотвращает автоматический апдейт на мажорные версии без вашего ведома. - В .phpunit.dist.xml укажите строгие настройки:
displayErrors="true" failOnWarning="true" failOnRisky="true". Такая конфигурация завалит сборку при любом предупреждении — это жёсткий стандарт качества. - Для каждого окружения создавайте отдельный файл .phpunit.xml (игнорируемый гитом), который переопределяет только пути к БД или кэшу — например,
cacheDirectory="/tmp/phpunit-cache-{env}".
После внедрения фиксации версий и жёстких настроек отчёта вы получаете нулевую вариативность прогона тестов. Любая ошибка или предупреждение — это блокировка деплоя, а не плавающий пасс. Окружение становится детерминированным, и вы перестаёте тратить время на расследование «магических» фейлов.
Вторая проблема — тесты тестируют не то, что нужно: путаница между Unit, Integration и Functional tests
Клиенты считают, что любой тест с PHPUnit — это Unit-тест. На самом деле PHPUnit — это фреймворк для написания разных типов тестов, но большинство разработчиков пишут интеграционные тесты (с реальной БД, с реальным HTTP-запросом), называя их «юнит-тестами». Разница критична: Unit-тест изолирует один класс, мокая все зависимости. Интеграционный тест проверяет связку нескольких классов. Смешение этих типов приводит к тому, что покрытие кода измеряется некорректно, а время прогона вырастает с 10 секунд до 10 минут.
- Строгое правило: каждый тестовый класс должен иметь аннотацию
@groupс типом:@group unit,@group integration,@group functional. Это позволяет запускать только нужную группу командойphpunit --group unit. - Для unit-тестов запретите в bootstrap.php автозагрузку базы данных и внешних сервисов. Используйте автозагрузчик только с
vendor/autoload.phpи никакихrequireдля конфигов приложения. - Все внешние зависимости в unit-тестах заменяйте на моки через
createMock()илиMockBuilder. Никаких реальных подключений к API, файловой системе или БД — это правило проверяется статическим анализатором в CI.
Результат — unit-тесты работают за миллисекунды, а не за секунды. Покрытие кода становится реальным: вы видите, какие методы действительно изолированы, а какие «случайно» проходят из-за подключения всей инфраструктуры. Скорость прогона unit-тестов в 2026 году должна быть менее 1 секунды на 100 тестов — иначе это не unit, это интеграция.
Третья проблема — тесты ничего не проверяют: пустые assert и отсутствие data provider
Частая картина: метод testSomething() содержит один вызов assertTrue(true) или вообще без assert (тест проходит, потому что не выброшено исключение). Формально покрытие есть, а фактически — нет проверки логики. Ещё хуже, когда один и тот же тест прогоняется с одними и теми же данными, не покрывая граничные случаи. PHPUnit предоставляет инструмент Data Provider, позволяющий запускать один тест с десятками разных наборов входных данных, но разработчики его игнорируют.
- Для каждого метода бизнес-логики пишите минимум 3 test-метода: один с корректными входными данными (happy path), один с null/пустыми данными, один с граничными значениями (максимум, минимум). Используйте
@dataProviderдля генерации массивов — это снижает дублирование кода в 5-7 раз. - В data provider возвращайте именованные массивы: ключ — описание кейса (например, 'Valid email, length = 5'), значение — массив с параметрами. При падении теста вы сразу видите, какой именно кейс упал, без копания в стеках.
- Никогда не используйте
assertTrue()для сравнения конкретного значения. ВместоassertTrue($result === 42);всегда пишитеassertSame(42, $result);. Разница:assertTrueне показывает ожидаемое и фактическое значение в ошибке — вы теряете суть.
После внедрения строгих правил по assert и обязательному использованию Data Provider вы получаете тесты, которые при падении сразу показывают точные входные и выходные данные. Устраняется ситуация «зелёный тест, а код сломан». Coverage report становится честным: строки кода действительно проверяются, а не просто выполняются.
Четвёртая проблема — тесты мешают рефакторингу: хрупкие тесты, привязанные к реализации
Когда вы меняете название приватного метода или переименовываете параметр, десятки тестов падают, хотя публичный интерфейс класса не изменился. Это признак тестов, которые тестируют детали реализации, а не поведение. PHPUnit поддерживает @covers аннотацию, которая явно декларирует, какой класс/метод тестируется. Разработчики её не используют, потому и тесты «ломаются» при любом рефакторинге. В 2026 году хороший тон — тестировать исключительно публичные методы через @covers ClassName, а все приватные методы тестировать только косвенно, через вызов публичных.
- Обязательная аннотация
@covers ClassName::methodNameдля каждого теста. Это даёт точную карту покрытия и при рефакторинге вы сразу видите, какие тесты реально затронуты. - Запретите вызов
setAccessible(true)в unit-тестах — это прямое нарушение изоляции. Если вам нужно проверить приватный метод, значит, он должен быть вынесен в отдельный класс и тестироваться как публичный. - Для всех тестов используйте статический анализ (PHPStan уровня max или Psalm) в CI, который проверяет, что тесты не используют недокументированные методы моков. Это защищает от случайного вызова реальных методов в моках.
Итог: рефакторинг перестаёт быть страшным. Вы меняете реализацию, а тесты падают только если нарушается контракт публичного API. Количество ложных срабатываний снижается до нуля. Время на адаптацию тестов после рефакторинга сокращается с часов до минут.
Пятая проблема — тесты не интегрированы в CI/CD pipeline: ручной запуск и игнорирование метрик
Многие разработчики запускают тесты локально раз в день, а в CI тесты либо не настроены, либо настроены на прогон всех тестов (включая медленные интеграционные) при каждом коммите, что занимает 20-30 минут. В 2026 году стандарт — многоступенчатый pipeline: сначала unit-тесты (группа unit, выполняются за 5 секунд), затем статический анализ, затем интеграционные тесты. Если unit-тесты падают, pipeline останавливается мгновенно, не тратя ресурсы на остальное.
- В CI (GitLab CI, GitHub Actions) пропишите отдельные джобы для unit-тестов:
phpunit --group unit --coverage-clover coverage.xml --log-junit test-results.xml. Параметр--coverage-cloverпозволяет генерировать отчёт в формате Clover, который легко интегрируется в любой dashboard. - Установите порог покрытия для unit-тестов: минимум 80% на уровне методов, иначе сборка фейлится. Используйте
--coverage-min 80в команде PHPUnit, это встроенная опция с версии 10. - Добавьте в pipeline шаг, который парсит test-results.xml и отправляет метрики в систему мониторинга (например, InfluxDB или Prometheus). Отслеживайте тренды: если покрытие упало на 1% за неделю — автоматически создаётся задача в трекере.
Результат: тесты становятся частью культуры непрерывной поставки (CD). Каждый коммит проверяется автоматически, пороги покрытия защищают от деградации, а метрики дают прозрачность всей команде. Время детекции дефектов сокращается с часов до секунд.
Шестая проблема — игнорирование рискованных тестов и нестабильных тестов (flaky tests)
Когда тесты падают по неочевидным причинам (например, из-за таймаута сети, порядка выполнения тестов, состояния shared memory), разработчики часто просто перезапускают CI, не разбираясь. Со временем накапливается 5-10% «серых» тестов, которые периодически падают. PHPUnit поддерживает атрибут @runInSeparateProcess и @preserveGlobalState для изоляции, но их редко используют. В 2026 году единственный способ борьбы с flaky-тестами — это жёсткая изоляция каждого unit-теста и запрет на использование static-свойств без сброса.
- Для каждого теста, который работает с глобальными переменными или static-свойствами, используйте
@runInSeparateProcessи@preserveGlobalState disabled. Это гарантирует, что тест не влияет на другие тесты через общее состояние. - Настройте PHPUnit на повторный запуск упавших тестов (режим Rerun). В CI используйте опцию
--order-by=random— если конкретный тест падает только при определённом порядке, вы сразу это увидите и сможете исправить зависимость. - Создайте скрипт, который прогоняет каждый unit-тест трижды подряд. Если хотя бы раз тест падает, он маркируется как flaky и отправляется в отдельную очередь на изоляцию. Не чините flaky-тесты «перезапуском» — чините код или окружение.
После внедрения изоляции и детекции flaky-тестов вы получаете pipeline, который либо зелёный, либо содержит реальную ошибку. Никаких «попробуйте перезапустить сборку». Стабильность тестов становится выше 99,9%, и команда доверяет результатам CI полностью.
Добавлено: 23.04.2026
