<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ІНСТРУМЕНТИ

Тести в SymfonyTesting in Symfony

У Symfony тести пишуть на PHPUnit, а фреймворк додає три речі: ядро в середовищі test, клієнт без HTTP-сервера й контейнер, з якого видно навіть приватні сервіси.

12 хв читання Middle
01 · ЩО ЦЕ

Три базові класи поверх PHPUnit

Цей сайт написаний на Laravel, тому блоку з нашим кодом тут немає. Натомість поруч з кожною ідеєю Symfony стоїть її відповідник у Laravel: принципи ті самі, різняться назви.

Unit-тест у Symfony це звичайний PHPUnit\Framework\TestCase без ядра. Коли потрібні справжні сервіси, тест успадковує : self::bootKernel() піднімає ядро в середовищі test, а static::getContainer() дає контейнер. додає static::createClient(), тобто KernelBrowser, який передає Request прямо в ядро, без вебсервера й мережі. У Laravel обидві ролі виконує tests/TestCase.php: застосунок піднімається перед кожним тестом, а $this->get() робить те саме, що createClient()->request().

СЛОВНИКKernelTestCase. Базовий клас Symfony для тестів з ядром. bootKernel() створює ядро з KERNEL_CLASS у середовищі test, getContainer() дає тестовий контейнер, а після кожного тесту ядро вимикається.
СЛОВНИКWebTestCase. Нащадок KernelTestCase для функціональних тестів. createClient() повертає KernelBrowser, а трейти з асертами додають assertResponseIsSuccessful(), assertSelectorTextContains() і листи через assertEmailCount().

Сервіси в Symfony за замовчуванням приватні: з робочого контейнера їх не дістати через get(), а невикористані компілятор узагалі видаляє. static::getContainer() повертає , який бачить і приватні сервіси, що вціліли після компіляції, а через set() дозволяє підмінити сервіс, поки його ніхто не створив. У Laravel відповідник це $this->app->instance() або $this->mock().

СЛОВНИКТестовий контейнер. Сервіс test.service_container. Дає доступ до приватних сервісів, якщо компілятор їх не вбудував у інші й не видалив, і дозволяє підмінити сервіс через set(), доки той не створений.

Налаштування для тестів живуть у : phpunit.dist.xml ставить APP_ENV=test, змінні беруться з .env.test, а .env.local у цьому середовищі ігнорується, щоб тести поводились однаково в усіх. Конфіг пакетів для тестів пишуть у блоці when@test, і саме там вмикають framework.test: true, без якого немає ні клієнта, ні тестового контейнера. У Laravel ту саму роль грають теги env у phpunit.xml і файл .env.testing.

СЛОВНИКСередовище test. Значення APP_ENV=test. Для нього Symfony читає .env.test і .env.test.local, пропускає .env.local і застосовує блоки when@test з конфігів.

Побічні дії Symfony ловить своїми засобами. Messenger у тестах переводять на , і тест читає надіслані повідомлення через getSent(). Листи перевіряють assertEmailCount() і getMailerMessage(), а HTTP-клієнт підміняють MockHttpClient з MockResponse. Для бази є два сторонні бандли, яких у самому Symfony немає: загортає кожен тест у транзакцію й відкочує її, а Foundry (zenstruck/foundry) дає фабрики сутностей, схожі на фабрики моделей Laravel.

СЛОВНИКIn-memory транспорт. Транспорт Messenger з DSN in-memory://. Нікуди не відправляє повідомлення, а складає їх у памʼяті: getSent(), getAcknowledged(), getRejected().
СЛОВНИКDAMA DoctrineTestBundle. Сторонній бандл dama/doctrine-test-bundle. Розширення PHPUnit відкриває транзакцію перед кожним тестом і відкочує її після, тож базу не треба перестворювати між тестами.
02 · МЕЖА

Куди входить тест у Symfony

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
newbootKernel()createClient()Requestget()TestCaseKernelTestCaseWebTestCaseчистий PHPShippingRatesquote(weight)APP_ENV=testKernelboot()без вебсервераKernelBrowserrequest()static::getContainer()test.service_containerі приватні сервісиin-memory://InMemoryTransportgetSent()
TestCaseKernelTestCaseWebTestCaseчистий PHPShippingRatesquote(weight)APP_ENV=testKernelboot()без вебсервераKernelBrowserrequest()static::getContainer()test.service_containerі приватні сервісиin-memory://InMemoryTransportgetSent()
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • TestCase використовує ShippingRates (new).
  • KernelTestCase використовує Kernel (bootKernel()).
  • WebTestCase використовує KernelBrowser (createClient()).
  • KernelBrowser використовує Kernel (Request).
  • Kernel використовує test.service_container.
  • test.service_container використовує InMemoryTransport (get()).
03 · ВАРІАНТИ

Symfony і Laravel поруч

Ті самі чотири сценарії двома фреймворками: сервіс з контейнера, сторінка вакансії, замовлення з чергою й листом, список вакансій з бази. Перемикайте пари вкладок. Для Symfony в when@test уже стоїть транспорт async: 'in-memory://' і підключено розширення DAMA та Foundry, для Laravel працює tests/Pest.php з RefreshDatabase.

php OrderTest.php Symfony · сервіс
1final class ShippingRatesTest extends KernelTestCase
2{
3 public function testItQuotesNovaPoshta(): void
4 {
5 self::bootKernel();
6 static::getContainer()->set(NovaPoshtaClient::class, new FakeNovaPoshta(price: 96));
7
8 $rates = static::getContainer()->get(ShippingRates::class);
9
10 $this->assertSame(96, $rates->quote(weight: 3));
11 }
12}
ЩО ПЕРЕВІРЯЄ
ShippingRates зі справжнього контейнера, але з фейковим клієнтом Нової пошти. set() спрацює лише до того, як клієнт хтось створив: інакше тестовий контейнер кине виняток.
КОЛИ БРАТИ
Інтеграційний тест сервісу з усіма його залежностями з DI, без HTTP.
php OrderTest.php Laravel · сервіс
1it('quotes Nova Poshta', function () {
2 $this->app->instance(NovaPoshtaClient::class, new FakeNovaPoshta(price: 96));
3
4 expect(app(ShippingRates::class)->quote(weight: 3))->toBe(96);
5});
ЩО ПЕРЕВІРЯЄ
Те саме: instance() кладе фейк у контейнер, а app() збирає ShippingRates уже з ним. Приватних сервісів у Laravel немає, тож тестовий контейнер не потрібен.
КОЛИ БРАТИ
Коли в Symfony взяли б KernelTestCase. Для мока замість фейка є $this->mock(NovaPoshtaClient::class, fn ($mock) => ...).
php OrderTest.php Symfony · HTTP
1final class JobPageTest extends WebTestCase
2{
3 public function testItShowsTheVacancy(): void
4 {
5 $client = static::createClient();
6 $client->request('GET', '/jobs/senior-laravel');
7
8 $this->assertResponseIsSuccessful();
9 $this->assertSelectorTextContains('h1', 'Senior Laravel');
10 }
11}
ЩО ПЕРЕВІРЯЄ
Що сторінка відповідає 2xx і в h1 є назва вакансії. Перевірка йде по CSS-селектору через DomCrawler, тож текст з іншого місця сторінки її не обдурить.
КОЛИ БРАТИ
Функціональні тести контролерів, форм ($client->submitForm()) і редиректів (assertResponseRedirects()).
php OrderTest.php Laravel · HTTP
1it('shows the vacancy', function () {
2 $this->get('/jobs/senior-laravel')
3 ->assertOk()
4 ->assertSeeText('Senior Laravel');
5});
ЩО ПЕРЕВІРЯЄ
Статус 200 і текст вакансії на сторінці. Селекторів TestResponse не має: assertSeeText() шукає по всьому тексту, тому для точної перевірки беріть характерний рядок.
КОЛИ БРАТИ
Відповідник WebTestCase. Запит теж не виходить у мережу: Laravel передає його в HTTP-ядро всередині процесу.
php OrderTest.php Symfony · черга й лист
1public function testItQueuesTheInvoiceAndEmailsTheBuyer(): void
2{
3 $client = static::createClient();
4 $client->request('POST', '/orders', ['weight' => 3]);
5
6 /** @var InMemoryTransport $transport */
7 $transport = static::getContainer()->get('messenger.transport.async');
8 $this->assertCount(1, $transport->getSent());
9 $this->assertInstanceOf(GenerateInvoice::class, $transport->getSent()[0]->getMessage());
10
11 $this->assertEmailCount(1);
12 $this->assertEmailAddressContains($this->getMailerMessage(), 'To', 'olena@example.com');
13}
ЩО ПЕРЕВІРЯЄ
Що в транспорт async пішло одне повідомлення GenerateInvoice і що покупцеві надіслано один лист. Обробник повідомлення не виконується: in-memory транспорт лише складає конверти.
КОЛИ БРАТИ
Код, що відправляє повідомлення в Messenger і листи через Mailer. Якщо листи самі йдуть через Messenger асинхронно, вони вважаються поставленими в чергу, і рахувати їх треба assertQueuedEmailCount().
php OrderTest.php Laravel · черга й лист
1it('queues the invoice and emails the buyer', function () {
2 Queue::fake();
3 Mail::fake();
4
5 $this->actingAs(User::factory()->create(['email' => 'olena@example.com']))
6 ->post('/orders', ['weight' => 3]);
7
8 Queue::assertPushed(GenerateInvoice::class);
9 Mail::assertSent(OrderConfirmation::class, fn (OrderConfirmation $mail) => $mail->hasTo('olena@example.com'));
10});
ЩО ПЕРЕВІРЯЄ
Те саме через фейки фасадів: job у черзі й лист на адресу покупця. Конфіг не змінюється, Queue::fake() і Mail::fake() діють лише в цьому тесті.
КОЛИ БРАТИ
Відповідник in-memory транспорту й асертів Mailer. Детально про кожен фейк у темі «Фейки Laravel».
php OrderTest.php Symfony · база
1final class JobListTest extends WebTestCase
2{
3 public function testItListsPublishedJobs(): void
4 {
5 $client = static::createClient();
6 JobFactory::createMany(3, ['published' => true]);
7 JobFactory::createOne(['published' => false]);
8
9 $client->request('GET', '/jobs');
10
11 $this->assertSelectorCount(3, '.job-card');
12 }
13}
ЩО ПЕРЕВІРЯЄ
Що в списку лише три опубліковані вакансії. Записи створює Foundry, а DAMA відкотить транзакцію після тесту, тож наступний тест бачить порожню базу. Клієнт створюється першим: фабрика сама підняла б ядро, і createClient() кинув би LogicException.
КОЛИ БРАТИ
Тести з Doctrine. Схему створюють один раз перед прогоном, а ізоляцію між тестами дає транзакція.
php OrderTest.php Laravel · база
1it('lists published jobs', function () {
2 Job::factory()->count(3)->create(['published' => true]);
3 Job::factory()->create(['published' => false]);
4
5 $this->get('/jobs')
6 ->assertOk()
7 ->assertViewHas('jobs', fn ($jobs) => $jobs->count() === 3);
8});
ЩО ПЕРЕВІРЯЄ
Те саме вбудованими засобами: фабрики моделей замість Foundry і RefreshDatabase замість DAMA. Трейт теж загортає кожен тест у транзакцію, а міграції запускає один раз на прогін.
КОЛИ БРАТИ
Відповідник Foundry і DAMA. Сторонні пакети для цього в Laravel не потрібні.
04 · КОД

Тестовий контейнер своїми руками

PHP 8.5
1<?php
2
31interface NovaPoshtaClient
4{
5 public function cost(int $weight): int;
6}
7
8final class HttpNovaPoshtaClient implements NovaPoshtaClient
9{
10 public function cost(int $weight): int
11 {
12 throw new RuntimeException('справжній запит до API у тесті');
13 }
14}
15
16final class FakeNovaPoshta implements NovaPoshtaClient
17{
18 public function __construct(private int $price) {}
19
20 public function cost(int $weight): int
21 {
22 return $this->price;
23 }
24}
25
26final class ShippingRates
27{
28 public function __construct(private NovaPoshtaClient $client) {}
29
30 public function quote(int $weight): int
31 {
32 return $this->client->cost($weight);
33 }
34}
35
362// Скомпільований контейнер: фабрика й видимість кожного сервісу.
37final class Container
38{
39 /** @var array<string, object> */
40 private array $services = [];
41
42 /** @param array<string, array{Closure, bool}> $definitions */
43 public function __construct(private array $definitions) {}
44
45 public function get(string $id): object
46 {
47 if (! ($this->definitions[$id][1] ?? false)) {
48 throw new RuntimeException("сервіс {$id} приватний або видалений");
49 }
50
51 return $this->resolve($id);
52 }
53
54 public function resolve(string $id): object
55 {
56 return $this->services[$id] ??= $this->definitions[$id][0]($this);
57 }
58
59 public function replace(string $id, object $service): void
60 {
61 if (isset($this->services[$id])) {
62 throw new LogicException("сервіс {$id} уже створено, підмінити його не можна");
63 }
64 $this->services[$id] = $service;
65 }
66}
67
683// Тестовий контейнер: бачить і приватні сервіси.
69final class TestContainer
70{
71 public function __construct(private Container $container) {}
72
73 public function get(string $id): object
74 {
75 return $this->container->resolve($id);
76 }
77
78 public function set(string $id, object $service): void
79 {
80 $this->container->replace($id, $service);
81 }
82}
83
844$container = new Container([
85 NovaPoshtaClient::class => [fn () => new HttpNovaPoshtaClient(), false],
86 ShippingRates::class => [fn (Container $c) => new ShippingRates($c->resolve(NovaPoshtaClient::class)), false],
87]);
88
89try {
90 $container->get(ShippingRates::class);
91} catch (RuntimeException $e) {
92 echo "✗ звичайний контейнер: {$e->getMessage()}\n";
93}
94
955$test = new TestContainer($container);
96$test->set(NovaPoshtaClient::class, new FakeNovaPoshta(price: 96));
97
98echo '✓ тестовий контейнер: доставка 3 кг коштує ', $test->get(ShippingRates::class)->quote(3), " грн\n";
99
1006try {
101 $test->set(NovaPoshtaClient::class, new FakeNovaPoshta(price: 120));
102} catch (LogicException $e) {
103 echo "✗ друга підміна: {$e->getMessage()}\n";
104}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–34

    Клієнт Нової пошти за інтерфейсом: справжній ходить у мережу, фейк повертає заготовлену ціну. ShippingRates залежить лише від інтерфейсу.

  2. 2 рядки 36–66

    Скомпільований контейнер. Сервіси приватні: get() їх не віддає, а створений сервіс уже не замінити.

  3. 3 рядки 68–82

    Тестовий контейнер бачить і приватні сервіси. Так у Symfony працює test.service_container, який повертає static::getContainer().

  4. 4 рядки 84–93

    Звичайний контейнер приватний ShippingRates не віддає. У Symfony так поводиться $kernel->getContainer()->get().

  5. 5 рядки 95–98

    Підміна до першого використання: ShippingRates збирається вже з фейком, і запиту в мережу немає.

  6. 6 рядки 100–104

    Клієнт уже створено, тож друга підміна падає. Справжній TestContainer::set() кидає такий самий виняток для вже ініціалізованого приватного сервісу.

  7. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
05 · ТИПОВІ ПОМИЛКИ

На чому спотикаються в тестах Symfony

✕ 1

bootKernel() перед createClient()

Тест спершу дістає сервіс з контейнера або створює сутність фабрикою, а вже потім викликає static::createClient(). Ядро на цей момент уже піднято, і WebTestCase кидає LogicException: Booting the kernel before calling createClient() is not supported.

Натомість: створюйте клієнт першим рядком тесту, а сервіси беріть після нього через static::getContainer(). Якщо підготовка мусить іти раніше, вимкніть ядро перед клієнтом: static::ensureKernelShutdown(), так радить і документація Foundry.

✕ 2

Підміна сервісу, що зникає після запиту

static::getContainer()->set() підмінив клієнт API, перший запит пройшов з фейком, а другий раптом пішов у мережу. KernelBrowser перед кожним наступним запитом перезавантажує ядро, і контейнер створюється заново.

Натомість: для кількох запитів з підміненим сервісом викличте $client->disableReboot() або задайте фейк конфігом у when@test, наприклад MockHttpClient через опцію mock_response_factory у налаштуваннях http_client.

✕ 3

Приватний сервіс, якого немає в тестовому контейнері

static::getContainer()->get(PriceFormatter::class) кидає ServiceNotFoundException, хоча клас зареєстровано. Сервіс ніхто не використовує, і компілятор його видалив, або вбудував в інший сервіс.

Натомість: тестуйте сервіс через того, хто ним користується, або створіть його в тесті через new. Робити сервіс public заради тесту не треба.

✕ 4

assertEmailCount() для листів через Messenger

Листи маршрутизовано в асинхронний транспорт, і assertEmailCount(1) показує нуль: Mailer вважає такий лист поставленим у чергу, а не надісланим.

Натомість: рахуйте assertQueuedEmailCount(), або в when@test надсилайте SendEmailMessage синхронно, якщо тесту потрібен сам лист.

06 · ДЕРЕВО РІШЕНЬ

Який базовий клас узяти

1
Тестові потрібні сервіси з контейнера або конфіг застосунку?
Репозиторій Doctrine, серіалізатор, сервіс з десятком залежностей з DI.
НІ →PHPUnit\Framework\TestCase. Створіть клас через new, залежності підставте фейками. Найшвидший варіант, ядро не завантажується.
2
Перевіряєте відповідь на HTTP-запит?
Маршрут, контролер, форма, редирект, права доступу, вміст сторінки.
НІ →KernelTestCase: self::bootKernel(), потім static::getContainer()->get() і виклик сервісу напряму.
ТАК НА ВСІ →WebTestCase: static::createClient(), $client->request() і асерти відповіді на кшталт assertResponseIsSuccessful() та assertSelectorTextContains().
07 · СЛОВНИК
KernelTestCase
Базовий клас Symfony для тестів з ядром. bootKernel() створює ядро з KERNEL_CLASS у середовищі test, getContainer() дає тестовий контейнер, а після кожного тесту ядро вимикається.
WebTestCase
Нащадок KernelTestCase для функціональних тестів. createClient() повертає KernelBrowser, а трейти з асертами додають assertResponseIsSuccessful(), assertSelectorTextContains() і листи через assertEmailCount().
Тестовий контейнер
Сервіс test.service_container. Дає доступ до приватних сервісів, якщо компілятор їх не вбудував у інші й не видалив, і дозволяє підмінити сервіс через set(), доки той не створений.
Середовище test
Значення APP_ENV=test. Для нього Symfony читає .env.test і .env.test.local, пропускає .env.local і застосовує блоки when@test з конфігів.
In-memory транспорт
Транспорт Messenger з DSN in-memory://. Нікуди не відправляє повідомлення, а складає їх у памʼяті: getSent(), getAcknowledged(), getRejected().
DAMA DoctrineTestBundle
Сторонній бандл dama/doctrine-test-bundle. Розширення PHPUnit відкриває транзакцію перед кожним тестом і відкочує її після, тож базу не треба перестворювати між тестами.
08 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

KernelTestCase піднімає ядро в середовищі test і дає контейнер, щоб викликати сервіси напряму. WebTestCase його розширює: createClient() повертає KernelBrowser, який шле запити в ядро без вебсервера, і додає асерти відповіді та сторінки. У Laravel обидві ролі в одному TestCase: app() для сервісів і $this->get() для запитів.

static::getContainer() повертає тестовий контейнер test.service_container, який віддає й приватні сервіси. Не спрацює для сервісу, який компілятор видалив як невикористаний або вбудував в інший: його в контейнері вже немає. Потрібен framework.test: true, у тестовому середовищі його вмикає рецепт.

У when@test задати транспорту DSN in-memory://, а в тесті взяти його з тестового контейнера як messenger.transport.async і перевірити getSent(): кількість конвертів і клас повідомлення в getMessage(). Обробник не викликається, тому його тестують окремо. У Laravel те саме дає Queue::fake() чи Bus::fake().

Схема створюється один раз, а кожен тест іде в транзакції, яку потім відкочують, тож база після тесту чиста без DROP і міграцій. Не допоможе там, де транзакції немає або вона непомітно комітиться: DDL на кшталт ALTER TABLE в MySQL, другий процес чи зʼєднання, яке не бачить незакомічених даних, код, що сам керує транзакціями. Тоді лишається ResetDatabase з Foundry або окрема база.