Тестирование на PHP
Тестирование кода на PHP — стандарты кода (PSR), инструменты PHPUnit/Pest и мокирование внешних зависимостей через внедряемые точки расширения.
16 вопросов
JuniorТеорияОчень частоКакие стандарты и инструменты есть для тестирования PHP и как мокать зависимости?
Какие стандарты и инструменты есть для тестирования PHP и как мокать зависимости?
Стандарты кода — это семейство PSR: PSR-1 и PSR-12, проверяемые инструментами вроде PHP_CodeSniffer. Основные раннеры тестов — PHPUnit и Pest. Чтобы изолировать код от внешних зависимостей, их мокают: createMock() из PHPUnit, библиотека Mockery или тест-дубли, внедряемые через конструктор (DI). Вы проверяете дубль вместо обращения к реальной сети или базе.
Типичные ошибки
- ✗Называть транспортную спеку вроде PSR-7 стандартом стиля вместо PSR-1/PSR-12
- ✗Тестировать класс, который сам создаёт свои зависимости, не оставляя точки для мока
- ✗Мокать объекты-значения или чистые функции, у которых нет внешнего эффекта для подмены
Уточняющие вопросы
- →Почему внедрение через конструктор легче мокать, чем зависимость, созданную через
newвнутри метода? - →В чём разница между стабом, возвращающим данные, и моком, проверяющим вызовы?
JuniorТеорияОчень частоЧем в Laravel unit-тест отличается от feature-теста?
Чем в Laravel unit-тест отличается от feature-теста?
Unit-тест проверяет один класс в изоляции, подменяя его зависимости дублями, и по умолчанию не поднимает фреймворк. Feature-тест поднимает приложение, поэтому может дёрнуть маршрут через $this->get('/orders'), ходить в базу и проверять HTTP-ответ. Feature-тесты доказывают связность, unit-тесты точнее указывают на дефект.
Типичные ошибки
- ✗Класть тест уровня маршрута в
tests/Unitи удивляться, что контейнер не поднят - ✗Мокать в feature-тесте столько, что он перестаёт доказывать связность
- ✗Считать, что feature-тесту нужен браузерный драйвер, чтобы дёрнуть маршрут
Уточняющие вопросы
- →От какого базового
TestCaseнаследуется unit-тест в Laravel и что это меняет? - →Когда feature-тест — неверный инструмент, а unit-тест — верный?
JuniorТеорияЧастоКаковы три фазы структуры теста Arrange-Act-Assert?
Каковы три фазы структуры теста Arrange-Act-Assert?
Arrange создаёт тестируемый объект и его входные данные. Act вызывает ровно одно поведение — обычно один метод. Assert сравнивает наблюдаемый результат с ожиданием. Разделение фаз и единственное действие оставляют тесту одну причину падения, поэтому красный тест точно указывает, какой вызов сломался.
Типичные ошибки
- ✗Вызывать несколько методов в фазе Act, из-за чего у падения больше одной причины
- ✗Проверять созданную фикстуру, а не наблюдаемый результат
- ✗Считать это правилом, которое применяет PHPUnit, а не соглашением о структуре
Уточняющие вопросы
- →Как формулировка Given-When-Then в BDD-тестах ложится на эти фазы?
- →Почему одна логическая проверка на тест обычно лучше нескольких несвязанных?
JuniorТеорияЧастоЧем тест-фреймворк Pest отличается от PHPUnit и что исполняет сами тесты?
Чем тест-фреймворк Pest отличается от PHPUnit и что исполняет сами тесты?
Pest — это DSL поверх PHPUnit, а не отдельный движок: вы пишете замыкания it('...', fn() => ...) вместо методов у TestCase, а исполняет их по-прежнему PHPUnit. Проверки, моки и конфигурация PHPUnit продолжают работать, а классические классы TestCase живут в том же наборе. Меняется стиль написания, а не раннер.
Типичные ошибки
- ✗Думать, что Pest заменяет движок PHPUnit, а не оборачивает его
- ✗Считать, что для перехода на Pest нужно сразу мигрировать все тесты
- ✗Ждать иной семантики тестов, а не иного синтаксиса написания
Уточняющие вопросы
- →Как из файла с тестами Pest перейти к классическому
TestCaseиз PHPUnit? - →Что даёт API ожиданий Pest сверх методов
assert*из PHPUnit?
JuniorТеорияЧастоЧто проверяет статический анализатор вроде PHPStan, вообще не запуская код?
Что проверяет статический анализатор вроде PHPStan, вообще не запуская код?
Он разбирает исходники и рассуждает о типах и достижимости, ничего не выполняя: вызовы несуществующих методов, неверные типы аргументов и возврата, всегда ложные условия, недостижимый код, отсутствующие проверки на null. Он читает и generics из докблоков, которые движок игнорирует. Строгость задаётся уровнем, поднимаемым постепенно.
Типичные ошибки
- ✗Считать, что статический анализатор выполняет код или требует поднятого приложения
- ✗Путать его с форматтером или линтером стиля вроде PHP_CodeSniffer
- ✗Включать на легаси сразу максимальный уровень вместо постепенного повышения
Уточняющие вопросы
- →Что даёт baseline-файл при внедрении PHPStan в легаси-код?
- →Как generics в докблоке меняют то, что анализатор способен доказать?
MiddleТеорияЧастоЧто делает data provider в PHPUnit и как он сообщает о упавшем случае?
Что делает data provider в PHPUnit и как он сообщает о упавшем случае?
Data provider — это метод, возвращающий наборы аргументов. PHPUnit прогоняет тест по разу на каждый набор, передавая его в параметры тест-метода, и показывает каждый набор отдельным тестом со своим именем — поэтому падение называет точный вход. А foreach внутри одного теста встал бы на первом плохом случае, скрыв остальные.
Типичные ошибки
- ✗Обходить случаи внутри одного теста, из-за чего первое падение скрывает все последующие
- ✗Ждать, что провайдер выполнится после теста, а не до него
- ✗Возвращать строки, форма которых не совпадает со списком параметров тест-метода
Уточняющие вопросы
- →Как именованные ключи в возвращаемом массиве меняют вывод при падении?
- →Почему data provider не может пользоваться фикстурой, созданной в
setUp()?
MiddleТеорияЧастоВ чём разница между стабом, моком и шпионом как тест-дублями?
В чём разница между стабом, моком и шпионом как тест-дублями?
Стаб возвращает заготовленные данные и ничего не проверяет — он лишь даёт коду отработать. Мок заранее задаёт ожидания по вызовам, например expects($this->once()), и роняет тест, когда они не выполнены. Шпион записывает вызовы для проверки после действия. Проверка взаимодействий привязывает тест к тому, как код зовёт зависимости.
Типичные ошибки
- ✗Брать мок со строгими ожиданиями там, где хватило бы стаба, возвращающего данные
- ✗Проверять взаимодействия повсюду, из-за чего тесты падают на любом безобидном рефакторинге
- ✗Считать, что стаб что-то проверяет — он только поставляет данные
Уточняющие вопросы
- →Когда написанный вручную fake лучше сгенерированного мока?
- →Почему проверка взаимодействий делает тест хрупким при рефакторинге?
MiddleДизайнЧастоВаш набор тестов Laravel работает на in-memory базе SQLite, чтобы CI (непрерывная интеграция) оставался быстрым, и он зелёный на каждом pull request. В проде — MySQL. Затем релиз падает на миграции, которую SQLite спокойно принял, а запрос, возвращавший строки в тестах, в проде не возвращает ничего. Решите, какой должна быть тестовая база, и обоснуйте компромисс между скоростью набора и той уверенностью, которую он на самом деле даёт.
Ваш набор тестов Laravel работает на in-memory базе SQLite, чтобы CI (непрерывная интеграция) оставался быстрым, и он зелёный на каждом pull request. В проде — MySQL. Затем релиз падает на миграции, которую SQLite спокойно принял, а запрос, возвращавший строки в тестах, в проде не возвращает ничего. Решите, какой должна быть тестовая база, и обоснуйте компромисс между скоростью набора и той уверенностью, которую он на самом деле даёт.
SQLite — это другой движок, а не быстрый MySQL: другой диалект, более вольная типизация, другое поведение ограничений и дат. Зелёный прогон на SQLite ничего не доказывает о запросах, которые пойдут в проде, — именно это у вас и сломалось. Тестируйте на том же движке, что и в проде, держа скорость откатом транзакции в CI.
Типичные ошибки
- ✗Считать SQLite быстрой заменой MySQL, а не другим движком
- ✗Читать зелёный набор как доказательство работоспособности боевых запросов
- ✗Замазывать расхождение флагами драйвера вместо запуска боевого движка
Уточняющие вопросы
- →Какие классы багов SQLite прячет, а настоящий MySQL вскрывает сразу?
- →Как держать набор на MySQL достаточно быстрым для запуска на каждом pull request?
JuniorТеорияИногдаПочему PHPUnit создаёт фикстуры в setUp(), а не в конструкторе?
Почему PHPUnit создаёт фикстуры в setUp(), а не в конструкторе?
PHPUnit создаёт новый экземпляр тест-класса на каждый тест-метод, вызывает setUp() перед ним и tearDown() после. Конструктор работает вне этого жизненного цикла: у него фиксированная сигнатура, нет парного teardown, а сбой в нём не сообщается как обычный провал теста. setUp() — штатная точка расширения.
Типичные ошибки
- ✗Считать, что один экземпляр тест-класса общий для всех его тест-методов
- ✗Переопределять конструктор, ломая ожидаемую PHPUnit сигнатуру
- ✗Создавать фикстуру без парного
tearDown(), протекая состоянием в следующий тест
Уточняющие вопросы
- →Чем
setUp()отличается от статического хукаsetUpBeforeClass()? - →Когда
tearDown()не выполняется, и на что из-за этого нельзя полагаться?
MiddleКодИногдаКак сделать класс, вызывающий time(), детерминированным в тесте?
Как сделать класс, вызывающий time(), детерминированным в тесте?
Внедрите часы вместо вызова глобальной функции. Объявите интерфейс Clock с методом now(): int, примите его в конструкторе и зовите $this->clock->now(). В проде подставляется SystemClock, возвращающий time(); в тест передаётся замороженный clock с фиксированной меткой. Глобальное состояние не трогается, поэтому тесты остаются изолированными и могут идти параллельно.
Типичные ошибки
- ✗Замораживать или подменять глобальные часы, протекая состоянием между тестами и ломая параллельный прогон
- ✗Стабить сам тестируемый класс, из-за чего проверяемое поведение вообще не выполняется
- ✗Читать часы в глубине метода, не оставляя шва, где тест мог бы их подменить
Уточняющие вопросы
- →Как интерфейс часов, стандартизованный в PSR-20, делает этот шов переиспользуемым между библиотеками?
- →Почему замороженные глобальные часы ломают параллельный прогон тестов?
MiddleТеорияИногдаЧто делает трейт RefreshDatabase в Laravel и когда он молча ломается?
Что делает трейт RefreshDatabase в Laravel и когда он молча ломается?
Он оборачивает каждый тест в транзакцию базы и откатывает её в конце, поэтому схема мигрируется один раз, а каждый тест стартует из одного состояния без очистки таблиц. Отсюда и скорость. Ломается он, когда тестируемый код сам делает commit или когда второе соединение — воркер, браузер — не видит незакоммиченных строк.
Типичные ошибки
- ✗Считать, что он чистит таблицы или мигрирует заново на каждый тест, а не откатывает транзакцию
- ✗Применять его к коду, который сам коммитит транзакцию и тем самым уходит из-под отката
- ✗Ждать, что второе соединение или внешний процесс увидит незакоммиченные строки теста
Уточняющие вопросы
- →Чем отличается трейт
DatabaseTruncationи когда стоит перейти на него? - →Почему браузерным тестам нужна иная стратегия базы, чем
RefreshDatabase?
MiddleТеорияИногдаКак статический анализатор в CI (непрерывной интеграции) дополняет типы времени выполнения и тесты?
Как статический анализатор в CI (непрерывной интеграции) дополняет типы времени выполнения и тесты?
Они закрывают разные окна. Объявление типа срабатывает лишь на строке, до которой запрос реально дошёл; тест доказывает только те пути, которые он проходит. Статический анализатор проверяет все пути в исходниках ещё до запуска и понимает generics из докблоков, невыразимые для движка. CI гоняет все три сразу.
Типичные ошибки
- ✗Считать, что
strict_typesделает статический анализатор избыточным - ✗Ждать от анализатора доказательства поведения — он проверяет типы и достижимость, а не результаты
- ✗Полагать, что анализатор выполняет код или требует поднятого приложения
Уточняющие вопросы
- →Какой дефект поймает тест, но никогда не поймает статический анализатор?
- →Почему повышение уровня PHPStan вскрывает ошибки в коде, который вы не трогали?
MiddleТеорияРедкоЧто измеряет инструмент мутационного тестирования Infection, чего не может покрытие строк?
Что измеряет инструмент мутационного тестирования Infection, чего не может покрытие строк?
Покрытие строк фиксирует лишь то, какие строки выполнились. Infection мутирует исходник — меняет > на >=, инвертирует условие, убирает return — заново гоняет набор и смотрит, упал ли тест. Выживший мутант — это дефект, который ваши тесты не поймали бы. Набор вовсе без проверок покажет полное покрытие.
Типичные ошибки
- ✗Читать полное покрытие строк как доказательство, что тесты поймают регрессию
- ✗Принимать выжившего мутанта за успех, а не за незамеченный дефект
- ✗Гонять мутационное тестирование по всему коду на каждый коммит, а не по изменённым файлам
Уточняющие вопросы
- →Почему мутационный прогон намного медленнее обычного прогона покрытия?
- →Что такое эквивалентный мутант и почему его нельзя убить?
SeniorДизайнРедкоВаш PHP-сервис потребляет REST API другой команды, а в тестах HTTP-клиент подменён моком. Набор остался зелёным после того, как провайдер переименовал поле ответа, и прод сломался на ближайшем деплое. Провайдер не станет гонять ваши тесты, а вы не станете ходить на их staging на каждый коммит. Предложите подход к тестированию, при котором ваш набор остаётся быстрым и на дублях, но краснеет при изменении контракта провайдера, и скажите, кто какой частью владеет.
Ваш PHP-сервис потребляет REST API другой команды, а в тестах HTTP-клиент подменён моком. Набор остался зелёным после того, как провайдер переименовал поле ответа, и прод сломался на ближайшем деплое. Провайдер не станет гонять ваши тесты, а вы не станете ходить на их staging на каждый коммит. Предложите подход к тестированию, при котором ваш набор остаётся быстрым и на дублях, но краснеет при изменении контракта провайдера, и скажите, кто какой частью владеет.
Мок кодирует ваше предположение о провайдере, и никто не проверяет, что оно ещё верно. Добавьте контрактное тестирование со стороны потребителя: ваш набор записывает делаемые запросы и ожидаемые ответы в пакт, а провайдер проигрывает этот пакт против своей реальной реализации в своём пайплайне. Ваш набор быстр, а их сборка краснеет, когда они вас ломают.
Типичные ошибки
- ✗Доверять моку как свидетельству о системе, которой вы не владеете
- ✗Менять дубли на живые вызовы провайдера, делая набор медленным и нестабильным
- ✗Оставлять проверку контракта вне пайплайна провайдера, так что его никто не предупредит
Уточняющие вопросы
- →Что даёт брокер пактов сверх коммита файла пакта в репозиторий провайдера?
- →Как версионировать пакт, чтобы провайдер продолжал поддерживать старого потребителя?
SeniorТеорияРедкоЧто даёт и чего стоит snapshot-тестирование представления, отрисованного шаблонизатором Blade?
Что даёт и чего стоит snapshot-тестирование представления, отрисованного шаблонизатором Blade?
Он отрисовывает представление, сравнивает вывод с закоммиченным файлом-снимком и падает на любом различии. Это дёшево писать, и оно ловит непреднамеренные правки разметки по всему слою представлений. Цена — он проверяет всё подряд: законная правка пробела или имени класса тоже роняет тест, а рефлекс — перегенерировать снимок, утвердив регрессию.
Типичные ошибки
- ✗Рефлекторно перегенерировать упавший снимок, тем самым утверждая настоящую регрессию
- ✗Снимать снимок целой страницы, из-за чего любая косметическая правка даёт падение
- ✗Читать прошедший снимок как доказательство правильности представления, а не его неизменности
Уточняющие вопросы
- →Как удержать динамические значения вроде меток времени или id вне снимка?
- →Когда проверка нескольких конкретных селекторов лучше снимка целой страницы?
SeniorДизайнРедкоЗадача Laravel, отправляемая в очередь из контроллера, списывает деньги с карты, пишет строку чека и шлёт письмо клиенту. Тестов нет вовсе: перед релизом её гоняют руками по песочнице платежей. Спроектируйте стратегию тестирования — что проверять в точке отправки, что проверять в самой задаче и как удержать настоящее списание и настоящее письмо вне набора, всё же доказав поведение при повторах и падении.
Задача Laravel, отправляемая в очередь из контроллера, списывает деньги с карты, пишет строку чека и шлёт письмо клиенту. Тестов нет вовсе: перед релизом её гоняют руками по песочнице платежей. Спроектируйте стратегию тестирования — что проверять в точке отправки, что проверять в самой задаче и как удержать настоящее списание и настоящее письмо вне набора, всё же доказав поведение при повторах и падении.
Разделите надвое. В точке отправки подмените очередь фейком и проверьте, что задача поставлена с верной нагрузкой — контракт контроллера на этом кончается. Затем протестируйте handle() как unit, внедрив шлюз и почтовик как дубли, чтобы ни списание, ни письмо не ушли. Повторы и хук падения покройте, заставив дубль бросить исключение.
Типичные ошибки
- ✗Давать отработать настоящему шлюзу или почтовику, не изолировав от них задачу
- ✗Проверять побочные эффекты задачи в тесте контроллера вместо факта её отправки
- ✗Оставлять повторы и падение без тестов, ни разу не заставив дубль бросить исключение
Уточняющие вопросы
- →Как проверить, что задача отправлена в конкретную очередь и на конкретное соединение?
- →Почему бросок исключения из внедрённого дубля доказывает путь повтора лучше нестабильной песочницы?