Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Тестування

Питання з реальних співбесід з відповідями: 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() з переліком здатностей для токенів.

Докладніше в документації: Тестування JSON API

Керувати часом застосунку з тесту, а не чекати.

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. Коли провайдер змінить формат, тести лишаться зеленими - тому корисні контрактні перевірки чи хоча б моніторинг справжніх відповідей у проді.

Докладніше в документації: HTTP-клієнт: тестування

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: рев'ю помічає порушення вибірково й залежить від уважності, а тест перевіряє кожен коміт. Архітектурні рішення, записані тестом, переживають зміну команди - їх не треба переказувати новачкам.

Обережно: правило без причини дратує й обходиться. Кожне має відповідати реальній домовленості, а не смаку.

Докладніше в документації: Тестування: вступ