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

Фейки LaravelLaravel fakes

Вбудовані фейки Laravel підміняють пошту, чергу, події, HTTP і диски одним рядком. Тест робить дію, а потім питає фейк, що саме той отримав.

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

Фейк замість справжнього сервісу за фасадом

Фасад Laravel, як-от Mail чи Queue, сам нічого не вміє: кожен статичний виклик він передає обʼєкту з . Mail::fake() кладе на це місце , тобто MailFake, який нічого не надсилає, а лише записує, що йому передали. Код застосунку не змінюється: він так само пише Mail::to($user)->send(...), але лист опиняється в памʼяті фейка.

СЛОВНИКСервіс-контейнер. Обʼєкт Laravel, який створює й зберігає сервіси. Фасад Mail звертається до нього за сервісом mail.manager, а Facade::swap() міняє цей екземпляр на інший.
СЛОВНИКФейк Laravel. Клас з Illuminate\Support\Testing\Fakes (MailFake, QueueFake, BusFake, EventFake, NotificationFake), що стає на місце справжнього сервісу, записує виклики й має assert-методи. Storage::fake() підставляє локальний диск у storage/framework/testing/disks, а Http::fake() перехоплює запити клієнта.

Після дії тест питає фейк через assert-методи: Mail::assertSent(), Notification::assertSentTo(), Queue::assertPushed(), Bus::assertDispatched(), Event::assertDispatched(), Http::assertSent(), а для дисків Storage::disk()->assertExists(). Кожен приймає замикання для перевірки вмісту: кому лист, з яким id job, які параметри пішли в API. Є й заперечення: assertNotSent(), assertNothingSent(), assertNothingPushed().

Http::fake() стоїть окремо: він підміняє не сервіс, а відповідь сервера. Відповіді описують за шаблоном URL, а Http::sequence() віддає їх по черзі, щоб перевірити повтор після 503. Якщо ж URL жодному шаблону не відповідає, запит тихо піде в справжню мережу. Http::preventStrayRequests() перетворює такий запит на з винятком, тож тест падає замість того, щоб ходити в API monobank.

СЛОВНИКStray request. HTTP-запит на адресу, для якої в тесті немає фейкової відповіді. Після Http::preventStrayRequests() він кидає StrayRequestException.

Від фейк відрізняється порядком перевірки. Mail::shouldReceive('send')->once() описує очікуваний виклик до дії й ламається, щойно код перейде з send() на queue(). Фейк записує все й перевіряється після дії, як spy з теми про test doubles, тому тест привʼязаний до результату, а не до того, яким методом його досягли.

СЛОВНИКMock. Дублер з очікуваннями до дії. У Laravel його дає Mail::shouldReceive() через Mockery: тест падає, якщо очікуваного виклику не було.
02 · МЕЖА

Що насправді робить Mail::fake()

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
post()assertSent()OrderTestPOST /ordersMail::to()->send()фасадMail__callStatic()сервіс-контейнерmail.managerекземпляр за фасадому продакшніMailManagerпісля Mail::fake()MailFake
post()OrderTestPOST /ordersMail::to()->send()фасадMail__callStatic()сервіс-контейнерmail.managerекземпляр за фасадому продакшніMailManagerпісля Mail::fake()MailFake
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • OrderTest використовує POST /orders (post()).
  • POST /orders використовує Mail.
  • Mail використовує mail.manager.
  • MailManager реалізує mail.manager.
  • MailFake реалізує mail.manager.
  • OrderTest використовує MailFake (assertSent()).
03 · ВАРІАНТИ

Одне замовлення, сім фейків

POST /orders рахує доставку через API Нової пошти, надсилає покупцеві лист, повідомляє відділ продажів, ставить у чергу рахунок, кидає подію OrderPlaced і кладе квитанцію на диск. Кожна вкладка перевіряє одну з цих дій своїм фейком. fakeNovaPoshta() це маленький помічник з Http::fake(), як fakeGitHubEndpoints() у тестах цього сайту, а у вкладці Http такий фейк розписано повністю.

php PlaceOrderTest.php Mail
1it('emails the order confirmation', function () {
2 fakeNovaPoshta();
3 Mail::fake();
4
5 $this->actingAs(User::factory()->create(['email' => 'olena@example.com']))
6 ->post('/orders', ['weight' => 3])
7 ->assertCreated();
8
9 Mail::assertSent(OrderConfirmation::class, fn (OrderConfirmation $mail) => $mail->hasTo('olena@example.com'));
10 Mail::assertSentCount(1);
11});
ЩО ПЕРЕВІРЯЄ
Що OrderConfirmation пішов саме на адресу покупця і що лист один. Якщо mailable реалізує ShouldQueue, фейк кладе його в чергу, а не надсилає: тоді потрібен assertQueued(), і assertSent() сам про це підкаже.
КОЛИ БРАТИ
Листи через Mailable: підтвердження, скидання пароля, рахунки.
php PlaceOrderTest.php Notification
1it('notifies the sales team', function () {
2 fakeNovaPoshta();
3 Notification::fake();
4
5 $this->actingAs(User::factory()->create())->post('/orders', ['weight' => 3]);
6
7 Notification::assertSentOnDemand(
8 NewOrder::class,
9 fn (NewOrder $notification, array $channels, object $notifiable) => $notifiable->routes['mail'] === 'sales@example.com',
10 );
11});
ЩО ПЕРЕВІРЯЄ
Що відділ продажів отримав NewOrder на свою адресу. Отримувач тут не модель, а Notification::route('mail', ...), тому перевірка через assertSentOnDemand(). Для користувача була б assertSentTo($user, NewOrder::class).
КОЛИ БРАТИ
Сповіщення з кількома каналами: пошта, база, Telegram. Фейк записує й ті, що реалізують ShouldQueue, тож assertSentTo() їх бачить.
php PlaceOrderTest.php Queue
1it('queues the invoice', function () {
2 fakeNovaPoshta();
3 Queue::fake();
4
5 $id = $this->actingAs(User::factory()->create())
6 ->post('/orders', ['weight' => 3])
7 ->json('id');
8
9 Queue::assertPushedOn('invoices', GenerateInvoice::class, fn (GenerateInvoice $job) => $job->orderId === $id);
10});
ЩО ПЕРЕВІРЯЄ
Що job GenerateInvoice з потрібним id потрапив саме в чергу invoices. handle() не виконується: його перевіряють окремим тестом.
КОЛИ БРАТИ
Важлива сама черга: назва, затримка, ланцюжок (assertPushedWithChain()). Job, запущений через dispatchSync(), у чергу не потрапляє, і Queue::fake() його не побачить.
php PlaceOrderTest.php Bus
1it('dispatches the invoice job', function () {
2 fakeNovaPoshta();
3 Bus::fake([GenerateInvoice::class]);
4
5 $this->actingAs(User::factory()->create())->post('/orders', ['weight' => 3]);
6
7 Bus::assertDispatched(GenerateInvoice::class);
8 Bus::assertDispatchedTimes(GenerateInvoice::class, 1);
9});
ЩО ПЕРЕВІРЯЄ
Що GenerateInvoice відправили рівно раз. Bus::fake() перехоплює dispatch() незалежно від того, піде job у чергу чи виконається одразу. Список у fake() підміняє лише GenerateInvoice, решта jobs відпрацює по-справжньому.
КОЛИ БРАТИ
Цікавить факт відправки, а не черга. Для batch і chain у BusFake є assertBatched() і assertChained().
php PlaceOrderTest.php Event
1it('announces the order', function () {
2 fakeNovaPoshta();
3 Event::fake([OrderPlaced::class]);
4
5 $this->actingAs(User::factory()->create())->post('/orders', ['weight' => 3]);
6
7 Event::assertDispatched(OrderPlaced::class, fn (OrderPlaced $event) => $event->weight === 3);
8});
ЩО ПЕРЕВІРЯЄ
Що подію OrderPlaced кинули з вагою 3. Слухачі цієї події не виконуються, а всі інші події, зокрема події моделей, ідуть як завжди, бо підмінено лише OrderPlaced.
КОЛИ БРАТИ
Подія це межа між модулями, і слухачів тестують окремо. Щоб підмінити події лише на шматок коду, є Event::fakeFor(fn () => ...), а щоб пропустити якісь події крізь фейк, Event::fake()->except([...]).
php PlaceOrderTest.php Http
1it('asks Nova Poshta for the rate', function () {
2 Http::preventStrayRequests();
3 Http::fake([
4 'api.novaposhta.ua/*' => Http::sequence()
5 ->push(['success' => true, 'data' => [['Cost' => 96]]])
6 ->pushStatus(503),
7 ]);
8 $this->actingAs(User::factory()->create());
9
10 $this->post('/orders', ['weight' => 3])->assertJson(['delivery' => 96]);
11 $this->post('/orders', ['weight' => 3])->assertServiceUnavailable();
12
13 Http::assertSent(fn (Request $request) => $request['methodProperties']['Weight'] === 3);
14 Http::assertSentCount(2);
15});
ЩО ПЕРЕВІРЯЄ
Перше замовлення отримує ціну 96 грн, друге натрапляє на 503 від Нової пошти й віддає 503 сам. Http::assertSent() перевіряє, що в API пішла вага 3, а preventStrayRequests() не дасть коду непомітно звернутися до будь-якої іншої адреси.
КОЛИ БРАТИ
Будь-який зовнішній API через Http-клієнт Laravel. Коли відповіді в sequence скінчаться, наступний запит кине OutOfBoundsException, якщо не задати ->whenEmpty().
php PlaceOrderTest.php Storage
1it('stores the receipt', function () {
2 fakeNovaPoshta();
3 Storage::fake('local');
4
5 $id = $this->actingAs(User::factory()->create())
6 ->post('/orders', ['weight' => 3])
7 ->json('id');
8
9 Storage::disk('local')->assertExists("receipts/{$id}.pdf");
10});
ЩО ПЕРЕВІРЯЄ
Що квитанцію записано саме за шляхом receipts/{id}.pdf. Storage::fake() підставляє замість диска чисту теку storage/framework/testing/disks/local, тож справжні файли й S3 не страждають.
КОЛИ БРАТИ
Завантаження й генерація файлів, особливо коли в продакшні диск у S3. Для завантажень у тесті є UploadedFile::fake()->image() і ->create().
04 · КОД

Фасад, контейнер і фейк своїми руками

PHP 8.5
1<?php
2
3final class OrderConfirmation
4{
5 public function __construct(public int $orderId) {}
6}
7
8final class SmtpMailer
9{
10 public function send(string $to, object $mail): void
11 {
12 throw new RuntimeException("справжній SMTP у тесті, лист на {$to}");
13 }
14}
15
161// Контейнер: імʼя сервісу і обʼєкт, що за ним стоїть.
17final class Container
18{
19 /** @var array<string, object> */
20 public static array $instances = [];
21}
22
232final class MailFake
24{
25 /** @var list<array{string, object}> */
26 private array $sent = [];
27
28 public function send(string $to, object $mail): void
29 {
30 $this->sent[] = [$to, $mail];
31 }
32
33 public function assertSent(string $class, ?Closure $check = null): void
34 {
35 foreach ($this->sent as [$to, $mail]) {
36 if ($mail instanceof $class && ($check === null || $check($mail, $to))) {
37 echo "✓ {$class} надіслано на {$to}\n";
38
39 return;
40 }
41 }
42 echo "✗ немає {$class}, що проходить перевірку\n";
43 }
44}
45
463final class Mail
47{
48 // Фасад нічого не вміє сам: передає виклик обʼєкту з контейнера.
49 public static function __callStatic(string $method, array $args): mixed
50 {
51 return Container::$instances['mail.manager']->$method(...$args);
52 }
53
54 public static function fake(): MailFake
55 {
56 return Container::$instances['mail.manager'] = new MailFake();
57 }
58}
59
60// Код застосунку знає лише фасад.
61function placeOrder(string $email): int
62{
63 $orderId = 1042;
64 Mail::send($email, new OrderConfirmation($orderId));
65
66 return $orderId;
67}
68
694Container::$instances['mail.manager'] = new SmtpMailer();
70
71try {
72 placeOrder('olena@example.com');
73} catch (RuntimeException $e) {
74 echo "✗ без фейка: {$e->getMessage()}\n";
75}
76
775Mail::fake();
78$id = placeOrder('olena@example.com');
79
80Mail::assertSent(OrderConfirmation::class, fn (OrderConfirmation $mail) => $mail->orderId === $id);
81Mail::assertSent(OrderConfirmation::class, fn (OrderConfirmation $mail, string $to) => $to === 'sales@example.com');
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 16–21

    Контейнер зберігає обʼєкт під іменем. У Laravel фасад Mail дивиться на mail.manager, і саме цей екземпляр підмінює fake().

  2. 2 рядки 23–44

    MailFake нічого не надсилає: send() кладе лист у масив, а assertSent() шукає в ньому лист потрібного класу, що проходить перевірку з замикання.

  3. 3 рядки 46–58

    Фасад передає кожен статичний виклик обʼєкту з контейнера. fake() кладе на те саме місце MailFake, і код застосунку різниці не помічає.

  4. 4 рядки 69–75

    Без фейка лист пішов би в справжній SMTP, тут він кидає виняток.

  5. 5 рядки 77–81

    Після Mail::fake() той самий код пише лист у фейк. Перша перевірка проходить, друга ні: на sales@example.com листа не було.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
05 · ЯК У PHP UKRAINE

Курс НБУ через Http::sequence()

Курс долара для зарплат у вакансіях сайт бере з API НБУ. Тест підставляє одну добру відповідь і далі 503 на кожен повтор через whenEmpty(), тож видно, що команда hiring:refresh-rates спершу зберігає курс, а коли банк лежить, падає й лишає попередній. Схожі фейки є й деінде: тести входу через GitHub вмикають Http::preventStrayRequests(), профілі розробників перевіряють фото на Storage::fake('public'), а реєстрація ловить листи через Notification::fake(). Queue::fake(), Bus::fake() і Event::fake() у наших тестах не трапляються: у застосунку немає власних jobs і подій, а черга в phpunit.xml працює як sync.

php ExchangeRatesTest.php фрагмент, скорочено
1// One good answer, then the bank is down for every retry.
2Http::fake([
3 'bank.gov.ua/*' => Http::sequence()← відповіді по черзі
4 ->push([
5 ['r030' => 840, 'txt' => 'Долар США', 'rate' => 41.2345, 'cc' => 'USD', 'exchangedate' => '05.09.2026'],
6 ['r030' => 978, 'txt' => 'Євро', 'rate' => 45.3579, 'cc' => 'EUR', 'exchangedate' => '05.09.2026'],
7 ])
8 ->whenEmpty(Http::response('', 503)),← далі завжди 503
9]);
10
11$this->artisan('hiring:refresh-rates')->assertSuccessful()->expectsOutputToContain('41.23 UAH per USD');
12
13expect($rates->uahPerUsd())->toBe(41.2345)
14 ->and($rates->usdPerEur())->toBe(1.1)
15 ->and($rates->asOf()?->format('Y-m-d'))->toBe('2026-09-05');
16
17$this->artisan('hiring:refresh-rates')->assertFailed();← банк лежить
18
19expect($rates->uahPerUsd())->toBe(41.2345);← курс лишився
06 · ТИПОВІ ПОМИЛКИ

Де фейки обманюють

✕ 1

Event::fake() без списку

Без аргументів EventFake підміняє всі події, зокрема події Eloquent. Обсервери й хуки creating перестають працювати, і модель без згенерованого slug чи UUID ламає тест у неочікуваному місці.

Натомість: передавайте список: Event::fake([OrderPlaced::class]). Або обмежте фейк шматком коду через Event::fakeFor(fn () => ...).

✕ 2

assertSent() для листа в черзі

OrderConfirmation реалізує ShouldQueue, і MailFake кладе його в чергу. Mail::assertSent() падає, хоча лист «пішов».

Натомість: для таких mailable перевіряйте Mail::assertQueued(). Повідомлення assertSent() саме підкаже: Did you mean to use assertQueued() instead?

✕ 3

Http::fake() без preventStrayRequests()

Хтось додав виклик ще одного API, а в тесті його не описали. Запит тихо йде в справжню мережу: тест повільний, нестабільний і може створити справжню ТТН.

Натомість: викликайте Http::preventStrayRequests() у beforeEach() або в tests/Pest.php для feature-тестів. Неописаний URL тоді кидає StrayRequestException з адресою запиту.

✕ 4

Перевірка лише класу

Mail::assertSent(OrderConfirmation::class) пройде, навіть якщо лист пішов не тому покупцеві або з чужим замовленням.

Натомість: передавайте замикання й перевіряйте вміст: fn (OrderConfirmation $mail) => $mail->hasTo($user->email). Для заперечення є assertNotSent() з тим самим замиканням.

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

Фейк, мок чи нічого

1
Дія виходить за межі процесу?
Лист, черга, HTTP-запит, файл на S3. База в памʼяті, кеш array і sync-черга сюди не належать.
НІ →Нічого не підміняйте. SQLite у памʼяті й array-драйвери вже швидкі, перевіряйте результат.
2
Для цієї дії є вбудований фейк Laravel?
Mail, Notification, Queue, Bus, Event, Http, Storage. Для SDK платіжки чи власного клієнта його немає.
НІ →Загорніть залежність в інтерфейс і підставте свій fake або Mockery::spy(), як у темі про test doubles.
3
Тесту досить знати, що дію запущено, без її виконання?
Чи надіслано лист і кому. А от бонуси, які нараховує слухач події, тесту потрібні насправді.
НІ →Фейкайте вибірково: Event::fake([OnlyThis::class]) чи Bus::fake([Job::class]) пропустять решту, а сам слухач чи job перевірте окремим тестом.
ТАК НА ВСІ →Вбудований фейк: X::fake() першим рядком, дія, потім X::assert...() з замиканням на вміст.
08 · СЛОВНИК
Сервіс-контейнер
Обʼєкт Laravel, який створює й зберігає сервіси. Фасад Mail звертається до нього за сервісом mail.manager, а Facade::swap() міняє цей екземпляр на інший.
Фейк Laravel
Клас з Illuminate\Support\Testing\Fakes (MailFake, QueueFake, BusFake, EventFake, NotificationFake), що стає на місце справжнього сервісу, записує виклики й має assert-методи. Storage::fake() підставляє локальний диск у storage/framework/testing/disks, а Http::fake() перехоплює запити клієнта.
Stray request
HTTP-запит на адресу, для якої в тесті немає фейкової відповіді. Після Http::preventStrayRequests() він кидає StrayRequestException.
Mock
Дублер з очікуваннями до дії. У Laravel його дає Mail::shouldReceive() через Mockery: тест падає, якщо очікуваного виклику не було.
09 · ПЕРЕВІР СЕБЕ

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

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

Він підміняє обʼєкт, що стоїть за фасадом Mail, на MailFake. Код застосунку викликає ті самі методи, але листи нікуди не йдуть, а записуються у фейк, і тест читає їх через Mail::assertSent(), assertQueued() чи assertNothingSent().

Queue::fake() підміняє чергу й бачить лише те, що в неї потрапило, разом з назвою черги: assertPushedOn('invoices', ...). Bus::fake() підміняє диспетчер команд і ловить кожен dispatch(), незалежно від того, піде job у чергу чи виконається одразу. Обидва приймають список класів, щоб підмінити тільки їх.

Без додаткових налаштувань запит піде в справжню мережу. Http::preventStrayRequests() змінює це: неописаний запит кидає StrayRequestException, і тест падає. Для sequence окремо: коли заготовлені відповіді закінчились, наступний запит кидає OutOfBoundsException, якщо не задано whenEmpty().

Фейк фасаду перевіряє лише, що запит до сервісу зроблено. Чи справжній сервіс прийме цей лист чи job, фейк не знає, тому серіалізацію, шаблон листа і handle() job треба тестувати окремо. А для власних залежностей, як клієнт Нової пошти, краще інтерфейс з in-memory реалізацією: він дає стан, який тест читає, і не привʼязаний до фасадів.