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

Junior: питання на співбесіді з теми «Чиста архітектура й тестованість»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

Шарова архітектура ділить застосунок на горизонтальні шари з різною відповідальністю. Кожен шар залежить лише від шару під ним.

Класичні три шари:

  • представлення (presentation) - взаємодія з користувачем чи клієнтом: контролери, шаблони, Livewire-компоненти, ресурси API, консольні команди;
  • домен (бізнес-логіка) - правила предметної області: розрахунок ціни, перевірка, чи можна скасувати замовлення, нарахування знижок;
  • дані (data) - збереження й отримання даних: запити до бази, сторонні API, файлове сховище.

Часто між представленням і доменом виділяють ще шар застосунку (application/service layer) - сценарії використання: «оформити замовлення» координує доменну логіку, транзакцію, відправку листа.

Навіщо:

  • зрозуміло, де що шукати - бізнес-правило не розкидане по контролерах і шаблонах;
  • зміни локалізовані - новий формат API змінює шар представлення, а не бізнес-логіку;
  • повторне використання - той самий сценарій викликається з контролера, команди й джоби;
  • тестування - бізнес-логіку можна перевірити без HTTP.

У Laravel це виглядає приблизно так:

Http/Controllers, Livewire, Console    ← представлення
Actions / Services                     ← сценарії
Models (+ доменні класи, Enums)        ← домен і дані разом (Active Record)

Eloquent за патерном Active Record поєднує доменний об'єкт і доступ до даних - шари «домен» і «дані» тут свідомо злиті заради простоти.

Типові помилки:

  • «товстий контролер» - уся логіка в контролері, неможливо використати з команди чи протестувати окремо;
  • «анемічні шари» - сервіс, що лише викликає репозиторій, який лише викликає модель, - шари без змісту;
  • залежності не в той бік - модель викликає контролер чи шаблон.

Мартін Фаулер радить на рівні великої системи ділити спершу за предметними областями (замовлення, каталог, оплата), а шари - всередині кожної: інакше кожна зміна фічі зачіпає всі шари всього застосунку одночасно.

Докладніше в документації: Martin Fowler: Presentation Domain Data Layering

YAGNI (You Aren't Gonna Need It) - «вам це не знадобиться»: не реалізовувати можливість, доки вона справді не потрібна.

KISS (Keep It Simple) - обирати найпростіше рішення, що розв'язує задачу.

Обидва - про боротьбу з передчасною складністю, але з різних боків: YAGNI - про що будувати (не будувати зайвого), KISS - про як (не ускладнювати те, що будуєте).

Типові порушення YAGNI:

  • інтерфейс і три реалізації «на випадок, якщо колись зміниться платіжний провайдер»;
  • налаштування й прапорці, які ніхто не просив;
  • узагальнений «рушій правил» для двох правил знижок;
  • мікросервіси для застосунку з однією командою й десятком користувачів;
  • підтримка кількох баз даних, хоча застосунок ніколи не змінить базу.

Чому це дорого (за Фаулером):

  • вартість побудови - час на непотрібне замість потрібного зараз;
  • вартість затримки - потрібна функція виходить пізніше;
  • вартість підтримки - кожен рядок треба читати, тестувати, оновлювати, і він ускладнює зміни поруч;
  • вартість помилки прогнозу - коли можливість таки знадобиться, вона часто потрібна інакше, ніж її передбачили, і готову абстракцію доводиться ламати.

Важливе уточнення: YAGNI стосується можливостей, а не якості коду. Він не означає «не писати тестів», «не рефакторити» чи «не думати про структуру». Навпаки - чистий, протестований код дешево змінювати, коли нова вимога справді прийде. Саме це робить YAGNI безпечним.

Як це виглядає в Laravel-проєкті:

  • спершу - Eloquent напряму в діях, без репозиторіїв «на майбутнє»;
  • один клас-сервіс замість ієрархії стратегій, доки варіант один;
  • конфігурація в config/, а не адмінка налаштувань, доки її ніхто не змінює.

Коли передбачення виправдане: рішення, які дорого змінити потім - схема публічного API, формат даних, вибір основної бази, безпека. Тут думати наперед доречно; у внутрішньому коді - краще відкласти.

Докладніше в документації: Martin Fowler: Yagni

Проблема: клас сам створює свої залежності - і в тесті їх неможливо замінити.

class OrderService
{
    public function place(Order $order): void
    {
        $gateway = new StripeGateway(config('services.stripe.key'));   // справжній платіж у тесті?
        $gateway->charge($order->total);
    }
}

Впровадження залежностей - клас отримує залежності ззовні (зазвичай через конструктор):

class OrderService
{
    public function __construct(private PaymentGateway $gateway) {}

    public function place(Order $order): void
    {
        $this->gateway->charge($order->total);
    }
}

Сервіс-контейнер Laravel створить OrderService і підставить реалізацію PaymentGateway, зареєстровану в провайдері. А в тесті можна підставити тестовий дублер.

Види тестових дублерів (за Джерардом Месарошем):

  • dummy - об'єкт-заглушка, що лише заповнює параметр і не використовується;
  • stub - повертає заздалегідь задані відповіді («платіж завжди успішний»);
  • spy - запам'ятовує виклики, щоб потім перевірити, як його використовували;
  • mock - заздалегідь налаштовані очікування викликів, що перевіряються автоматично;
  • fake - робоча спрощена реалізація (база в пам'яті, платіжний шлюз, що зберігає транзакції в масиві).

У Laravel багато фейків уже готові:

Mail::fake();
Queue::fake();
Http::fake(['api.stripe.com/*' => Http::response(['status' => 'succeeded'])]);
Storage::fake('s3');

$this->app->instance(PaymentGateway::class, new FakePaymentGateway());

Чому fake часто кращі за mock: mock перевіряє, як код викликає залежність (порядок, аргументи), і ламається при будь-якому рефакторингу. Fake перевіряє результат («після оформлення в шлюзі є одна транзакція на 500 грн») - тести стійкіші.

Пастка надмірних моків: тест, де все замокано, перевіряє лише те, що код викликає моки так, як ви їх налаштували. Моки - для меж системи (сторонні API, пошта, час, випадковість), а не для кожного внутрішнього класу.

Докладніше в документації: Martin Fowler: Test Double

ADR (Architecture Decision Record) - короткий документ про одне архітектурне рішення: що вирішили, чому, які були альтернативи й наслідки.

Навіщо: через рік ніхто не пам'ятає, чому обрали саме Redis для черг, чому немає мікросервісів, чому гроші зберігаються в копійках. Нова людина в команді бачить дивне рішення і або боїться його чіпати, або «виправляє» - і повторює помилку, від якої колись свідомо відмовилися.

Типова структура (шаблон Майкла Найгарда):

# 7. Зберігати суми в копійках цілим числом

## Статус
Прийнято (2026-03-12)

## Контекст
Розрахунки з float давали розбіжності в копійку в звітах.
Декілька валют, у деяких - інша кількість знаків після коми.

## Рішення
Усі суми - BIGINT у мінімальних одиницях + код валюти.
Перетворення у форматований вигляд - лише при виведенні.

## Наслідки
+ точні розрахунки, прості порівняння
- міграція наявних колонок, зміна API (поле amount стає цілим)

Принципи:

  • один ADR - одне рішення;
  • незмінність: прийнятий ADR не редагують по суті. Якщо рішення змінилося - новий ADR, а старий отримує статус «Замінено ADR-15». Так зберігається історія мислення;
  • поруч із кодом - у репозиторії (docs/adr/0007-money-in-cents.md), рецензується в pull request, як код;
  • коротко - сторінка, а не трактат.

Що варто записувати: рішення, що дорого змінити або що виглядатимуть неочевидними: вибір фреймворку чи бази, структура модулів, формат API, підхід до автентифікації, відмова від чогось популярного («чому ми не використовуємо репозиторії»).

Що не варто: дрібні рішення, які видно з коду й легко змінити.

Додаткова користь: написання ADR змушує сформулювати альтернативи й компроміси - рішення стають обдуманішими ще до того, як їх зафіксували. А в рев'ю ADR команда обговорює ідею до тижнів реалізації.

Докладніше в документації: ADR GitHub organization

Тестова піраміда (Майк Кон) - модель розподілу тестів за рівнями:

        /\        E2E / UI          - мало: повільні, крихкі, дорогі
       /  \
      /----\      інтеграційні      - середньо
     /      \
    /--------\    модульні (unit)   - багато: швидкі, дешеві, точні

Логіка: чим вище рівень, тим тест повільніший, крихкіший і дорожчий у підтримці, але тим більше впевненості він дає, що система працює цілком. Тому основу складають швидкі модульні тести, а наскрізних - небагато, на головні сценарії.

«Тестовий трофей» (Кент Доддс), популярний у фронтенді:

     🏆  E2E               - кілька
    ----
   |    | інтеграційні     - НАЙБІЛЬШЕ
   |----|
    unit                   - трохи
  ======
  статичний аналіз         - основа: типи, лінтери

Аргумент: модульні тести окремих функцій часто перевіряють деталі реалізації й не ловлять помилок взаємодії, а сучасні інструменти зробили інтеграційні тести досить швидкими. «Чим більше тести схожі на те, як використовують програму, тим більше впевненості вони дають».

Як це виглядає в Laravel-проєкті:

  • статичний аналіз - PHPStan/Larastan, Pint: ловлять цілі класи помилок без жодного тесту;
  • feature-тести (основа більшості Laravel-проєктів) - HTTP-запит до маршруту з реальною базою (RefreshDatabase), фейками пошти й черг. Фактично інтеграційні, але досить швидкі;
  • unit-тести - для чистої логіки зі складними розгалуженнями: розрахунок ціни, парсери, правила;
  • браузерні тести (Pest browser, Dusk) - для критичних сценаріїв з JavaScript.

Де архітектура має значення: якщо бізнес-логіка розмазана по контролерах і моделях, перевірити її можна лише дорогими тестами верхнього рівня. Винесена в окремі класи з явними залежностями - тестується дешево й точно.

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

Докладніше в документації: Martin Fowler: The Practical Test Pyramid