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, формат даних, вибір основної бази, безпека. Тут думати наперед доречно; у внутрішньому коді - краще відкласти.
Проблема: клас сам створює свої залежності - і в тесті їх неможливо замінити.
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, пошта, час, випадковість), а не для кожного внутрішнього класу.
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 команда обговорює ідею до тижнів реалізації.
Тестова піраміда (Майк Кон) - модель розподілу тестів за рівнями:
/\ E2E / UI - мало: повільні, крихкі, дорогі
/ \
/----\ інтеграційні - середньо
/ \
/--------\ модульні (unit) - багато: швидкі, дешеві, точні
Логіка: чим вище рівень, тим тест повільніший, крихкіший і дорожчий у підтримці, але тим більше впевненості він дає, що система працює цілком. Тому основу складають швидкі модульні тести, а наскрізних - небагато, на головні сценарії.
«Тестовий трофей» (Кент Доддс), популярний у фронтенді:
🏆 E2E - кілька
----
| | інтеграційні - НАЙБІЛЬШЕ
|----|
unit - трохи
======
статичний аналіз - основа: типи, лінтери
Аргумент: модульні тести окремих функцій часто перевіряють деталі реалізації й не ловлять помилок взаємодії, а сучасні інструменти зробили інтеграційні тести досить швидкими. «Чим більше тести схожі на те, як використовують програму, тим більше впевненості вони дають».
Як це виглядає в Laravel-проєкті:
- статичний аналіз - PHPStan/Larastan, Pint: ловлять цілі класи помилок без жодного тесту;
- feature-тести (основа більшості Laravel-проєктів) - HTTP-запит до маршруту з реальною базою (
RefreshDatabase), фейками пошти й черг. Фактично інтеграційні, але досить швидкі; - unit-тести - для чистої логіки зі складними розгалуженнями: розрахунок ціни, парсери, правила;
- браузерні тести (Pest browser, Dusk) - для критичних сценаріїв з JavaScript.
Де архітектура має значення: якщо бізнес-логіка розмазана по контролерах і моделях, перевірити її можна лише дорогими тестами верхнього рівня. Винесена в окремі класи з явними залежностями - тестується дешево й точно.
Правильний розподіл - той, що дає впевненість у змінах за прийнятний час. Обидві моделі - орієнтири, а не закони: важливо, щоб набір тестів запускався швидко, був стабільним і ловив реальні регресії.
Докладніше в документації: Martin Fowler: The Practical Test Pyramid