<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Як ви тестуєте Laravel-застосунок: feature проти unit, фейки й фабрики?

У Laravel «unit» і «feature» — це не «клас проти роуту», а «без завантаженого застосунку проти з ним»: у стандартному `tests/Pest.php` `Tests\TestCase` підключається лише до теки `Feature`, тому в `Unit` немає ні контейнера, ні фасадів, ні бази. Логіку без Illuminate тестують швидкими unit-тестами, все інше — feature-тестами через HTTP-межу з фабриками для даних і фейками (`Queue`, `Mail`, `Http`, `Storage`) для зовнішніх меж; фейк доводить лише факт виклику, тому job чи mailable потребують власного тесту.

У вас 400 тестів — скільки з них unit, скільки feature і чому саме так?
Тест із `Mail::fake()` зелений, а на проді лист не пішов. Як таке можливо?
Ви додали `Event::fake()` — і обсервер перестав проставляти slug. Що сталося?
Ви написали тест сервісу, підмінивши мокапом власний репозиторій. Що саме цей тест довів?
Pest тестування фабрики Queue::fake RefreshDatabase Mockery

Почніть відповідь із того, що в Laravel «unit» і «feature» — це технічна, а не філософська межа. У згенерованому tests/Pest.php стоїть pest()->extend(Tests\TestCase::class)->in('Feature'), і саме Tests\TestCase тягне за собою CreatesApplication: піднімає контейнер, реєструє провайдери, вмикає фасади й зʼєднання з базою. Тести з теки Unit успадковують голий PHPUnit\Framework\TestCase, тому будь-який Cache::get() чи config() там впаде з «A facade root has not been set», а Model::factory() — з відсутнім зʼєднанням. Звідси й практичне правило: unit-тест — це тест коду, який не потребує застосунку (калькулятори, value objects, доменні правила, форматери), і він коштує мілісекунди; feature-тест піднімає застосунок, б'є в роут через $this->post(...) й перевіряє все, що між HTTP-запитом і рядком у базі: middleware, Form Request, авторизацію, контролер, модель, редірект. У проєкті з розділенням на Domain/Application/Infrastructure межа збігається з архітектурною: Domain і Application не імпортують Illuminate, тому покриваються unit-тестами природно, а все, що є Laravel-склейкою, — feature-тестами.

Пропорція між ними — це не «піраміда з підручника», а наслідок того, де у вашому коді живуть баги. У типовому CRUD-застосунку більшість помилок — не в арифметиці, а на стиках: забутий middleware('auth'), правило валідації, яке пропускає порожній рядок, політика, що дозволяє чуже редагувати, звʼязок, який повертає не те. Усе це ловиться лише feature-тестом, тому в Laravel-проєктах їх зазвичай більшість, і це нормально. Unit-тести виправдані там, де є справжня логіка з багатьма гілками: розрахунок вилки зарплати, парсер, стейт-машина статусів, — там прогнати 20 випадків через HTTP-цикл просто марнотратно.

База даних у feature-тестах тримається на трейтах, і їх варто розрізняти. RefreshDatabase мігрує схему один раз на весь прогін, а далі загортає кожен тест у транзакцію й відкочує її — це найшвидший і дефолтний варіант, але він не працює, якщо тестований код сам робить TRUNCATE або керує транзакціями так, що зовнішня «ламається»; тоді беруть DatabaseTruncation зі списком $tablesToTruncate. DatabaseMigrations мігрує заново перед кожним тестом — чесно, але повільно, і на великій сюїті це головна причина шестихвилинних прогонів. Окремо памʼятайте про драйвер: SQLite :memory: спокусливо швидкий, але це інша СУБД — lockForUpdate() там фактично no-op, JSON-функції, строгість типів, поведінка дат і повідомлення про порушення унікальності відрізняються від MySQL і PostgreSQL. Якщо код покладається на блокування чи специфічний SQL, тести мають ходити в ту саму СУБД, що й прод, а швидкість добирається через --parallel (Laravel створює по базі на процес: ..._test_1, ..._test_2) і BCRYPT_ROUNDS=4 у phpunit.xml.

Дані для тестів створюють фабриками, і тут більшість зупиняється на Model::factory()->create(), не використавши й половини можливостей. make() будує модель без запису в базу — цього достатньо, коли перевіряється серіалізація чи метод моделі. Стани (->published(), ->expired()) описують не поля, а бізнес-ситуацію, і роблять тест читабельним. for() і has() будують дерево звʼязків одним виразом, а recycle($company) вирішує типову проблему такого дерева: без нього кожна вкладена фабрика створює собі нову компанію, і тест на «вакансії однієї компанії» тихо перевіряє не те. sequence() дає різні значення на кожну наступну модель, afterCreating() довішує побічні сутності. Дві пастки: фабрика, викликана в Pest-датасеті, впаде, бо датасети резолвляться до підняття застосунку (створюйте дані замиканням усередині тесту), і фабрика, яка через has() генерує сотні рядків заради одного асерту, — саме такі тести потім показує pest --profile.

Фейки — це підміна біндінгу в контейнері на записувач викликів, і з них випливає все їхнє правильне й неправильне вживання. Ставити фейк треба до дії: Queue::fake() після dispatch() не побачить нічого. Queue::fake() перехоплює чергу, але не dispatchSync() — така job виконається по-справжньому; повний перехоплювач — Bus::fake(), який ще й уміє assertChained() та assertBatched(). Event::fake() без списку класів глушить і події моделей, тому обсервер, що проставляє slug чи uuid, мовчить, і тест валиться в несподіваному місці — тому майже завжди пишуть Event::fake([VacancyPublished::class]) або Event::fakeExcept(). Mail::fake() розрізняє відправлені й поставлені в чергу листи: mailable із ShouldQueue перевіряється через assertQueued(), а не assertSent(). Http::fake() без Http::preventStrayRequests() перетворює будь-який незбіжний запит на порожню 200 — інтеграція зламана, тест зелений. Storage::fake('public') підміняє диск тимчасовою текою, після чого працює Storage::disk('public')->assertExists(...). І головне обмеження, яке відрізняє сильну відповідь: фейк доводить лише те, що ви перетнули межу, а не те, що по той бік усе правильно. На кожен Queue::assertPushed(IndexVacancy::class) має існувати другий тест, який створює job і кличе handle() із підставленою залежністю; інакше клас, який ви «покрили», не виконувався жодного разу.

// tests/Unit/SalaryRangeTest.php — без контейнера й БД: чиста логіка домену.
it('нормалізує перевернуту вилку зарплати', function () {
    $range = new SalaryRange(from: 4000, to: 3000, currency: 'USD');

    expect($range->from())->toBe(3000)          // межі міняються місцями
        ->and($range->to())->toBe(4000);
});

// tests/Feature/PublishVacancyTest.php — справжній роут, контейнер і база.
it('публікує вакансію та ставить її в чергу на індексацію', function () {
    Queue::fake([IndexVacancy::class]);         // решта job виконуються як завжди
    Http::preventStrayRequests();               // незамокане не поверне тихо 200
    Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
    $this->freezeTime();

    $company = Company::factory()->create();
    // recycle(): і користувач, і вакансії посилаються на ту саму компанію,
    // інакше кожна вкладена фабрика створила б собі нову.
    $user = User::factory()->recycle($company)->create();

    $this->actingAs($user)
        ->post(route('vacancies.store'), ['title' => 'Senior PHP', 'salary_from' => 4000])
        ->assertRedirect();

    $this->assertDatabaseHas('vacancies', [
        'company_id' => $company->id,
        'published_at' => now(),                // детерміновано лише через freezeTime()
    ]);
    Queue::assertPushed(IndexVacancy::class);
    Http::assertSentCount(1);
});

// Фейк довів тільки те, що job поставили в чергу. Її поведінка — окремий тест.
it('надсилає вакансію в пошуковий індекс', function () {
    $vacancy = Vacancy::factory()->published()->create(); // стан фабрики
    $index = Mockery::spy(SearchIndex::class);

    (new IndexVacancy($vacancy->id))->handle($index);     // handle() кличемо руками

    $index->shouldHaveReceived('put')->once();
});
Що поділ на unit/feature у Laravel визначається не розміром об'єкта тестування, а тим, чи піднято застосунок: у дефолтному `tests/Pest.php` стоїть `pest()->extend(Tests\TestCase::class)->in('Feature')`, тож у `Unit` фасад кине «A facade root has not been set».
Що `Queue::fake()` не перехоплює `dispatchSync()` (job виконається по-справжньому), а `Bus::fake()` перехоплює і його — звідси `Bus::assertDispatchedSync()`, `assertChained()`, `assertBatched()`.
Що `Event::fake()` без аргументів глушить і події моделей (`creating`, `saved`, `deleted`), тому обсервери й `booted()`-хуки перестають працювати; рятує `Event::fake([OrderShipped::class])` або `Event::fakeExcept()`.
Що `RefreshDatabase` мігрує базу один раз на прогін і загортає кожен тест у транзакцію з відкотом, а не мігрує заново перед кожним (це `DatabaseMigrations`).
Що фабрики вміють більше за `create()`: стани, `for()`/`has()`, `recycle()` для переви­користання тієї самої повʼязаної моделі, `sequence()`, і що `make()` не пише в базу.
Що фейк перевіряє межу, а не поведінку: після `Queue::assertPushed(Job::class)` сама job лишається непокритою, доки її `handle()` не викликано в окремому тесті.
Мокати те, що написали самі: `$this->mock(VacancyRepository::class)->shouldReceive('find')->andReturn($vacancy)` — такий тест перевіряє власну ж заглушку й ламається від будь-якого рефакторингу, не ловлячи жодного бага.
`Mail::fake()` + `assertSent()` для mailable з `ShouldQueue` або відправленого через `Mail::queue()`: він реєструється як queued, тож потрібен `assertQueued()`, інакше тест валиться (або, гірше, дає хибну впевненість у зворотному напрямку).
`Http::fake()` без `Http::preventStrayRequests()`: щойно зареєстровано хоч один фейк, усі незбіжні запити тихо отримують порожню відповідь 200 — URL змінили, інтеграція зламалась, тест зелений.
Викликати фабрику в Pest-датасеті: датасети резолвляться до того, як підніметься застосунок, тому `dataset('users', [User::factory()->create()])` падає; дані створюють замиканням усередині тесту.
Ганяти сюїту на SQLite `:memory:`, а прод тримати на MySQL/PostgreSQL: `lockForUpdate()` і `sharedLock()` там фактично no-op, JSON-функції й строгість типів інші, помилки унікальності та дати поводяться інакше.
Ставити `Event::fake()` чи `Queue::fake()` після дії, яку перевіряють: фейк підміняє біндінг у контейнері в момент виклику, тож усе, що відбулося раніше, він не бачить і асерт покаже «нічого не відправлено».
Гнатися за відсотком покриття unit-тестами гетерів, ресурсів і фасадних обгорток, залишивши без жодного feature-тесту авторизацію й валідацію — саме там ламається продакшн.
ПОРАДА

Скажіть уголос розділову лінію: «unit — для коду без Illuminate, feature — для всього, що торкається контейнера, БД чи HTTP», і одразу додайте, що в Laravel це технічна межа, а не філософська: у `Unit` застосунок не піднято. Далі — правило про фейки: «фейк перевіряє, що ми перетнули межу, а не що по той бік усе правильно», тому на кожен `Queue::assertPushed()` має бути другий тест, який кличе `handle()`.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`Queue::fake()` підміняє менеджер черг, а `dispatchSync()` до черги не звертається — job виконається по-справжньому; ловить його `Bus::fake()` (`assertDispatchedSync()`). `Event::fake()` без списку класів глушить і події моделей, тому обсервери, які проставляють slug чи uuid, мовчать. У стандартному `tests/Pest.php` `Tests\TestCase` підключено рядком `->in('Feature')`, тож у `Unit` застосунок не піднято й фасади кинуть «A facade root has not been set». І головне: асерт `assertPushed()` каже лише «job поставили в чергу», а не «job працює правильно».