Тестирование на PHP
Набор тестов покупает одну вещь — возможность менять код и узнавать о поломке за секунды, а не от пользователя. Но покупает ровно настолько, насколько тесты действительно поймали бы дефект. Здесь и проходит водораздел темы: инструменты покрытия отвечают на вопрос «какие строки исполнились», а не «какая ошибка была бы замечена», и набор, где нет ни одного assert, честно отчитается о стопроцентном покрытии. Всё остальное в теме — способы приблизить второй вопрос к первому.
Ловушки называем сразу, потому что почти каждая из них выглядит как успех. Мок кодирует ваше предположение о чужой зависимости — и остаётся зелёным, когда предположение перестало быть верным. RefreshDatabase откатывает транзакцию, поэтому собственный commit в тестируемом коде из-под отката уходит, а второе соединение — очередь, браузерный драйвер — незакоммиченных строк просто не видит. In-memory SQLite — не быстрый MySQL, а другой движок с другим диалектом: зелёный прогон на нём ничего не говорит про запросы, которые пойдут в продакшен. Упавший снапшот чинится «регенерацией» — и регрессия оказывается молча одобрена. Разбор по слоям — ниже.
Карта темы
- Что такое тест — структура Arrange–Act–Assert, правило одного действия и почему проверять надо поведение, а не реализацию.
- PHPUnit — жизненный цикл и data provider — свежий экземпляр тест-класса на каждый метод,
setUp/tearDownвместо конструктора и провайдер данных, который называет упавший вход. - Pest — синтаксис поверх PHPUnit — DSL из замыканий, исполняемый тем же движком, и почему миграция «всем разом» не нужна.
- Мокирование зависимостей — стаб, мок и спай, шов для подмены, внедрённые часы и цена пере-мокирования.
- Unit- и feature-тесты в Laravel — что поднимает фреймворк, а что нет, фейки на границе диспатча и цена снапшотов.
- Тестовая база данных — как
RefreshDatabaseоткатывает транзакцию, где откат молча не срабатывает и почемуSQLite— другой движок. - Мутационное тестирование — чем
Infectionотличается от покрытия и что означает выживший мутант. - Статический анализ — что
PHPStanнаходит, не исполняя код, и почему он не заменяется ниstrict_types, ни тестами. - Стандарты кода — PSR-1, PSR-12 и PSR-4,
phpcs/phpcbfи три уровня контроля.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Читать полное покрытие как доказательство, что регрессия будет поймана | Покрытие меряет исполнение, а не обнаружение — набор без единого assert показывает 100% |
| Вызвать несколько методов в фазе Act | У красного теста больше одной возможной причины — падение перестаёт локализовать дефект |
| Проверять в Assert подготовленную фикстуру, а не наблюдаемый результат | Тест зелёный всегда: он проверяет сам себя |
Строить фикстуру в конструкторе тест-класса вместо setUp() | Конструктор вне жизненного цикла — нет парного teardown, а его падение не отчитывается как обычный провал теста |
Перебирать входные случаи foreach внутри одного теста | Первый упавший случай прячет все остальные, и отчёт не называет вход |
Считать Pest отдельным движком | Ожидание другой семантики и «миграции всем разом» там, где меняется только синтаксис записи |
| Проверять взаимодействия везде, где хватило бы стаба | Тест прибит к реализации и краснеет на каждом безобидном рефакторинге |
Замораживать глобальные часы вместо внедрения Clock | Состояние течёт между тестами и ломает параллельный прогон |
Тестировать класс, который создаёт зависимость через new внутри | Шва нет — дубль подставить некуда, класс непроверяем в изоляции |
| Считать мок доказательством о чужом API | Мок кодирует ваше предположение о провайдере; смену контракта он не заметит |
Думать, что RefreshDatabase чистит таблицы | Он оборачивает тест в транзакцию и откатывает её — собственный commit кода уходит из-под отката |
| Ждать, что очередь или браузерный драйвер увидят данные теста | Второе соединение не видит незакоммиченных строк — тест виснет или падает без причины |
Гонять набор на in-memory SQLite при MySQL в продакшене | Другой движок и диалект — зелёный прогон не говорит ничего о запросах продакшена |
| Регенерировать упавший снапшот рефлексом | Регрессия молча одобрена: снапшот проверяет всё подряд, включая то, что вы не собирались менять |
| Путать статический анализатор со стилевым линтером | phpcs смотрит стиль, PHPStan — типы и достижимость; strict_types его не заменяет |
| Назвать транспортный PSR-7 стандартом стиля | Стиль задают PSR-1 и PSR-12; PSR-7 описывает интерфейсы HTTP-сообщений |
Значение для собеседований
Тестирование спрашивают не вопросом «пишете ли вы тесты», а вопросом «что ваш набор доказывает и чего он не ловит». Кандидат, который умеет назвать границы собственного набора — «покрытие показывает исполнение, поэтому я гоняю Infection на изменённых файлах», «мок провайдера ничего не доказывает про провайдера, поэтому контракт проверяется на его стороне» — сразу отделяется от того, кто отвечает процентом покрытия.
Что обычно проверяют:
- Три фазы AAA и почему действие должно быть ровно одно.
- Жизненный цикл PHPUnit — свежий экземпляр на тест,
setUpпротив конструктора. - Что делает data provider и как он отчитывается о падении конкретного входа.
- Чем
Pestявляется по отношению к PHPUnit и что именно в нём меняется. - Разницу стаба, мока и спая и цену проверок взаимодействия.
- Как сделать код с
time()детерминированным и почему не через глобаль. - Границу unit- и feature-теста в Laravel и что доказывает каждый.
- Что
RefreshDatabaseделает на самом деле и где он молча ломается. - Почему
SQLiteв тестах приMySQLв проде — не оптимизация, а потеря доказательства. - Что меряет мутационное тестирование сверх покрытия.
- Какие дефекты статический анализатор находит без запуска кода.
Типичный неверный ответ: «у нас покрытие 90%, значит регрессии мы ловим». Отсюда обычно и начинается разговор: покрытие фиксирует, что строка исполнилась, а не что её поломку кто-то заметил бы — набор, где закомментированы все assert, покажет ровно те же 90%. Второй классический провал — «RefreshDatabase очищает базу перед каждым тестом»: он оборачивает тест в транзакцию и откатывает её, и именно поэтому он быстрый, и именно поэтому он бессилен против кода, который коммитит сам.