Питання на співбесіді: Тестування
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Factory (фабрика) генерує фейкові дані моделей через Faker. Незамінна в тестах і сідерах.
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'body' => fake()->paragraphs(3, true),
];
}
}
Post::factory()->count(10)->create(); // 10 записів у БД
Post::factory()->published()->make(); // стан + без збереження
«Стани» (states) дозволяють описати варіації, наприклад ->published() чи ->trashed().
Різниця в тому, скільки застосунку бере участь у тесті.
Feature-тест (tests/Feature) піднімає весь Laravel: маршрути, middleware, базу, контейнер. Він перевіряє поведінку так, як її бачить користувач:
it('creates an order', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/orders', ['product_id' => 1])
->assertRedirect('/orders');
expect($user->orders)->toHaveCount(1);
});
Unit-тест (tests/Unit) перевіряє один клас ізольовано й не завантажує застосунок - отже, там немає фасадів, конфігурації й бази. Він підходить для чистої логіки:
it('calculates the discount', function () {
expect((new PriceCalculator)->withDiscount(1000, 10))->toBe(900);
});
Як обирати: у Laravel-застосунку більшість цінних тестів - feature: вони ловлять помилки на стиках, де їх найбільше, і не ламаються від рефакторингу внутрішніх класів. Unit-тести - для алгоритмів, розрахунків і класів без залежностей від фреймворку, де вони швидкі й точно вказують на поломку.
Тест створюють командою php artisan make:test, з --unit для unit-тесту.
Скидати базу між тестами. Laravel дає для цього трейти.
RefreshDatabase - стандартний вибір. Він мігрує базу один раз на прогін, а кожен тест загортає в транзакцію й відкочує її наприкінці. Швидко, бо таблиці не перестворюються.
// tests/Pest.php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
LazilyRefreshDatabase - те саме, але мігрує лише тоді, коли тест справді звернувся до бази. Тести без бази стають швидшими.
DatabaseMigrations і DatabaseTruncation скидають базу повністю: перестворюють чи очищують таблиці. Значно повільніше - потрібні, коли код сам керує транзакціями чи з'єднаннями, і обгортка RefreshDatabase йому заважає.
Що ще ділять тести, крім бази:
- кеш і сесію - у тестах їх тримають на драйвері
array, що живе один запит; - статичні властивості й синглтони, які пережили тест;
- файли - тому для дисків беруть
Storage::fake().
Ознака проблеми: тести проходять поодинці, але падають разом чи в іншому порядку.
Докладніше в документації: Скидання бази після кожного тесту
Laravel має тестування з коробки поверх PHPUnit; популярна надбудова - Pest із лаконічним синтаксисом.
it('creates a post', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'title' => 'Hello',
]);
$response->assertRedirect();
$this->assertDatabaseHas('posts', ['title' => 'Hello']);
});
- Feature-тести перевіряють HTTP-флоу (більшість тестів); Unit - окремі класи ізольовано.
- Трейт
RefreshDatabaseізолює тести, відкочуючи зміни після кожного; дані генерують фабрики. actingAs($user)автентифікує користувача для захищених маршрутів.
Корисні асерції: assertStatus, assertSee, assertJson, assertDatabaseHas, assertRedirect, assertAuthenticated.
Мокінг - ізоляція від зовнішніх ефектів (API, листи, черги):
Mail::fake(); Mail::assertSent(OrderShipped::class);
Queue::fake(); Queue::assertPushed(ProcessPodcast::class);
Notification::fake(); Http::fake();
// мок сервісу через контейнер
$this->mock(PaymentGateway::class)
->shouldReceive('charge')->once()->andReturn(true);
Запуск: php artisan test, --filter=PostTest, --parallel (швидше).
Підмінити відповідний фасад фейком: він перехопить відправку, а тест перевірить, що вона сталася з правильними даними.
it('notifies the customer when the order ships', function () {
Notification::fake();
Mail::fake();
Queue::fake();
$order = Order::factory()->create();
$order->ship();
Notification::assertSentTo($order->customer, OrderShipped::class);
Mail::assertSent(InvoiceMail::class, fn ($mail) => $mail->hasTo($order->customer->email));
Queue::assertPushed(SyncWithWarehouse::class);
});
Які фейки є: Mail, Notification, Queue, Bus (бачить ще й ланцюжки й пакети), Event, Storage, Http, Process, Exceptions.
Пастки:
Event::fake()без аргументів глушить усі події, включно з подіями моделей, на яких можуть триматися генерація UUID чиObserver. Підробляйте лише потрібні:Event::fake([OrderShipped::class]).- Фейк не перевіряє, що робота виконується.
Queue::assertPushed()доводить, що завдання поставлено, але не що йогоhandle()працює. Код самого завдання тестують окремо - викликаючи його напряму. - Mailable можна перевірити без відправки:
(new InvoiceMail($order))->assertSeeInHtml($order->number).
HTTP-тести Laravel мають методи для JSON-запитів і перевірки відповіді.
it('creates a post via the API', function () {
Sanctum::actingAs(User::factory()->create(), ['posts:write']);
$this->postJson('/api/posts', ['title' => 'Черги в Laravel'])
->assertCreated()
->assertJsonPath('data.title', 'Черги в Laravel')
->assertJsonStructure(['data' => ['id', 'title', 'created_at']]);
});
it('rejects a post without a title', function () {
Sanctum::actingAs(User::factory()->create(), ['posts:write']);
$this->postJson('/api/posts', [])
->assertUnprocessable()
->assertInvalid(['title']);
});
Що варто знати:
postJson()ставитьAccept: application/json, тож помилки валідації приходять як 422 з JSON, а не редиректом.assertJsonPath()перевіряє одне значення за шляхом,assertJson()- частковий збіг,assertExactJson()- точний.- Флюентні перевірки дозволяють описати структуру з типами й гарантувати, що зайвих полів немає:
->assertJson(fn (AssertableJson $json) => $json
->where('data.id', $post->id)
->whereType('data.title', 'string')
->missing('data.author.password')
->etc());
- Автентифікація:
actingAs()для сесії,Sanctum::actingAs()з переліком здатностей для токенів.
Керувати часом застосунку з тесту, а не чекати.
it('expires a subscription after 30 days', function () {
$this->freezeTime();
$subscription = Subscription::factory()->create();
$this->travel(31)->days();
expect($subscription->fresh()->isExpired())->toBeTrue();
});
Інструменти:
freezeTime()- зупиняєnow(), щоб час не зсувався між рядками тесту.travel(5)->minutes(),travelTo($date),travelBack()- зсувають час застосунку.- Те саме вміє
Carbon::setTestNow().
Що варто винести з цього: такий тест працює, лише поки код бере час через now() чи Carbon. Виклики time(), date() чи new \DateTime() бачать справжній годинник і тест обійдуть.
Чистіший шлях для доменного коду - передавати момент явно:
public function isExpired(CarbonImmutable $now): bool
{
return $this->ends_at->lessThan($now);
}
Тоді тест взагалі не потребує підміни часу, а метод стає чистою функцією. Корисні випадки для перевірки: межі (рівно 30 днів), перехід доби, часові пояси й літній час.
Підмінити HTTP-клієнт Laravel фейковими відповідями, щоб тест не залежав від мережі й чужого сервісу.
it('imports exchange rates', function () {
Http::fake([
'api.rates.example/*' => Http::response(['usd' => 41.2], 200),
'*' => Http::response(status: 500),
]);
(new ImportRates)->handle();
expect(Rate::latest()->value('usd'))->toBe(41.2);
Http::assertSent(fn (Request $request) => str_contains($request->url(), 'api.rates.example')
&& $request->hasHeader('Authorization'));
});
Що варто знати:
- Шаблони URL з
*підмінюють групи запитів. Запит, що не збігся з жодним шаблоном, піде в мережу насправді. Http::preventStrayRequests()забороняє такі запити: будь-який непідмінений кидатиме виняток. Його вмикають уTestCaseчиPest.phpдля всього набору.Http::sequence()повертає різні відповіді на послідовні виклики - так перевіряють повтори після помилки.- Помилки теж тестують: таймаут, 429, 500 - саме там зазвичай ховаються баги обробки.
Межа фейку: він закріплює ваше уявлення про чужий API. Коли провайдер змінить формат, тести лишаться зеленими - тому корисні контрактні перевірки чи хоча б моніторинг справжніх відповідей у проді.
BDD розширює TDD, зміщуючи фокус на поведінку системи з погляду бізнесу/користувача, а не на технічні деталі. Сценарії описують зрозумілою мовою (Gherkin: Given-When-Then).
Scenario: Успішний вхід
Given користувач зареєстрований
When він вводить правильні дані
Then він потрапляє на дашборд
У PHP-екосистемі - Behat. Pest також заохочує «describe behavior» стиль:
it('redirects to dashboard after login', function () {
// ...
});
Цінність BDD - спільна мова між розробниками, QA та бізнесом; тести стають живою документацією очікуваної поведінки.
Для Livewire - хелпер livewire() (із pest-plugin-livewire):
livewire(SearchPosts::class)
->set('query', 'laravel')
->assertSee('Laravel Queues')
->call('clear')
->assertSet('query', '')
->assertDispatched('posts-updated');
Для Filament - спершу автентифікуйте користувача, потім тестуйте сторінки ресурсів:
livewire(CreatePost::class)
->fillForm(['title' => 'Hello'])
->call('create')
->assertHasNoFormErrors();
livewire(ListPosts::class)
->callAction(TestAction::make('publish')->table($post))
->assertNotified();
Ключові асерти: assertSet, assertSee, assertDispatched, assertHasFormErrors, assertCanSeeTableRecords.
Усі три замінюють справжню залежність у тесті, але перевіряють різне.
- Fake - робоча спрощена реалізація.
Mail::fake()справді приймає листи, а тест перевіряє результат: що пішло, кому, з чим. - Mock - об'єкт з очікуваннями, заданими заздалегідь: «метод
chargeбуде викликано раз з сумою 100». Не збулося - тест падає. - Spy - записує всі виклики, а перевірки пишуть після дії. Як mock, але читається в порядку «дія → перевірка».
$this->mock(PaymentGateway::class, function (MockInterface $mock) {
$mock->expects('charge')->with(10000)->andReturn(new Charge('ch_1'));
});
Чому надмір моків шкодить: тест, що мокає п'ять залежностей і перевіряє порядок їхніх викликів, фіксує реалізацію, а не поведінку. Будь-який рефакторинг ламає тест, хоча для користувача нічого не змінилось, - і команда звикає «лагодити тести», а не довіряти їм.
Практичні правила:
- Мокати межі системи - зовнішні API, платежі, пошту, час - а не власні класи.
- Віддавати перевагу фейкам і перевірці стану: що записано в базу, що повернуто.
- Не мокати те, чим не володієте, напряму: краще обгорнути чужий SDK своїм інтерфейсом і підміняти його.
- Хоча б кілька тестів мають проганяти справжній ланцюжок без підмін - інакше зелений набір нічого не каже про продакшен.
Спершу виміряти, потім лагодити найповільніше.
1. Знайти повільні тести:
php artisan test --profile
Зазвичай кілька тестів забирають більшу частину часу - справжні HTTP-запити, sleep(), сідер з тисячами рядків.
2. Паралельний запуск:
php artisan test --parallel
Laravel створює окрему тестову базу на кожен процес. Тести мають бути незалежними: спільні файли, кеш чи зовнішні ресурси під паралеллю дадуть «моргаючі» падіння.
3. Дешевші налаштування:
WithCachedConfig(Laravel 13) - конфігурація збирається один раз, а не на кожен тест;LazilyRefreshDatabase- тести без бази не мігрують її;- драйвери
arrayдля кешу й сесії,syncдля черги, низькийBCRYPT_ROUNDSдля хешування.
4. Менше роботи в самих тестах:
- створювати рівно ті дані, які перевіряються, а не викликати важкий сідер;
make()замістьcreate(), де база не потрібна;- підміняти мережу (
Http::fake()) і час (travel()), а не чекати.
5. Ширше: на CI розбивати набір між машинами (--shard у Pest), а локально запускати лише тести, яких торкнулися зміни (--tia у Pest 5).
Швидкий набір - не розкіш: тести, які йдуть 25 хвилин, розробники перестають запускати локально.
Зробити так, щоб N+1 ламав тест, а не тихо пригальмовував сторінку в проді.
1. Заборонити ліниве завантаження поза продакшеном:
// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());
Тепер звернення до незавантаженого зв'язку в циклі кидає LazyLoadingViolationException - і будь-який тест сторінки, що це робить, впаде. Часто вмикають разом Model::shouldBeStrict(), який ще й ловить звернення до відсутніх атрибутів.
2. Обмежити кількість запитів для критичної сторінки (Laravel 13):
it('loads the orders page in a fixed number of queries', function () {
Order::factory()->count(20)->hasItems(3)->create();
$this->expectsDatabaseQueryCount(4);
$this->actingAs(User::factory()->admin()->create())
->get('/admin/orders')
->assertOk();
});
Важливо: тестові дані мають бути «множинними». N+1 не видно на одному записі - один пост з одним коментарем дасть два запити і з with(), і без. Тест має створювати кілька записів, щоб різниця між «2 запити» і «21 запит» проявилася.
Альтернатива в Laravel 12+ - Model::automaticallyEagerLoadRelationships(), що сам довантажує зв'язки для всієї колекції. Але тест з лічильником запитів корисний і тоді: він фіксує, що сторінка не деградує з часом.
Це тести не поведінки, а структури коду: які класи де лежать, від чого залежать, що їм заборонено. У Pest вони пишуться функцією arch().
arch('models extend Eloquent')
->expect('App\Models')
->toExtend(Illuminate\Database\Eloquent\Model::class);
arch('no debugging leftovers')
->expect(['dd', 'dump', 'ray', 'var_dump'])
->not->toBeUsed();
arch('controllers stay thin')
->expect('App\Http\Controllers')
->not->toUse('Illuminate\Support\Facades\DB');
arch()->preset()->laravel();
arch()->preset()->security();
Що ними зручно фіксувати:
- забуті
dd()іdump(), які інакше потрапляють у продакшен; - межі шарів: домен не залежить від HTTP, контролери не пишуть сирі запити;
- угоди: усі завдання реалізують
ShouldQueue, усі enum - уApp\Enums, класиfinalчиreadonly, де так домовились; - небезпечні функції:
eval,exec,md5для паролів (пресетsecurity).
Навіщо, якщо є code review: рев'ю помічає порушення вибірково й залежить від уважності, а тест перевіряє кожен коміт. Архітектурні рішення, записані тестом, переживають зміну команди - їх не треба переказувати новачкам.
Обережно: правило без причини дратує й обходиться. Кожне має відповідати реальній домовленості, а не смаку.