Тесты в Symfony: unit, functional или integration
Три типа тестов в Symfony решают разные задачи и стоят разного времени выполнения. Разбираем, когда брать чистый unit, когда поднимать ядро через KernelTestCase, а когда гонять HTTP-запросы.

Проект на Symfony растёт, тестов становится сотни, и прогон CI из тридцати секунд превращается в семь минут. Причина почти всегда одна: половина тестов, которые могли бы быть быстрыми unit-тестами, зачем-то поднимают контейнер зависимостей и подключаются к базе. Symfony даёт три уровня тестирования — от изолированного класса до полноценного HTTP-запроса, — и цена у них отличается на порядки. Разберём, что и когда брать, чтобы не платить секундами за проверку, которую можно сделать за миллисекунды.
Материал ориентирован на Symfony 6.4 / 7.x и PHPUnit 10/11. Базовый пакет для тестов — symfony/test-pack, он ставит PHPUnit, browser-kit и css-selector одной командой.
Три уровня и что каждый реально проверяет
Symfony предлагает три базовых класса, и путаница между ними — главный источник медленных тестов.
- Unit-тест наследует обычный
PHPUnit\Framework\TestCase. Контейнера нет, ядра нет, базы нет. Вы создаёте объект руками черезnewи подсовываете моки зависимостей. - Integration-тест наследует
Symfony\Bundle\FrameworkBundle\Test\KernelTestCase. Поднимается ядро и контейнер, можно доставать реальные сервисы черезself::getContainer()->get(...). HTTP-слоя нет. - Functional-тест наследует
WebTestCase(расширяетKernelTestCase). Появляется клиентstatic::createClient(), который гоняет настоящие запросы через ядро без реального веб-сервера.
Ключевая мысль: чем выше по уровню, тем больше вы поднимаете и тем медленнее идёт тест. Integration-тест поднимает контейнер — это десятки миллисекунд на старте. Functional добавляет маршрутизацию, security, сериализацию. Unit не поднимает ничего.
Как выглядит каждый на практике
Возьмём сервис, который считает скидку. Сначала чистый unit — зависимость замокана, база не нужна:
// tests/Unit/DiscountCalculatorTest.php
// Symfony 7.x, PHPUnit 11
namespace App\Tests\Unit;
use App\Repository\PromoRepository;
use App\Service\DiscountCalculator;
use PHPUnit\Framework\TestCase;
final class DiscountCalculatorTest extends TestCase
{
public function testAppliesActivePromo(): void
{
$promoRepo = $this->createMock(PromoRepository::class);
$promoRepo->method('findActive')->willReturn(0.15);
$calculator = new DiscountCalculator($promoRepo);
// 1000 с промо 15% -> 850
self::assertSame(850.0, $calculator->finalPrice(1000.0));
}
}Тот же результат через integration-тест — но здесь мы достаём реальный сервис из контейнера и проверяем, что он правильно собран (autowiring, аргументы, конфиг):
// tests/Integration/DiscountCalculatorTest.php
namespace App\Tests\Integration;
use App\Service\DiscountCalculator;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class DiscountCalculatorTest extends KernelTestCase
{
public function testServiceIsWiredAndWorks(): void
{
self::bootKernel();
$container = self::getContainer();
$calculator = $container->get(DiscountCalculator::class);
// Здесь работает реальный PromoRepository и реальная БД
self::assertIsFloat($calculator->finalPrice(1000.0));
}
}И functional — проверяем весь путь запроса, включая контроллер, роутинг и ответ:
// tests/Functional/CheckoutControllerTest.php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class CheckoutControllerTest extends WebTestCase
{
public function testCheckoutPageShowsPrice(): void
{
$client = static::createClient();
$client->request('GET', '/checkout?product=42');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('.final-price', '850');
}
}Обратите внимание: в functional-тесте createClient() уже загружает ядро, отдельный bootKernel() вызывать не нужно и он бросит исключение, если сделать это после создания клиента.
Цена каждого подхода
| Критерий | Unit | Integration | Functional |
|---|---|---|---|
| Базовый класс | TestCase | KernelTestCase | WebTestCase |
| Поднимается контейнер | нет | да | да |
| Проходит HTTP-слой | нет | нет | да |
| Нужна БД | нет (мок) | обычно да | обычно да |
| Скорость одного теста | доли мс | десятки мс | десятки–сотни мс |
| Что ломается при рефакторинге | сигнатура класса | сборка сервисов | контракт эндпоинта |
Цифры скорости — порядок величины, а не замер: конкретика зависит от размера контейнера, количества бандлов и того, ходит ли тест в базу. Смысл в соотношении: integration в десятки раз тяжелее unit на старте, functional — ещё тяжелее.
Если тест поднимает ядро только чтобы проверить чистую логику одного метода — это не integration-тест, это медленный unit-тест, который однажды съест ваш CI.
Когда что выбирать
Критерий простой — что именно вы проверяете, а не что удобнее написать.
- Unit — когда проверяете логику одного класса: расчёты, валидацию, преобразование данных, ветвления. Зависимости у класса есть, но их поведение вы задаёте моками. Таких тестов должно быть большинство.
- Integration — когда проверяете стык нескольких реальных сервисов или работу с базой через Doctrine: правильно ли собран запрос, срабатывают ли события, корректно ли настроен сервис в контейнере. Мокать здесь Doctrine бессмысленно — тогда вы тестируете мок, а не запрос.
- Functional — когда проверяете контракт эндпоинта целиком: код ответа, редиректы, security (доступ гостя и авторизованного), формат JSON, содержимое страницы. Это тесты на «снаружи всё работает», их держат немного, но на ключевые сценарии.
Практическое правило: если можете проверить поведение без контейнера — проверяйте без контейнера. К integration и functional поднимайтесь только тогда, когда именно взаимодействие частей и есть предмет теста.
Что ломается на практике
Состояние базы протекает между тестами
Самая частая боль integration и functional: один тест создал запись, второй на неё наткнулся. Решение — оборачивать каждый тест в транзакцию с откатом. Для этого есть пакет dama/doctrine-test-bundle: он включает откат транзакции после каждого теста, и база остаётся чистой без пересоздания схемы. Подключается через listener в phpunit.xml.dist — точный синтаксис регистрации сверяйте в README пакета, он менялся между версиями PHPUnit 9 и 10+.
getContainer() и приватные сервисы
В тестовом окружении Symfony делает сервисы доступными через специальный тестовый контейнер, но не все. Если сервис нигде не используется публично, self::getContainer()->get(...) может бросить, что сервис не найден или удалён компилятором. Обычно достаточно, что сервис используется в приложении — тогда он останется в контейнере. Не полагайтесь на то, что можно достать вообще любой приватный сервис.
Клиент и повторные запросы
В functional-тесте по умолчанию createClient() не переживает несколько запросов с разными ожиданиями исключений в одном тесте — Symfony выбрасывает предупреждение о повторной загрузке ядра. Если нужно несколько запросов подряд в одном сценарии — это нормально, ядро переиспользуется; проблема возникает при попытке создать клиент дважды.
Тест зелёный локально, красный в CI
Чаще всего дело в окружении: не выставлена переменная APP_ENV=test, не подготовлена тестовая БД, или порядок тестов другой и всплывает зависимость между ними. Второе лечится изоляцией состояния, первое — файлом .env.test и отдельной тестовой базой в конфиге.
Что делать дальше
Посмотрите на свой каталог tests/ и посчитайте, сколько классов наследуют KernelTestCase или WebTestCase ради проверки, которую можно сделать моком. Каждый такой перевод в unit срезает десятки миллисекунд с прогона. Дальше — подключите dama/doctrine-test-bundle, если тесты ходят в базу без изоляции, и включите замер времени: PHPUnit умеет показывать самые медленные тесты, ищите их и решайте, действительно ли им нужно ядро. Организация фикстур и параллельный запуск через paratest — отдельная тема, здесь мы разбирали только выбор уровня теста.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Symfony — Testing, DAMADoctrineTestBundle (GitHub)
Частые вопросы
У меня Symfony 5.4, эти примеры сработают?
Почему self::getContainer() говорит, что сервис не найден, хотя он есть?
Обязательно ли мокать Doctrine в integration-тестах?
Тесты проходят локально, но падают в CI — с чего начать?
Сколько каких тестов держать в проекте?
Можно ли в одном functional-тесте сделать несколько запросов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.