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