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

Типичная картина в проекте на Symfony: тестов сотня, но зелёными они бывают только на машине автора. В CI половина падает из-за грязной базы, другая — из-за того, что кто-то ходит в реальный платёжный шлюз. Прогон занимает восемь минут, поэтому перед пушем их никто не запускает. В этом материале — конкретные ошибки в тестировании Symfony-приложений (актуально для Symfony 6.4 LTS и 7.x, PHPUnit 10–11) и как их убрать, чтобы тесты снова стали инструментом, а не ритуалом.
Ошибка первая: всё через WebTestCase
Самая частая беда — когда каждый тест поднимает ядро через WebTestCase и стучится в базу, даже если проверяет чистую логику форматирования цены. Такой тест медленный, хрупкий и ничего не говорит о том, где именно сломалось: упал сервис, шаблон или роутинг — по красному отчёту не понять.
В Symfony три уровня тестов, и путать их дорого:
- Unit — обычный
TestCaseиз PHPUnit, никакого контейнера. Тестируете один класс, зависимости передаёте моками. Миллисекунды на тест. - Integration —
KernelTestCase. Поднимается контейнер, можно достать реальный сервис черезself::getContainer()->get(...)и проверить связку сервиса с базой или очередью. - Application (функциональные) —
WebTestCase. Эмулируется HTTP-запрос, проверяется весь стек от роутинга до ответа.
Если проверку можно сделать на уровне ниже — делайте на уровне ниже. Функциональный тест на форматирование даты — это восемь секунд впустую и ложная уверенность.
Практическое правило: логика домена покрывается unit-тестами, критичные пользовательские сценарии (регистрация, оформление заказа) — несколькими функциональными, всё между — интеграционными.
Ошибка вторая: тесты зависят друг от друга через базу
Тест А создаёт пользователя, тест Б рассчитывает, что этот пользователь уже есть. Пока они идут по порядку — всё зелёное. Стоит PHPUnit сменить порядок или запустить их параллельно — каскад падений.
Лекарство — изоляция каждого теста. Штатный способ в экосистеме Symfony — пакет dama/doctrine-test-bundle: он оборачивает каждый тест в транзакцию и откатывает её после. База между тестами остаётся чистой, и это быстрее, чем пересоздавать схему.
Подключается в phpunit.xml.dist как расширение PHPUnit:
<!-- phpunit.xml.dist, PHPUnit 10+ -->
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
Точный синтаксис регистрации расширения отличался между PHPUnit 9 (тег <extension>) и 10+ (<bootstrap>) — сверьте с README пакета для вашей версии. После подключения не нужно вручную чистить таблицы в tearDown(): откат делает бандл.
Ошибка третья: отдельного окружения test фактически нет
Тесты гоняются на той же базе, что и разработка, потому что DATABASE_URL один на всех. Итог — то затёртые данные разработчика, то тест, случайно прошедший на данных, которых в чистой базе нет.
Symfony читает .env.test автоматически, когда APP_ENV=test. Переопределяйте подключение именно там:
# .env.test
APP_ENV=test
APP_DEBUG=0
# отдельная база для тестов
DATABASE_URL="postgresql://app:app@127.0.0.1:5432/app_test?serverVersion=16"
Проверка: команда php bin/console debug:container --env=test должна отработать без ошибок, а тестовая база — не совпадать с базой разработки. Значения в .env.test коммитятся (секретов там нет), локальные переопределения кладите в .env.test.local, который в .gitignore.
Ошибка четвёртая: тесты ходят во внешний мир
Функциональный тест регистрации отправляет письмо через реальный SMTP, а тест оплаты — в песочницу платёжки. Такой тест падает, когда упал внешний сервис, и это не ваша вина, но красный CI — ваш.
Что подменять и чем:
- Почта. В окружении
testMailer по умолчанию не отправляет письма наружу, если настроен соответствующий транспорт. Проверять отправку удобно трейтомMailerAssertionsTrait— методы вродеassertEmailCount()иassertEmailAddressContains(). - HTTP-клиенты. Вместо реального
HttpClientподставляйтеMockHttpClientс заранее заданными ответами — он в составе компонента HttpClient. - Время. Не вызывайте
new \DateTimeImmutable()прямо в коде — внедряйтеSymfony\Component\Clock\ClockInterface. В тестах используйтеMockClock, чтобы зафиксировать «сейчас».
Подмена сервиса в функциональном тесте делается через контейнер до первого запроса:
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Clock\ClockInterface;
final class OrderControllerTest extends WebTestCase
{
public function testOrderUsesFixedTime(): void
{
$client = static::createClient();
// фиксируем время до запроса, чтобы проверить дату в ответе
$fixedClock = new MockClock('2024-01-15 10:00:00');
static::getContainer()->set(ClockInterface::class, $fixedClock);
$client->request('GET', '/orders/new');
self::assertResponseIsSuccessful();
}
}
Подмена через getContainer()->set() работает только для сервисов, помеченных как публичные или заменяемые в тестовом контейнере; в Symfony 6.1+ тестовый контейнер это разрешает для большинства случаев. Если сервис приватный и его не удаётся подменить — вынесите зависимость в интерфейс и внедряйте его.
Ошибка пятая: фикстуры на весь проект в каждом тесте
Один большой файл фикстур с сотней сущностей грузится перед каждым тестовым классом. Тест про скидку тянет за собой каталог, склад и историю заказов, хотя ему нужен один товар. Прогон распухает, а причина падения тонет в куче лишних данных.
Загружайте минимум, нужный конкретному тесту, прямо в самом тесте, а не через глобальный набор фикстур. Сущность создаётся в setUp() или в начале теста через EntityManager, персистится, и на этом всё. С включённым dama/doctrine-test-bundle эти данные откатятся автоматически после теста.
Когда фикстуры всё же уместны
Общий справочный набор — валюты, статусы, роли, который не меняется от теста к тесту, — можно грузить один раз. Но пользователей и заказы для конкретного сценария создавайте локально.
Сравнение уровней тестов
| Уровень | Базовый класс | Что поднимается | Когда применять |
|---|---|---|---|
| Unit | PHPUnit\Framework\TestCase | ничего, только PHP | логика класса, расчёты, валидация значений |
| Integration | KernelTestCase | контейнер, база | связка сервиса с БД, репозитории, очереди |
| Application | WebTestCase | весь HTTP-стек | ключевые пользовательские сценарии end-to-end |
Ориентир по количеству — привычная пирамида: много быстрых unit, меньше интеграционных, единицы функциональных на самые важные пути.
Ошибка шестая: assertResponseIsSuccessful вместо проверки содержимого
Тест проверяет только код 200 и радуется. А контроллер вернул страницу с текстом «Ошибка сохранения» — и тест зелёный, потому что статус 200. Проверяйте не только код, но и то, что на странице оказалось нужное: assertSelectorTextContains(), assertResponseRedirects(), содержимое JSON через assertJson() или разбор тела ответа. Статус ответа — необходимое, но не достаточное условие.
Что настроить дальше
Когда базовые ошибки убраны, стоит заняться скоростью и стабильностью прогона:
- Включите параллельный прогон через
paratest— с изоляцией через транзакции это безопасно и заметно ускоряет CI. - Посмотрите покрытие через
--coverage-text, но не гонитесь за процентом: важнее покрыть критичные сценарии, а не геттеры. - В CI фиксируйте версию PHP и PostgreSQL/MySQL той же, что в проде — тест, зелёный на PostgreSQL 16 и красный на 14, лучше поймать до релиза.
- Мерьте время прогона в CI и следите, чтобы оно не росло незаметно: медленные тесты перестают запускать локально, и от них теряется вся польза.
Инвалидация тестов при рефакторинге и мутационное тестирование — отдельные темы, здесь их не разворачиваем.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Symfony Docs — Testing, dama/doctrine-test-bundle — GitHub
Частые вопросы
У меня Symfony 5.4, а не 6.4 — приёмы подойдут?
Тесты зелёные локально, но падают в CI — с чего начать?
getContainer()->set() не подменяет сервис, ругается на приватность — что делать?
Нужно ли стремиться к 100% покрытию?
Как ускорить прогон, если тестов уже несколько сотен?
Почему нельзя просто чистить таблицы в tearDown()?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.