Тестирование Symfony 7: как настроить правильно с первого раза
Разбираем рабочую конфигурацию PHPUnit для Symfony 7.x: отдельная БД для тестов, WebTestCase, изоляция транзакций и типичные ошибки, из-за которых тесты начинают падать через раз.

Первое, что ломается в свежем Symfony-проекте при попытке добавить тесты, — это база данных. Тесты пишут в ту же БД, что и dev-окружение, данные протекают между тестами, а на CI всё падает, потому что там нет вашего локального дампа. Разберём рабочую конфигурацию для Symfony 7.x с PHPUnit 11 и symfony/test-pack: отдельная тестовая база, три типа тестовых классов и изоляция состояния между тестами, чтобы порядок запуска не влиял на результат.
Речь про то, как заложить основу так, чтобы не переписывать её через месяц. Мутационное тестирование, покрытие и Panther для браузерных сценариев — отдельные темы, здесь их не трогаем.
Что нужно поставить и настроить до первого теста
Symfony поставляет метапакет, который тянет PHPUnit-bridge и базовые классы:
composer require --dev symfony/test-pack
После установки в корне появляется phpunit.xml.dist и папка tests/. Проверьте версии — если в composer.lock оказался PHPUnit ниже 10, часть примеров ниже не заработает: атрибуты #[Test] и #[DataProvider] появились именно там.
Три типа классов, которые вы будете использовать, отличаются тем, сколько ядра Symfony они поднимают:
| Базовый класс | Что поднимает | Для чего |
|---|---|---|
TestCase (PHPUnit) | Ничего из Symfony | Чистая логика: value-объекты, калькуляторы, валидаторы без зависимостей |
KernelTestCase | Контейнер, сервисы, Doctrine | Сервисы с зависимостями, репозитории, работа с БД |
WebTestCase | Контейнер + HTTP-ядро и клиент | Контроллеры, маршруты, ответы, формы |
Правило простое: берите самый лёгкий класс, которого хватает. WebTestCase для проверки чистой функции — это лишние сотни миллисекунд на каждый тест и медленный прогон в целом.
Отдельная база для тестов
Самая частая ошибка — тесты пишут в вашу рабочую БД. Symfony по умолчанию использует суффикс окружения для имени базы, если задать переменную DATABASE_URL в файле .env.test. Создайте его рядом с .env:
# .env.test
DATABASE_URL="postgresql://app:app@127.0.0.1:5432/app_test?serverVersion=16&charset=utf8"
Файл .env.test коммитится в репозиторий — в нём не должно быть настоящих паролей от продакшена, только тестовые заглушки. Секреты для CI задают через переменные окружения раннера.
Дальше базу нужно создать и накатить схему. Для тестового окружения команды запускают с флагом --env=test:
# создать тестовую БД
php bin/console --env=test doctrine:database:create
# накатить схему через миграции
php bin/console --env=test doctrine:migrations:migrate --no-interaction
Проверка: после doctrine:database:create в консоли появится строка Created database `app_test`. Если вместо этого вы видите already exists — база уже была, это не ошибка.
Изоляция: чтобы тесты не мешали друг другу
Второй источник плавающих падений — данные, оставшиеся от предыдущего теста. Один тест создал пользователя, второй посчитал их количество и получил на одного больше, чем ожидал. Хуже всего, что при запуске тестов по отдельности всё зелёное, а вместе — красное.
Рабочее решение — оборачивать каждый тест в транзакцию и откатывать её после. Есть готовый пакет, который делает это автоматически:
composer require --dev dama/doctrine-test-bundle
После установки добавьте его расширение в phpunit.xml.dist внутрь секции <extensions>:
<extensions>
<bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
Теперь каждый тест начинается в транзакции, а по завершении она откатывается. База возвращается в исходное состояние без пересоздания схемы — это быстро. Точный синтаксис секции extensions отличался между PHPUnit 9 и 10+; если у вас старая конфигурация с атрибутами внутри тега <phpunit>, сверьте формат с README пакета для вашей версии.
Тест, который зависит от порядка запуска других тестов, — это не тест, а ловушка. Он зелёный ровно до того дня, когда вы добавите новый тест выше по списку.
Первый тест контроллера
Проверим, что маршрут отдаёт 200 и нужный контент. WebTestCase даёт клиент, который дёргает ядро напрямую, без реального HTTP:
<?php
// tests/Controller/HomeControllerTest.php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HomeControllerTest extends WebTestCase
{
public function testHomePageReturnsOk(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
// проверяем код ответа
$this->assertResponseIsSuccessful();
// проверяем, что на странице есть заголовок
$this->assertSelectorTextContains('h1', 'Добро пожаловать');
}
}
Метод createClient() нельзя вызывать после того, как контейнер уже был загружен через self::getContainer() в том же тесте — Symfony выбросит исключение о том, что ядро уже загружено. Сначала клиент, потом всё остальное.
Тест сервиса с доступом к контейнеру
Для сервиса поднимаем только ядро и достаём его из контейнера. Важно: в тестах доступен специальный тестовый контейнер, где private-сервисы видны напрямую:
<?php
// tests/Service/PriceCalculatorTest.php
namespace App\Tests\Service;
use App\Service\PriceCalculator;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class PriceCalculatorTest extends KernelTestCase
{
public function testAppliesDiscount(): void
{
self::bootKernel();
$container = static::getContainer();
// тестовый контейнер отдаёт даже private-сервисы
$calculator = $container->get(PriceCalculator::class);
$result = $calculator->withDiscount(1000, 20);
$this->assertSame(800, $result);
}
}
Запуск всего набора:
php bin/phpunit
Если не получилось
- «SQLSTATE... database "app" does not exist» или тесты пишут в dev-базу — не создан
.env.testс отдельнымDATABASE_URL, либо БД не создана командой с флагом--env=test. - «There is no extension able to load the configuration for ...» при запуске — расширение
dama/doctrine-test-bundleпрописано в формате, не подходящем для вашей версии PHPUnit. Сверьте синтаксис секции extensions с документацией пакета. - Тесты зелёные по одному, но падают все вместе — не подключена изоляция транзакциями. Данные протекают между тестами. Проверьте, что bootstrap-расширение DAMA реально загружается.
- «Cannot create the client used to test the application. ...» —
createClient()вызван после загрузки ядра. Перенесите его в начало теста. - «You have requested a non-existent service» — сервис private и вы достаёте его из боевого контейнера, а не из
static::getContainer(). В тестах используйте только тестовый контейнер.
Что настроить дальше
Базовая инфраструктура готова: отдельная БД, три типа тестов, изоляция. Следующие шаги в порядке пользы:
- Фикстуры через
doctrine/doctrine-fixtures-bundle— чтобы каждый тест стартовал с предсказуемым набором данных, а не создавал всё вручную. - Прогон тестов в CI: на GitHub Actions или GitLab CI поднимаете сервис PostgreSQL, задаёте
DATABASE_URLчерез переменные окружения и запускаете те же команды, что локально. - Покрытие через
--coverage-htmlс Xdebug или PCOV — смотрите не на общий процент, а на то, какие ветки логики вообще не заходят в тесты.
Не стремитесь к полному покрытию сразу. Начните с контроллеров критичных маршрутов и сервисов, где есть расчёты и ветвления, — там ошибка стоит дороже всего.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Symfony — Testing (официальная документация), dama/doctrine-test-bundle — GitHub
Частые вопросы
У меня Symfony 6.4, а не 7 — примеры подойдут?
Тесты проходят локально, но падают на CI — с чего начать?
Обязательно ли ставить dama/doctrine-test-bundle?
Чем createClient() отличается от реального HTTP-запроса?
Как достать private-сервис в тесте, если в контроллере он не доступен через get()?
Нужно ли пересоздавать тестовую базу перед каждым запуском?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.